diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePath.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePath.java
index abd3e084d3..a6edd906cc 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePath.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePath.java
@@ -16,25 +16,38 @@
package org.springframework.data.relational.core.mapping;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
+import java.util.function.BinaryOperator;
import java.util.function.Predicate;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import org.springframework.data.mapping.PersistentProperty;
import org.springframework.data.mapping.PersistentPropertyPath;
+import org.springframework.data.mapping.PropertyHandler;
+import org.springframework.data.relational.core.sql.Column;
import org.springframework.data.relational.core.sql.SqlIdentifier;
+import org.springframework.data.relational.core.sql.Table;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* Represents a path within an aggregate starting from the aggregate root. The path can be iterated from the leaf to its
* root.
+ *
+ * It implements {@link Comparable} so that collections of {@code AggregatePath} instances can be sorted in a consistent
+ * way.
*
- * @since 3.2
* @author Jens Schauder
* @author Mark Paluch
+ * @since 3.2
*/
-public interface AggregatePath extends Iterable {
+public interface AggregatePath extends Iterable, Comparable {
/**
* Returns the path that has the same beginning but is one segment shorter than this path.
@@ -52,6 +65,15 @@ public interface AggregatePath extends Iterable {
*/
AggregatePath append(RelationalPersistentProperty property);
+ /**
+ * Creates a new path by extending the current path by the path passed as an argument.
+ *
+ * @param path must not be {@literal null}.
+ * @return Guaranteed to be not {@literal null}.
+ * @since 4.0
+ */
+ AggregatePath append(AggregatePath path);
+
/**
* @return {@literal true} if this is a root path for the underlying type.
*/
@@ -227,42 +249,61 @@ default Stream stream() {
*/
AggregatePath getIdDefiningParentPath();
- record TableInfo(
-
- /*
- * The fully qualified name of the table this path is tied to or of the longest ancestor path that is actually
- * tied to a table.
- */
- SqlIdentifier qualifiedTableName,
-
- /*
- * The alias used for the table on which this path is based.
- */
- @Nullable SqlIdentifier tableAlias,
-
- ColumnInfo reverseColumnInfo,
-
- /*
- * The column used for the list index or map key of the leaf property of this path.
- */
- @Nullable ColumnInfo qualifierColumnInfo,
+ /**
+ * The path resulting from removing the first element of the {@link AggregatePath}.
+ *
+ * @return {@literal null} for any {@link AggregatePath} having less than two elements.
+ * @since 4.0
+ */
+ @Nullable
+ AggregatePath getTail();
- /*
- * The type of the qualifier column of the leaf property of this path or {@literal null} if this is not
- * applicable.
- */
- @Nullable Class> qualifierColumnType,
+ /**
+ * Subtract the {@literal basePath} from {@literal this} {@literal AggregatePath} by removing the {@literal basePath}
+ * from the beginning of {@literal this}.
+ *
+ * @param basePath the path to be removed.
+ * @return an AggregatePath that ends like the original {@literal AggregatePath} but has {@literal basePath} removed
+ * from the beginning.
+ * @since 4.0
+ */
+ @Nullable
+ AggregatePath subtract(@Nullable AggregatePath basePath);
- /*
- * The column name of the id column of the ancestor path that represents an actual table.
- */
- SqlIdentifier idColumnName,
+ /**
+ * Compares this {@code AggregatePath} to another {@code AggregatePath} based on their dot path notation.
+ *
+ * This is used to get {@code AggregatePath} instances sorted in a consistent way. Since this order affects generated
+ * SQL this also affects query caches and similar.
+ *
+ * @param other the {@code AggregatePath} to compare to. Must not be {@literal null}.
+ * @return a negative integer, zero, or a positive integer as this object's path is less than, equal to, or greater
+ * than the specified object's path.
+ * @since 4.0
+ */
+ @Override
+ default int compareTo(AggregatePath other) {
+ return toDotPath().compareTo(other.toDotPath());
+ }
- /*
- * If the table owning ancestor has an id the column name of that id property is returned. Otherwise the reverse
- * column is returned.
- */
- SqlIdentifier effectiveIdColumnName) {
+ /**
+ * Information about a table underlying an entity.
+ *
+ * @param qualifiedTableName the fully qualified name of the table this path is tied to or of the longest ancestor
+ * path that is actually tied to a table. Must not be {@literal null}.
+ * @param tableAlias the alias used for the table on which this path is based. May be {@literal null}.
+ * @param backReferenceColumnInfos information about the columns used to reference back to the owning entity. Must not
+ * be {@literal null}. Since 3.5.
+ * @param qualifierColumnInfo the column used for the list index or map key of the leaf property of this path. May be
+ * {@literal null}.
+ * @param qualifierColumnType the type of the qualifier column of the leaf property of this path or {@literal null} if
+ * this is not applicable. May be {@literal null}.
+ * @param idColumnInfos the column name of the id column of the ancestor path that represents an actual table. Must
+ * not be {@literal null}.
+ */
+ record TableInfo(SqlIdentifier qualifiedTableName, @Nullable SqlIdentifier tableAlias,
+ ColumnInfos backReferenceColumnInfos, @Nullable ColumnInfo qualifierColumnInfo,
+ @Nullable Class> qualifierColumnType, ColumnInfos idColumnInfos) {
static TableInfo of(AggregatePath path) {
@@ -273,18 +314,7 @@ static TableInfo of(AggregatePath path) {
SqlIdentifier tableAlias = tableOwner.isRoot() ? null : AggregatePathTableUtils.constructTableAlias(tableOwner);
- ColumnInfo reverseColumnInfo = null;
- if (!tableOwner.isRoot()) {
-
- AggregatePath idDefiningParentPath = tableOwner.getIdDefiningParentPath();
- RelationalPersistentProperty leafProperty = tableOwner.getRequiredLeafProperty();
-
- SqlIdentifier reverseColumnName = leafProperty
- .getReverseColumnName(idDefiningParentPath.getRequiredLeafEntity());
-
- reverseColumnInfo = new ColumnInfo(reverseColumnName,
- AggregatePathTableUtils.prefixWithTableAlias(path, reverseColumnName));
- }
+ ColumnInfos backReferenceColumnInfos = computeBackReferenceColumnInfos(path);
ColumnInfo qualifierColumnInfo = null;
if (!path.isRoot()) {
@@ -300,27 +330,128 @@ static TableInfo of(AggregatePath path) {
qualifierColumnType = path.getRequiredLeafProperty().getQualifierColumnType();
}
- SqlIdentifier idColumnName = leafEntity.hasIdProperty() ? leafEntity.getIdColumn() : null;
+ ColumnInfos idColumnInfos = computeIdColumnInfos(tableOwner, leafEntity);
- SqlIdentifier effectiveIdColumnName = tableOwner.isRoot() ? idColumnName : reverseColumnInfo.name();
+ return new TableInfo(qualifiedTableName, tableAlias, backReferenceColumnInfos, qualifierColumnInfo,
+ qualifierColumnType, idColumnInfos);
- return new TableInfo(qualifiedTableName, tableAlias, reverseColumnInfo, qualifierColumnInfo, qualifierColumnType,
- idColumnName, effectiveIdColumnName);
+ }
+ private static ColumnInfos computeIdColumnInfos(AggregatePath tableOwner,
+ RelationalPersistentEntity> leafEntity) {
+
+ ColumnInfos idColumnInfos = ColumnInfos.empty(tableOwner);
+ if (!leafEntity.hasIdProperty()) {
+ return idColumnInfos;
+ }
+
+ RelationalPersistentProperty idProperty = leafEntity.getRequiredIdProperty();
+ AggregatePath idPath = tableOwner.append(idProperty);
+
+ if (idProperty.isEntity()) {
+ ColumInfosBuilder ciBuilder = new ColumInfosBuilder(idPath);
+ idPath.getRequiredLeafEntity().doWithProperties((PropertyHandler) p -> {
+ AggregatePath idElementPath = idPath.append(p);
+ ciBuilder.add(idElementPath, ColumnInfo.of(idElementPath));
+ });
+ return ciBuilder.build();
+ } else {
+ ColumInfosBuilder ciBuilder = new ColumInfosBuilder(idPath.getParentPath());
+ ciBuilder.add(idPath, ColumnInfo.of(idPath));
+ return ciBuilder.build();
+ }
}
- }
- record ColumnInfo(
+ private static ColumnInfos computeBackReferenceColumnInfos(AggregatePath path) {
+
+ AggregatePath tableOwner = AggregatePathTraversal.getTableOwningPath(path);
+
+ if (tableOwner.isRoot()) {
+ return ColumnInfos.empty(tableOwner);
+ }
+
+ AggregatePath idDefiningParentPath = tableOwner.getIdDefiningParentPath();
+ RelationalPersistentProperty idProperty = idDefiningParentPath.getRequiredLeafEntity().getIdProperty();
+
+ AggregatePath basePath = idProperty != null && idProperty.isEntity() ? idDefiningParentPath.append(idProperty)
+ : idDefiningParentPath;
+ ColumInfosBuilder ciBuilder = new ColumInfosBuilder(basePath);
+
+ if (idProperty != null && idProperty.isEntity()) {
+
+ RelationalPersistentEntity> idEntity = basePath.getRequiredLeafEntity();
+ idEntity.doWithProperties((PropertyHandler) p -> {
+ AggregatePath idElementPath = basePath.append(p);
+ SqlIdentifier name = idElementPath.getColumnInfo().name();
+ name = name.transform(n -> idDefiningParentPath.getTableInfo().qualifiedTableName.getReference() + "_" + n);
+
+ ciBuilder.add(idElementPath, name, name);
+ });
- /* The name of the column used to represent this property in the database. */
- SqlIdentifier name, /* The alias for the column used to represent this property in the database. */
- SqlIdentifier alias) {
+ } else {
+
+ RelationalPersistentProperty leafProperty = tableOwner.getRequiredLeafProperty();
+ SqlIdentifier reverseColumnName = leafProperty
+ .getReverseColumnName(idDefiningParentPath.getRequiredLeafEntity());
+ SqlIdentifier alias = AggregatePathTableUtils.prefixWithTableAlias(path, reverseColumnName);
+
+ if (idProperty != null) {
+ ciBuilder.add(idProperty, reverseColumnName, alias);
+ } else {
+ ciBuilder.add(idDefiningParentPath, reverseColumnName, alias);
+ }
+ }
+ return ciBuilder.build();
+ }
+
+ @Override
+ public ColumnInfos backReferenceColumnInfos() {
+ return backReferenceColumnInfos;
+ }
+
+ /**
+ * Returns the unique {@link ColumnInfo} referencing the parent table, if such exists.
+ *
+ * @return guaranteed not to be {@literal null}.
+ * @throws IllegalStateException if there is not exactly one back referencing column.
+ * @deprecated since there might be more than one reverse column instead. Use {@link #backReferenceColumnInfos()}
+ * instead.
+ */
+ @Deprecated(forRemoval = true)
+ public ColumnInfo reverseColumnInfo() {
+ return backReferenceColumnInfos.unique();
+ }
+
+ /**
+ * The id columns of the underlying table.
+ *
+ * These might be:
+ *
+ * - the columns representing the id of the entity in question.
+ * - the columns representing the id of a parent entity, which _owns_ the table. Note that this case also covers
+ * the first case.
+ * - or the backReferenceColumns.
+ *
+ *
+ * @return ColumnInfos representing the effective id of this entity. Guaranteed not to be {@literal null}.
+ */
+ public ColumnInfos effectiveIdColumnInfos() {
+ return backReferenceColumnInfos.columnInfos.isEmpty() ? idColumnInfos : backReferenceColumnInfos;
+ }
+ }
+
+ /**
+ * @param name the name of the column used to represent this property in the database.
+ * @param alias the alias for the column used to represent this property in the database.
+ * @since 3.2
+ */
+ record ColumnInfo(SqlIdentifier name, SqlIdentifier alias) {
/**
* Create a {@link ColumnInfo} from an aggregate path. ColumnInfo can be created for simple type single-value
* properties only.
*
- * @param path
+ * @param path the path to the {@literal ColumnInfo} for.
* @return the {@link ColumnInfo}.
* @throws IllegalArgumentException if the path is {@link #isRoot()}, {@link #isEmbedded()} or
* {@link #isMultiValued()}.
@@ -338,4 +469,176 @@ static ColumnInfo of(AggregatePath path) {
return new ColumnInfo(columnName, AggregatePathTableUtils.prefixWithTableAlias(path, columnName));
}
}
+
+ /**
+ * A group of {@link ColumnInfo} values referenced by there respective {@link AggregatePath}. It is used in a similar
+ * way as {@literal ColumnInfo} when one needs to consider more than a single column. This is relevant for composite
+ * ids and references to such ids.
+ *
+ * @author Jens Schauder
+ * @since 4.0
+ */
+ class ColumnInfos {
+
+ private final AggregatePath basePath;
+ private final Map columnInfos;
+ private final Map> columnCache = new HashMap<>();
+
+ /**
+ * Creates a new ColumnInfos instances based on the arguments.
+ *
+ * @param basePath The path on which all other paths in the other argument are based on. For the typical case of a
+ * composite id, this would be the path to the composite ids.
+ * @param columnInfos A map, mapping {@literal AggregatePath} instances to the respective {@literal ColumnInfo}
+ */
+ ColumnInfos(AggregatePath basePath, Map columnInfos) {
+
+ this.basePath = basePath;
+ this.columnInfos = columnInfos;
+ }
+
+ /**
+ * An empty {@literal ColumnInfos} instance with a fixed base path. Useful as a base when collecting
+ * {@link ColumnInfo} instances into an {@literal ColumnInfos} instance.
+ *
+ * @param basePath The path on which paths in the {@literal ColumnInfos} or derived objects will be based on.
+ * @return an empty instance save the {@literal basePath}.
+ */
+ public static ColumnInfos empty(AggregatePath basePath) {
+ return new ColumnInfos(basePath, new HashMap<>());
+ }
+
+ /**
+ * If this instance contains exactly one {@link ColumnInfo} it will be returned.
+ *
+ * @return the unique {@literal ColumnInfo} if present.
+ * @throws IllegalStateException if the number of contained {@literal ColumnInfo} instances is not exactly 1.
+ */
+ public ColumnInfo unique() {
+
+ Collection values = columnInfos.values();
+ Assert.state(values.size() == 1, "ColumnInfo is not unique");
+ return values.iterator().next();
+ }
+
+ /**
+ * Any of the contained {@link ColumnInfo} instances.
+ *
+ * @return a {@link ColumnInfo} instance.
+ * @throws java.util.NoSuchElementException if no instance is available.
+ */
+ public ColumnInfo any() {
+
+ Collection values = columnInfos.values();
+ return values.iterator().next();
+ }
+
+ /**
+ * Checks if {@literal this} instance is empty, i.e. does not contain any {@link ColumnInfo} instance.
+ *
+ * @return {@literal true} iff the collection of {@literal ColumnInfo} is empty.
+ */
+ public boolean isEmpty() {
+ return columnInfos.isEmpty();
+ }
+
+ /**
+ * Converts the given {@link Table} into a list of {@link Column}s. This method retrieves and caches the list of
+ * columns for the specified table. If the columns are not already cached, it computes the list by mapping
+ * {@code columnInfos} to their corresponding {@link Column} in the provided table and then stores the result in the
+ * cache.
+ *
+ * @param table the {@link Table} for which the columns should be generated; must not be {@literal null}.
+ * @return a list of {@link Column}s associated with the specified {@link Table}. Guaranteed no to be
+ * {@literal null}.
+ */
+ public List toColumnList(Table table) {
+
+ return columnCache.computeIfAbsent(table,
+ t -> columnInfos.values().stream().map(columnInfo -> t.column(columnInfo.name)).toList());
+ }
+
+ /**
+ * Performs a {@link Stream#reduce(Object, BiFunction, BinaryOperator)} on {@link ColumnInfo} and
+ * {@link AggregatePath} to reduce the results into a single {@code T} return value.
+ *
+ * If {@code ColumnInfos} is empty, then {@code identity} is returned. Without invoking {@code combiner}. The
+ * {@link BinaryOperator combiner} is called with the current state (or initial {@code identity}) and the
+ * accumulated {@code T} state to combine both into a single return value.
+ *
+ * @param identity the identity (initial) value for the combiner function.
+ * @param accumulator an associative, non-interfering (free of side effects), stateless function for incorporating
+ * an additional element into a result.
+ * @param combiner an associative, non-interfering, stateless function for combining two values, which must be
+ * compatible with the {@code accumulator} function.
+ * @param type of the result.
+ * @return result of the function.
+ * @since 3.5
+ */
+ public T reduce(T identity, BiFunction accumulator, BinaryOperator combiner) {
+
+ T result = identity;
+
+ for (Map.Entry entry : columnInfos.entrySet()) {
+
+ T mapped = accumulator.apply(entry.getKey(), entry.getValue());
+ result = combiner.apply(result, mapped);
+ }
+
+ return result;
+ }
+
+ /**
+ * Calls the consumer for each pair of {@link AggregatePath} and {@literal ColumnInfo}.
+ *
+ * @param consumer the function to call.
+ */
+ public void forEach(BiConsumer consumer) {
+ columnInfos.forEach(consumer);
+ }
+
+ /**
+ * Calls the {@literal mapper} for each pair one pair of {@link AggregatePath} and {@link ColumnInfo}, if there is
+ * any.
+ *
+ * @param mapper the function to call.
+ * @return the result of the mapper
+ * @throws java.util.NoSuchElementException if this {@literal ColumnInfo} is empty.
+ */
+ public T any(BiFunction mapper) {
+
+ Map.Entry any = columnInfos.entrySet().iterator().next();
+ return mapper.apply(any.getKey(), any.getValue());
+ }
+
+ /**
+ * Gets the {@link ColumnInfo} for the provided {@link AggregatePath}
+ *
+ * @param path for which to return the {@literal ColumnInfo}
+ * @return {@literal ColumnInfo} for the given path.
+ */
+ public ColumnInfo get(AggregatePath path) {
+ return columnInfos.get(path);
+ }
+
+ /**
+ * Constructs an {@link AggregatePath} from the {@literal basePath} and the provided argument.
+ *
+ * @param ap {@literal AggregatePath} to be appended to the {@literal basePath}.
+ * @return the combined (@literal AggregatePath}
+ */
+ public AggregatePath fullPath(AggregatePath ap) {
+ return basePath.append(ap);
+ }
+
+ /**
+ * Number of {@literal ColumnInfo} elements in this instance.
+ *
+ * @return the size of the collection of {@literal ColumnInfo}.
+ */
+ public int size() {
+ return columnInfos.size();
+ }
+ }
+
}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntity.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntity.java
index 99b48363fc..e216ee4865 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntity.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntity.java
@@ -154,6 +154,7 @@ public SqlIdentifier getQualifiedTableName() {
}
@Override
+ @Deprecated(forRemoval = true)
public SqlIdentifier getIdColumn() {
return getRequiredIdProperty().getColumnName();
}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/ColumInfosBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/ColumInfosBuilder.java
new file mode 100644
index 0000000000..2e5c290325
--- /dev/null
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/ColumInfosBuilder.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.relational.core.mapping;
+
+import java.util.Map;
+import java.util.TreeMap;
+
+import org.springframework.data.relational.core.sql.SqlIdentifier;
+
+/**
+ * A builder for {@link AggregatePath.ColumnInfos} instances.
+ *
+ * @author Jens Schauder
+ * @since 4.0
+ */
+class ColumInfosBuilder {
+
+ private final AggregatePath basePath;
+ private final Map columnInfoMap = new TreeMap<>();
+
+ /**
+ * Start construction with just the {@literal basePath} which all other paths are build upon.
+ *
+ * @param basePath must not be null.
+ */
+ ColumInfosBuilder(AggregatePath basePath) {
+ this.basePath = basePath;
+ }
+
+ /**
+ * Adds a {@link AggregatePath.ColumnInfo} to the {@link AggregatePath.ColumnInfos} under construction.
+ *
+ * @param path referencing the {@literal ColumnInfo}.
+ * @param name of the column.
+ * @param alias alias for the column.
+ */
+ void add(AggregatePath path, SqlIdentifier name, SqlIdentifier alias) {
+ add(path, new AggregatePath.ColumnInfo(name, alias));
+ }
+
+ /**
+ * Adds a {@link AggregatePath.ColumnInfo} to the {@link AggregatePath.ColumnInfos} under construction.
+ *
+ * @param property referencing the {@literal ColumnInfo}.
+ * @param name of the column.
+ * @param alias alias for the column.
+ */
+ void add(RelationalPersistentProperty property, SqlIdentifier name, SqlIdentifier alias) {
+ add(basePath.append(property), name, alias);
+ }
+
+ /**
+ * Adds a {@link AggregatePath.ColumnInfo} to the {@link AggregatePath.ColumnInfos} under construction.
+ *
+ * @param path the path referencing the {@literal ColumnInfo}
+ * @param columnInfo the {@literal ColumnInfo} added.
+ */
+ void add(AggregatePath path, AggregatePath.ColumnInfo columnInfo) {
+ columnInfoMap.put(path.subtract(basePath), columnInfo);
+ }
+
+ /**
+ * Build the final {@link AggregatePath.ColumnInfos} instance.
+ *
+ * @return a {@literal ColumnInfos} instance containing all the added {@link AggregatePath.ColumnInfo} instances.
+ */
+ AggregatePath.ColumnInfos build() {
+ return new AggregatePath.ColumnInfos(basePath, columnInfoMap);
+ }
+
+}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/DefaultAggregatePath.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/DefaultAggregatePath.java
index b0bcc78cb2..dd264dbcca 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/DefaultAggregatePath.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/DefaultAggregatePath.java
@@ -97,6 +97,20 @@ public AggregatePath append(RelationalPersistentProperty property) {
return nestedCache.get(property);
}
+ @Override
+ public AggregatePath append(AggregatePath path) {
+
+ if (path.isRoot()) {
+ return this;
+ }
+
+ RelationalPersistentProperty baseProperty = path.getRequiredBaseProperty();
+ AggregatePath appended = append(baseProperty);
+ AggregatePath tail = path.getTail();
+ return tail == null ? appended : appended.append(tail);
+
+ }
+
private AggregatePath doGetAggegatePath(RelationalPersistentProperty property) {
PersistentPropertyPath extends RelationalPersistentProperty> newPath = isRoot() //
@@ -194,6 +208,47 @@ public AggregatePath getIdDefiningParentPath() {
return AggregatePathTraversal.getIdDefiningPath(this);
}
+ @Override
+ public AggregatePath getTail() {
+
+ if (getLength() <= 2) {
+ return null;
+ }
+
+ AggregatePath tail = null;
+ for (RelationalPersistentProperty prop : this.path) {
+ if (tail == null) {
+ tail = context.getAggregatePath(context.getPersistentEntity(prop));
+ } else {
+ tail = tail.append(prop);
+ }
+ }
+ return tail;
+ }
+
+ @Override
+ @Nullable
+ public AggregatePath subtract(@Nullable AggregatePath basePath) {
+
+ if (basePath == null || basePath.isRoot()) {
+ return this;
+ }
+
+ if (this.isRoot()) {
+ throw new IllegalStateException("Can't subtract from root path");
+ }
+
+ if (basePath.getRequiredBaseProperty().equals(getRequiredBaseProperty())) {
+ AggregatePath tail = this.getTail();
+ if (tail == null) {
+ return null;
+ }
+ return tail.subtract(basePath.getTail());
+ }
+
+ throw new IllegalStateException("Can't subtract [%s] from [%s]".formatted(basePath, this));
+ }
+
/**
* Finds and returns the longest path with ich identical or an ancestor to the current path and maps directly to a
* table.
@@ -240,7 +295,6 @@ public int hashCode() {
return Objects.hash(context, rootType, path);
}
-
@Override
public String toString() {
return "AggregatePath["
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java
index 27359f7592..6a3befcdf8 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java
@@ -51,6 +51,7 @@ public SqlIdentifier getTableName() {
}
@Override
+ @Deprecated(forRemoval = true)
public SqlIdentifier getIdColumn() {
throw new MappingException("Embedded entity does not have an id column");
}
@@ -106,13 +107,6 @@ public String getName() {
return delegate.getName();
}
- @Override
- @Deprecated
- @Nullable
- public PreferredConstructor getPersistenceConstructor() {
- return delegate.getPersistenceConstructor();
- }
-
@Override
@Nullable
public InstanceCreatorMetadata getInstanceCreatorMetadata() {
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java
index eb6409e74e..47bd819900 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java
@@ -148,9 +148,9 @@ protected RelationalPersistentProperty createPersistentProperty(Property propert
}
/**
- * @since 3.2
* @return iff single query loading is enabled.
* @see #setSingleQueryLoadingEnabled(boolean)
+ * @since 3.2
*/
public boolean isSingleQueryLoadingEnabled() {
return singleQueryLoadingEnabled;
@@ -161,8 +161,8 @@ public boolean isSingleQueryLoadingEnabled() {
* {@link org.springframework.data.relational.core.dialect.Dialect} supports it, Spring Data JDBC will try to use
* Single Query Loading if possible.
*
- * @since 3.2
* @param singleQueryLoadingEnabled
+ * @since 3.2
*/
public void setSingleQueryLoadingEnabled(boolean singleQueryLoadingEnabled) {
this.singleQueryLoadingEnabled = singleQueryLoadingEnabled;
@@ -217,7 +217,6 @@ private record AggregatePathCacheKey(RelationalPersistentEntity> root,
* Create a new AggregatePathCacheKey for a root entity.
*
* @param root the root entity.
- * @return
*/
static AggregatePathCacheKey of(RelationalPersistentEntity> root) {
return new AggregatePathCacheKey(root, null);
@@ -226,8 +225,7 @@ static AggregatePathCacheKey of(RelationalPersistentEntity> root) {
/**
* Create a new AggregatePathCacheKey for a property path.
*
- * @param path
- * @return
+ * @param path {@Literal AggregatePath} to obtain a cache key for.
*/
static AggregatePathCacheKey of(PersistentPropertyPath extends RelationalPersistentProperty> path) {
return new AggregatePathCacheKey(path.getBaseProperty().getOwner(), path);
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPersistentEntity.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPersistentEntity.java
index 49e9b929c1..7cc9fdc9ba 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPersistentEntity.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPersistentEntity.java
@@ -50,7 +50,10 @@ default SqlIdentifier getQualifiedTableName() {
* Returns the column representing the identifier.
*
* @return will never be {@literal null}.
+ * @deprecated because an entity may have multiple id columns. Use
+ * {@code AggregatePath.getTableInfo().getIdColumnInfos()} instead.
*/
+ @Deprecated(forRemoval = true)
SqlIdentifier getIdColumn();
}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AnalyticFunction.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AnalyticFunction.java
index b3af3e1e86..fb4edc9a9e 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AnalyticFunction.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AnalyticFunction.java
@@ -16,6 +16,7 @@
package org.springframework.data.relational.core.sql;
import java.util.Arrays;
+import java.util.Collection;
/**
* Represents an analytic function, also known as windowing function
@@ -44,18 +45,62 @@ private AnalyticFunction(SimpleFunction function, Partition partition, OrderBy o
this.orderBy = orderBy;
}
+ /**
+ * Specify the {@literal PARTITION BY} clause of an analytic function
+ *
+ * @param partitionBy Typically, column but other expressions are fine to.
+ * @return a new {@literal AnalyticFunction} is partitioned by the given expressions, overwriting any expression
+ * previously present.
+ */
public AnalyticFunction partitionBy(Expression... partitionBy) {
-
return new AnalyticFunction(function, new Partition(partitionBy), orderBy);
}
+ /**
+ * Specify the {@literal PARTITION BY} clause of an analytic function
+ *
+ * @param partitionBy Typically, column but other expressions are fine to.
+ * @return a new {@literal AnalyticFunction} is partitioned by the given expressions, overwriting any expression
+ * previously present.
+ * @since 4.0
+ */
+ public AnalyticFunction partitionBy(Collection extends Expression> partitionBy) {
+ return partitionBy(partitionBy.toArray(new Expression[0]));
+ }
+
+ /**
+ * Specify the {@literal ORDER BY} clause of an analytic function
+ *
+ * @param orderBy Typically, column but other expressions are fine to.
+ * @return a new {@literal AnalyticFunction} is ordered by the given expressions, overwriting any expression
+ * previously present.
+ */
public AnalyticFunction orderBy(OrderByField... orderBy) {
return new AnalyticFunction(function, partition, new OrderBy(orderBy));
}
- public AnalyticFunction orderBy(Expression... orderByExpression) {
+ /**
+ * Specify the {@literal ORDER BY} clause of an analytic function
+ *
+ * @param orderBy Typically, column but other expressions are fine to.
+ * @return a new {@literal AnalyticFunction} is ordered by the given expressions, overwriting any expression
+ * previously present.
+ * @since 4.0
+ */
+ public AnalyticFunction orderBy(Collection extends Expression> orderBy) {
+ return orderBy(orderBy.toArray(new Expression[0]));
+ }
+
+ /**
+ * Specify the {@literal ORDER BY} clause of an analytic function
+ *
+ * @param orderBy array of {@link Expression}. Typically, column but other expressions are fine to.
+ * @return a new {@literal AnalyticFunction} is ordered by the given expressions, overwriting any expression
+ * previously present.
+ */
+ public AnalyticFunction orderBy(Expression... orderBy) {
- final OrderByField[] orderByFields = Arrays.stream(orderByExpression) //
+ final OrderByField[] orderByFields = Arrays.stream(orderBy) //
.map(OrderByField::from) //
.toArray(OrderByField[]::new);
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Conditions.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Conditions.java
index aa7f4e70e7..c5f4fe0d88 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Conditions.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Conditions.java
@@ -247,7 +247,7 @@ public static In in(Expression columnOrExpression, Expression... expressions) {
* @param subselect the subselect.
* @return the {@link In} condition.
*/
- public static In in(Column column, Select subselect) {
+ public static In in(Expression column, Select subselect) {
Assert.notNull(column, "Column must not be null");
Assert.notNull(subselect, "Subselect must not be null");
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Expressions.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Expressions.java
index 328c37218a..42176e1e55 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Expressions.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Expressions.java
@@ -15,15 +15,17 @@
*/
package org.springframework.data.relational.core.sql;
+import java.util.List;
+
/**
* Factory for common {@link Expression}s.
*
* @author Mark Paluch
* @author Jens Schauder
- * @since 1.1
* @see SQL
* @see Conditions
* @see Functions
+ * @since 1.1
*/
public abstract class Expressions {
@@ -61,6 +63,26 @@ public static Expression cast(Expression expression, String targetType) {
return Cast.create(expression, targetType);
}
+ /**
+ * Creates an {@link Expression} based on the provided list of {@link Column}s.
+ *
+ * If the list contains only a single column, this method returns that column directly as the resulting
+ * {@link Expression}. Otherwise, it creates and returns a {@link TupleExpression} that represents multiple columns as
+ * a single expression.
+ *
+ * @param columns the list of {@link Column}s to include in the expression; must not be {@literal null}.
+ * @return an {@link Expression} corresponding to the input columns: either a single column or a
+ * {@link TupleExpression} for multiple columns.
+ * @since 4.0
+ */
+ public static Expression of(List columns) {
+
+ if (columns.size() == 1) {
+ return columns.get(0);
+ }
+ return new TupleExpression(columns);
+ }
+
// Utility constructor.
private Expressions() {}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/TupleExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/TupleExpression.java
new file mode 100644
index 0000000000..82ac2fe7f8
--- /dev/null
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/TupleExpression.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.data.relational.core.sql;
+
+import static java.util.stream.Collectors.*;
+
+import java.util.List;
+
+/**
+ * A tuple as used in conditions like
+ *
+ *
+ * WHERE (one, two) IN (select x, y from some_table)
+ *
+ *
+ * @author Jens Schauder
+ * @since 4.0
+ */
+public class TupleExpression extends AbstractSegment implements Expression {
+
+ private final List extends Expression> expressions;
+
+ private static Segment[] children(List extends Expression> expressions) {
+ return expressions.toArray(new Segment[0]);
+ }
+
+ TupleExpression(List extends Expression> expressions) {
+
+ super(children(expressions));
+
+ this.expressions = expressions;
+ }
+
+ public static TupleExpression create(Expression... expressions) {
+ return new TupleExpression(List.of(expressions));
+ }
+
+ public static TupleExpression create(List extends Expression> expressions) {
+ return new TupleExpression(expressions);
+ }
+
+ @Override
+ public String toString() {
+ return "(" + expressions.stream().map(Expression::toString).collect(joining(", ")) + ")";
+ }
+}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ExpressionVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ExpressionVisitor.java
index 32ce15dee1..40c21e1976 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ExpressionVisitor.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ExpressionVisitor.java
@@ -48,7 +48,7 @@ class ExpressionVisitor extends TypedSubtreeVisitor implements PartR
/**
* Creates an {@code ExpressionVisitor}.
*
- * @param context must not be {@literal null}.
+ * @param context must not be {@literal null}.
* @param aliasHandling controls if columns should be rendered as their alias or using their table names.
* @since 2.3
*/
@@ -78,6 +78,13 @@ Delegation enterMatched(Expression segment) {
return Delegation.delegateTo(visitor);
}
+ if (segment instanceof TupleExpression) {
+
+ TupleVisitor visitor = new TupleVisitor(context);
+ partRenderer = visitor;
+ return Delegation.delegateTo(visitor);
+ }
+
if (segment instanceof AnalyticFunction) {
AnalyticFunctionVisitor visitor = new AnalyticFunctionVisitor(context);
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/TupleVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/TupleVisitor.java
new file mode 100644
index 0000000000..d03fce9d3f
--- /dev/null
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/TupleVisitor.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.relational.core.sql.render;
+
+import org.springframework.data.relational.core.sql.TupleExpression;
+import org.springframework.data.relational.core.sql.Visitable;
+
+/**
+ * Visitor for rendering tuple expressions.
+ *
+ * @author Jens Schauder
+ * @since 4.0
+ */
+class TupleVisitor extends TypedSingleConditionRenderSupport implements PartRenderer {
+
+ private final StringBuilder part = new StringBuilder();
+ private boolean needsComma = false;
+
+ TupleVisitor(RenderContext context) {
+ super(context);
+ }
+
+ @Override
+ Delegation leaveNested(Visitable segment) {
+
+ if (hasDelegatedRendering()) {
+
+ if (needsComma) {
+ part.append(", ");
+ }
+
+ part.append(consumeRenderedPart());
+ needsComma = true;
+ }
+
+ return super.leaveNested(segment);
+ }
+
+ @Override
+ Delegation enterMatched(TupleExpression segment) {
+
+ part.append("(");
+
+ return super.enterMatched(segment);
+ }
+
+ @Override
+ Delegation leaveMatched(TupleExpression segment) {
+
+ part.append(")");
+
+ return super.leaveMatched(segment);
+ }
+
+ @Override
+ public CharSequence getRenderedPart() {
+ return part;
+ }
+}
diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGenerator.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGenerator.java
index 65b0ff095f..2038f721ed 100644
--- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGenerator.java
+++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGenerator.java
@@ -167,9 +167,10 @@ private QueryMeta createInlineQuery(AggregatePath basePath, @Nullable Condition
columns.add(rownumber);
String rowCountAlias = aliases.getRowCountAlias(basePath);
- Expression count = basePath.isRoot() ? new AliasedExpression(SQL.literalOf(1), rowCountAlias)
- : AnalyticFunction.create("count", Expressions.just("*"))
- .partitionBy(table.column(basePath.getTableInfo().reverseColumnInfo().name())).as(rowCountAlias);
+ Expression count = basePath.isRoot() ? new AliasedExpression(SQL.literalOf(1), rowCountAlias) //
+ : AnalyticFunction.create("count", Expressions.just("*")) //
+ .partitionBy(basePath.getTableInfo().backReferenceColumnInfos().toColumnList(table) //
+ ).as(rowCountAlias);
columns.add(count);
String backReferenceAlias = null;
@@ -178,7 +179,8 @@ private QueryMeta createInlineQuery(AggregatePath basePath, @Nullable Condition
if (!basePath.isRoot()) {
backReferenceAlias = aliases.getBackReferenceAlias(basePath);
- columns.add(table.column(basePath.getTableInfo().reverseColumnInfo().name()).as(backReferenceAlias));
+ columns
+ .add(table.column(basePath.getTableInfo().backReferenceColumnInfos().unique().name()).as(backReferenceAlias));
keyAlias = aliases.getKeyAlias(basePath);
Expression keyExpression = basePath.isQualified()
@@ -238,9 +240,10 @@ private String getIdentifierProperty(List paths) {
private static AnalyticFunction createRowNumberExpression(AggregatePath basePath, Table table,
String rowNumberAlias) {
+ AggregatePath.ColumnInfos reverseColumnInfos = basePath.getTableInfo().backReferenceColumnInfos();
return AnalyticFunction.create("row_number") //
- .partitionBy(table.column(basePath.getTableInfo().reverseColumnInfo().name())) //
- .orderBy(table.column(basePath.getTableInfo().reverseColumnInfo().name())) //
+ .partitionBy(reverseColumnInfos.toColumnList(table)) //
+ .orderBy(reverseColumnInfos.toColumnList(table)) //
.as(rowNumberAlias);
}
diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/DbActionExecutionExceptionUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/DbActionExecutionExceptionUnitTests.java
deleted file mode 100644
index c81c7dd20a..0000000000
--- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/DbActionExecutionExceptionUnitTests.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * Copyright 2018-2025 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.springframework.data.relational.core.conversion;
-
-import org.junit.jupiter.api.Test;
-
-import static org.mockito.Mockito.*;
-
-/**
- * Unit test for {@link DbActionExecutionException}.
- *
- * @author Jens Schauder
- */
-public class DbActionExecutionExceptionUnitTests {
-
- @Test // DATAJDBC-162
- public void constructorWorksWithNullPropertyPath() {
-
- DbAction> action = mock(DbAction.class);
- new DbActionExecutionException(action, null);
- }
-
-}
diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/AggregatePathAssertions.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/AggregatePathAssertions.java
new file mode 100644
index 0000000000..33a195d5b6
--- /dev/null
+++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/AggregatePathAssertions.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.relational.core.mapping;
+
+import org.assertj.core.api.AbstractAssert;
+
+/**
+ * Custom AssertJ assertions for {@link AggregatePath} instances
+ *
+ * @author Jens Schauder
+ * @since 4.0
+ */
+public class AggregatePathAssertions extends AbstractAssert {
+
+ /**
+ * Constructor taking the actual {@link AggregatePath} to assert over.
+ *
+ * @param actual
+ */
+ public AggregatePathAssertions(AggregatePath actual) {
+ super(actual, AggregatePathAssertions.class);
+ }
+
+ /**
+ * Entry point for creating assertions for AggregatePath.
+ */
+ public static AggregatePathAssertions assertThat(AggregatePath actual) {
+ return new AggregatePathAssertions(actual);
+ }
+
+ /**
+ * Assertion method comparing the path of the actual AggregatePath with the provided String representation of a path
+ * in dot notation. Note that the assertion does not test the root entity type of the AggregatePath.
+ */
+ public AggregatePathAssertions hasPath(String expectedPath) {
+ isNotNull();
+
+ if (!actual.toDotPath().equals(expectedPath)) { // Adjust this condition based on your AggregatePath's path logic
+ failWithMessage("Expected path to be <%s> but was <%s>", expectedPath, actual.toString());
+ }
+ return this;
+ }
+
+ /**
+ * assertion testing if the actual path is a root path.
+ */
+ public AggregatePathAssertions isRoot() {
+ isNotNull();
+
+ if (!actual.isRoot()) {
+ failWithMessage("Expected AggregatePath to be root path, but it was not");
+ }
+ return this;
+ }
+
+ /**
+ * assertion testing if the actual path is NOT a root path.
+ */
+ public AggregatePathAssertions isNotRoot() {
+ isNotNull();
+
+ if (actual.isRoot()) {
+ failWithMessage("Expected AggregatePath not to be root path, but it was.");
+ }
+ return this;
+ }
+}
diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/AggregatePathSoftAssertions.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/AggregatePathSoftAssertions.java
new file mode 100644
index 0000000000..3b59af40ba
--- /dev/null
+++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/AggregatePathSoftAssertions.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.relational.core.mapping;
+
+import java.util.function.Consumer;
+
+import org.assertj.core.api.SoftAssertions;
+import org.assertj.core.api.SoftAssertionsProvider;
+
+/**
+ * Soft assertions for {@link AggregatePath} instances.
+ *
+ * @author Jens Schauder
+ * @since 4.0
+ */
+public class AggregatePathSoftAssertions extends SoftAssertions {
+
+ /**
+ * Entry point for assertions. The default {@literal assertThat} can't be used, since it collides with {@link SoftAssertions#assertThat(Iterable)}
+ */
+ public AggregatePathAssertions assertAggregatePath(AggregatePath actual) {
+ return proxy(AggregatePathAssertions.class, AggregatePath.class, actual);
+ }
+
+ static void assertAggregatePathsSoftly(Consumer softly) {
+ SoftAssertionsProvider.assertSoftly(AggregatePathSoftAssertions.class, softly);
+ }
+}
diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/ColumnInfosUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/ColumnInfosUnitTests.java
new file mode 100644
index 0000000000..fa1db8e202
--- /dev/null
+++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/ColumnInfosUnitTests.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.data.relational.core.mapping;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.relational.core.sql.SqlIdentifier;
+import org.springframework.data.relational.core.sql.Table;
+
+/**
+ * Unit tests for the construction of {@link org.springframework.data.relational.core.mapping.AggregatePath.ColumnInfos}
+ *
+ * @author Jens Schauder
+ */
+class ColumnInfosUnitTests {
+
+ static final Table TABLE = Table.create("dummy");
+ static final SqlIdentifier ID = SqlIdentifier.quoted("ID");
+ RelationalMappingContext context = new RelationalMappingContext();
+
+ @Test // GH-574
+ void emptyColumnInfos() {
+
+ AggregatePath.ColumnInfos columnInfos = AggregatePath.ColumnInfos.empty(basePath(DummyEntity.class));
+
+ assertThat(columnInfos.isEmpty()).isTrue();
+ assertThrows(NoSuchElementException.class, columnInfos::any);
+ assertThrows(IllegalStateException.class, columnInfos::unique);
+ assertThat(columnInfos.toColumnList(TABLE)).isEmpty();
+ }
+
+ @Test // GH-574
+ void singleElementColumnInfos() {
+
+ AggregatePath.ColumnInfos columnInfos = basePath(DummyEntity.class).getTableInfo().idColumnInfos();
+
+ assertThat(columnInfos.isEmpty()).isFalse();
+ assertThat(columnInfos.any().name()).isEqualTo(ID);
+ assertThat(columnInfos.unique().name()).isEqualTo(ID);
+ assertThat(columnInfos.toColumnList(TABLE)).containsExactly(TABLE.column(ID));
+ }
+
+ @Test // GH-574
+ void multiElementColumnInfos() {
+
+ AggregatePath.ColumnInfos columnInfos = basePath(WithCompositeId.class).getTableInfo().idColumnInfos();
+
+ assertThat(columnInfos.isEmpty()).isFalse();
+ assertThat(columnInfos.any().name()).isEqualTo(SqlIdentifier.quoted("ONE"));
+ assertThrows(IllegalStateException.class, columnInfos::unique);
+ assertThat(columnInfos.toColumnList(TABLE)) //
+ .containsExactly( //
+ TABLE.column(SqlIdentifier.quoted("ONE")), //
+ TABLE.column(SqlIdentifier.quoted("TWO")) //
+ );
+
+ List collector = new ArrayList<>();
+ columnInfos.forEach((ap, ci) -> collector.add(ap.toDotPath() + "+" + ci.name()));
+ assertThat(collector).containsExactly("one+\"ONE\"", "two+\"TWO\"");
+
+ columnInfos.get(getPath(CompositeId.class, "one"));
+
+ }
+
+ private AggregatePath getPath(Class> type, String name) {
+ return basePath(type).append(context.getPersistentEntity(type).getPersistentProperty(name));
+ }
+
+ private AggregatePath basePath(Class> type) {
+ return context.getAggregatePath(context.getPersistentEntity(type));
+ }
+
+ record DummyEntity(@Id String id, String name) {
+ }
+
+ record CompositeId(String one, String two) {
+ }
+
+ record WithCompositeId(@Id @Embedded.Nullable CompositeId id, String name) {
+ }
+}
diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/DefaultAggregatePathUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/DefaultAggregatePathUnitTests.java
index c173d0294f..dfd90d2a43 100644
--- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/DefaultAggregatePathUnitTests.java
+++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/DefaultAggregatePathUnitTests.java
@@ -22,13 +22,17 @@
import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.ReadOnlyProperty;
import org.springframework.data.mapping.PersistentPropertyPath;
+import org.springframework.data.relational.core.sql.Column;
import org.springframework.data.relational.core.sql.SqlIdentifier;
+import org.springframework.data.relational.core.sql.Table;
/**
* Tests for {@link AggregatePath}.
@@ -46,7 +50,7 @@ void isNotRootForNonRootPath() {
AggregatePath path = context.getAggregatePath(context.getPersistentPropertyPath("entityId", DummyEntity.class));
- assertThat(path.isRoot()).isFalse();
+ AggregatePathAssertions.assertThat(path).isNotRoot();
}
@Test // GH-1525
@@ -54,17 +58,17 @@ void isRootForRootPath() {
AggregatePath path = context.getAggregatePath(entity);
- assertThat(path.isRoot()).isTrue();
+ AggregatePathAssertions.assertThat(path).isRoot();
}
@Test // GH-1525
void getParentPath() {
- assertSoftly(softly -> {
+ AggregatePathSoftAssertions.assertAggregatePathsSoftly(softly -> {
- softly.assertThat(path("second.third2.value").getParentPath()).isEqualTo(path("second.third2"));
- softly.assertThat(path("second.third2").getParentPath()).isEqualTo(path("second"));
- softly.assertThat(path("second").getParentPath()).isEqualTo(path());
+ softly.assertAggregatePath(path("second.third2.value").getParentPath()).hasPath("second.third2");
+ softly.assertAggregatePath(path("second.third2").getParentPath()).hasPath("second");
+ softly.assertAggregatePath(path("second").getParentPath()).isRoot();
softly.assertThatThrownBy(() -> path().getParentPath()).isInstanceOf(IllegalStateException.class);
});
@@ -75,13 +79,13 @@ void getRequiredLeafEntity() {
assertSoftly(softly -> {
+ RelationalPersistentEntity> secondEntity = context.getRequiredPersistentEntity(Second.class);
+ RelationalPersistentEntity> thirdEntity = context.getRequiredPersistentEntity(Third.class);
+
softly.assertThat(path().getRequiredLeafEntity()).isEqualTo(entity);
- softly.assertThat(path("second").getRequiredLeafEntity())
- .isEqualTo(context.getRequiredPersistentEntity(Second.class));
- softly.assertThat(path("second.third").getRequiredLeafEntity())
- .isEqualTo(context.getRequiredPersistentEntity(Third.class));
- softly.assertThat(path("secondList").getRequiredLeafEntity())
- .isEqualTo(context.getRequiredPersistentEntity(Second.class));
+ softly.assertThat(path("second").getRequiredLeafEntity()).isEqualTo(secondEntity);
+ softly.assertThat(path("second.third").getRequiredLeafEntity()).isEqualTo(thirdEntity);
+ softly.assertThat(path("secondList").getRequiredLeafEntity()).isEqualTo(secondEntity);
softly.assertThatThrownBy(() -> path("secondList.third.value").getRequiredLeafEntity())
.isInstanceOf(IllegalStateException.class);
@@ -92,16 +96,16 @@ void getRequiredLeafEntity() {
@Test // GH-1525
void idDefiningPath() {
- assertSoftly(softly -> {
+ AggregatePathSoftAssertions.assertAggregatePathsSoftly(softly -> {
- softly.assertThat(path("second.third2.value").getIdDefiningParentPath()).isEqualTo(path());
- softly.assertThat(path("second.third.value").getIdDefiningParentPath()).isEqualTo(path());
- softly.assertThat(path("secondList.third2.value").getIdDefiningParentPath()).isEqualTo(path());
- softly.assertThat(path("secondList.third.value").getIdDefiningParentPath()).isEqualTo(path());
- softly.assertThat(path("second2.third2.value").getIdDefiningParentPath()).isEqualTo(path());
- softly.assertThat(path("second2.third.value").getIdDefiningParentPath()).isEqualTo(path());
- softly.assertThat(path("withId.second.third2.value").getIdDefiningParentPath()).isEqualTo(path("withId"));
- softly.assertThat(path("withId.second.third.value").getIdDefiningParentPath()).isEqualTo(path("withId"));
+ softly.assertAggregatePath(path("second.third2.value").getIdDefiningParentPath()).isRoot();
+ softly.assertAggregatePath(path("second.third.value").getIdDefiningParentPath()).isRoot();
+ softly.assertAggregatePath(path("secondList.third2.value").getIdDefiningParentPath()).isRoot();
+ softly.assertAggregatePath(path("secondList.third.value").getIdDefiningParentPath()).isRoot();
+ softly.assertAggregatePath(path("second2.third2.value").getIdDefiningParentPath()).isRoot();
+ softly.assertAggregatePath(path("second2.third.value").getIdDefiningParentPath()).isRoot();
+ softly.assertAggregatePath(path("withId.second.third2.value").getIdDefiningParentPath()).hasPath("withId");
+ softly.assertAggregatePath(path("withId.second.third.value").getIdDefiningParentPath()).hasPath("withId");
});
}
@@ -121,13 +125,13 @@ void reverseColumnName() {
assertSoftly(softly -> {
- softly.assertThat(path("second.third2").getTableInfo().reverseColumnInfo().name())
+ softly.assertThat((Object) path("second.third2").getTableInfo().reverseColumnInfo().name())
.isEqualTo(quoted("DUMMY_ENTITY"));
- softly.assertThat(path("second.third").getTableInfo().reverseColumnInfo().name())
+ softly.assertThat((Object) path("second.third").getTableInfo().reverseColumnInfo().name())
.isEqualTo(quoted("DUMMY_ENTITY"));
- softly.assertThat(path("secondList.third2").getTableInfo().reverseColumnInfo().name())
+ softly.assertThat((Object) path("secondList.third2").getTableInfo().reverseColumnInfo().name())
.isEqualTo(quoted("DUMMY_ENTITY"));
- softly.assertThat(path("secondList.third").getTableInfo().reverseColumnInfo().name())
+ softly.assertThat((Object) path("secondList.third").getTableInfo().reverseColumnInfo().name())
.isEqualTo(quoted("DUMMY_ENTITY"));
softly.assertThat(path("second2.third").getTableInfo().reverseColumnInfo().name())
.isEqualTo(quoted("DUMMY_ENTITY"));
@@ -140,6 +144,19 @@ void reverseColumnName() {
});
}
+ @Test // GH-574
+ void reverseColumnNames() {
+
+ assertSoftly(softly -> {
+ softly
+ .assertThat(path(CompoundIdEntity.class, "second").getTableInfo().backReferenceColumnInfos()
+ .toColumnList(Table.create("dummy")))
+ .extracting(Column::getName)
+ .containsExactlyInAnyOrder(quoted("COMPOUND_ID_ENTITY_ONE"), quoted("COMPOUND_ID_ENTITY_TWO"));
+
+ });
+ }
+
@Test // GH-1525
void getQualifierColumn() {
@@ -169,12 +186,11 @@ void getQualifierColumnType() {
@Test // GH-1525
void extendBy() {
+ AggregatePathSoftAssertions.assertAggregatePathsSoftly(softly -> {
- assertSoftly(softly -> {
-
- softly.assertThat(path().append(entity.getRequiredPersistentProperty("withId"))).isEqualTo(path("withId"));
- softly.assertThat(path("withId").append(path("withId").getRequiredIdProperty()))
- .isEqualTo(path("withId.withIdId"));
+ softly.assertAggregatePath(path().append(entity.getRequiredPersistentProperty("withId"))).hasPath("withId");
+ softly.assertAggregatePath(path("withId").append(path("withId").getRequiredIdProperty()))
+ .hasPath("withId.withIdId");
});
}
@@ -229,11 +245,11 @@ void isMultiValued() {
softly.assertThat(path("second").isMultiValued()).isFalse();
softly.assertThat(path("second.third2").isMultiValued()).isFalse();
softly.assertThat(path("secondList.third2").isMultiValued()).isTrue(); // this seems wrong as third2 is an
- // embedded path into Second, held by
- // List