From 1b569543d9736e60215c03384190eda7edc87a43 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 19 Nov 2024 10:10:10 +0100 Subject: [PATCH 1/8] 574-composite-id - Prepare branch --- Jenkinsfile | 2 +- pom.xml | 2 +- spring-data-jdbc-distribution/pom.xml | 2 +- spring-data-jdbc/pom.xml | 4 ++-- spring-data-r2dbc/pom.xml | 4 ++-- spring-data-relational/pom.xml | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 8919ba10f4..8b0dcdd33c 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -9,7 +9,7 @@ pipeline { triggers { pollSCM 'H/10 * * * *' - upstream(upstreamProjects: "spring-data-commons/main", threshold: hudson.model.Result.SUCCESS) + upstream(upstreamProjects: "spring-data-commons/4.0.x", threshold: hudson.model.Result.SUCCESS) } options { diff --git a/pom.xml b/pom.xml index ec709663b5..3c9e363c1e 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT pom Spring Data Relational Parent diff --git a/spring-data-jdbc-distribution/pom.xml b/spring-data-jdbc-distribution/pom.xml index b3c39e64c3..8d5696607e 100644 --- a/spring-data-jdbc-distribution/pom.xml +++ b/spring-data-jdbc-distribution/pom.xml @@ -14,7 +14,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT ../pom.xml diff --git a/spring-data-jdbc/pom.xml b/spring-data-jdbc/pom.xml index e61fd64020..9f7d6c308e 100644 --- a/spring-data-jdbc/pom.xml +++ b/spring-data-jdbc/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-jdbc - 4.0.0-SNAPSHOT + 4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT Spring Data JDBC Spring Data module for JDBC repositories. @@ -15,7 +15,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT diff --git a/spring-data-r2dbc/pom.xml b/spring-data-r2dbc/pom.xml index 3ee76fd3c1..d2b28e8abc 100644 --- a/spring-data-r2dbc/pom.xml +++ b/spring-data-r2dbc/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-r2dbc - 4.0.0-SNAPSHOT + 4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT Spring Data R2DBC Spring Data module for R2DBC @@ -15,7 +15,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT diff --git a/spring-data-relational/pom.xml b/spring-data-relational/pom.xml index 8fd6d7a6f0..14ea2ad98c 100644 --- a/spring-data-relational/pom.xml +++ b/spring-data-relational/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-relational - 4.0.0-SNAPSHOT + 4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT Spring Data Relational Spring Data Relational support @@ -14,7 +14,7 @@ org.springframework.data spring-data-relational-parent - 4.0.0-SNAPSHOT + 4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT From 03f08cf1e42ebacf5b66e2eaf52a9642684d681a Mon Sep 17 00:00:00 2001 From: Jens Schauder Date: Mon, 10 Jun 2024 15:18:19 +0200 Subject: [PATCH 2/8] Add support for composite ids. Entities may be annotated with `@Id` and `@Embedded`, resulting in a composite id on the database side. The full embedded entity is considered the id, and therefore the check for determining if an aggregate is considered a new aggregate requiring an insert or an existing one, asking for an update is based on that entity, not its elements. Most use cases will require a custom `BeforeConvertCallback` to set the id for new aggregate. For an entity with `@Embedded` id, the back reference used in tables for referenced entities consists of multiple columns, each named by a concatenation of + `_` + . E.g. the back reference to a `Person` entity, with a composite id with the properties `firstName` and `lastName` will consist of the two columns `PERSON_FIRST_NAME` and `PERSON_LAST_NAME`. This holds for directly referenced entities as well as `List`, `Set` and `Map`. Closes #574 Original pull request #1957 --- .../convert/DefaultDataAccessStrategy.java | 14 +- ...dbcBackReferencePropertyValueProvider.java | 54 --- .../core/convert/JdbcIdentifierBuilder.java | 41 +- .../core/convert/MappingJdbcConverter.java | 68 +-- .../data/jdbc/core/convert/SqlContext.java | 17 +- .../data/jdbc/core/convert/SqlGenerator.java | 296 +++++++----- .../core/convert/SqlParametersFactory.java | 92 ++-- .../query/JdbcDeleteQueryCreator.java | 41 +- .../repository/query/JdbcQueryCreator.java | 84 ++-- .../jdbc/repository/query/SqlContext.java | 44 +- ...AggregateTemplateHsqlIntegrationTests.java | 308 +++++++++++++ .../JdbcIdentifierBuilderUnitTests.java | 182 +++++--- ...orContextBasedNamingStrategyUnitTests.java | 4 +- .../SqlGeneratorEmbeddedUnitTests.java | 253 ++++++++++- ...GeneratorFixedNamingStrategyUnitTests.java | 9 +- .../core/convert/SqlGeneratorUnitTests.java | 68 ++- ...ava => SqlParametersFactoryUnitTests.java} | 52 ++- ...mbeddedWithCollectionIntegrationTests.java | 3 +- .../query/PartTreeJdbcQueryUnitTests.java | 13 +- ...egateTemplateHsqlIntegrationTests-hsql.sql | 46 ++ ...AggregateTemplateIntegrationTests-hsql.sql | 426 +++++++++--------- .../support/SimpleR2dbcRepository.java | 34 +- .../core/R2dbcEntityTemplateUnitTests.java | 4 - ...CompositeIdRepositoryIntegrationTests.java | 122 +++++ .../core/mapping/AggregatePath.java | 251 +++++++++-- .../BasicRelationalPersistentEntity.java | 1 + .../core/mapping/DefaultAggregatePath.java | 62 ++- .../EmbeddedRelationalPersistentEntity.java | 1 + .../mapping/RelationalMappingContext.java | 8 +- .../mapping/RelationalPersistentEntity.java | 2 + .../relational/core/sql/AnalyticFunction.java | 51 ++- .../data/relational/core/sql/Conditions.java | 2 +- .../relational/core/sql/TupleExpression.java | 54 +++ .../core/sql/render/ExpressionVisitor.java | 9 +- .../core/sql/render/TupleVisitor.java | 72 +++ .../SingleQuerySqlGenerator.java | 17 +- .../core/mapping/ColumnInfosUnitTests.java | 102 +++++ .../DefaultAggregatePathUnitTests.java | 161 ++++--- .../RelationalMappingContextUnitTests.java | 17 +- .../core/sql/TupleExpressionUnitTests.java | 48 ++ .../sql/render/DeleteRendererUnitTests.java | 8 +- .../sql/render/SelectRendererUnitTests.java | 425 +++++++++-------- .../sqlgeneration/AliasFactoryUnitTests.java | 55 +-- .../modules/ROOT/pages/jdbc/mapping.adoc | 3 + .../antora/modules/ROOT/partials/mapping.adoc | 7 + 45 files changed, 2592 insertions(+), 1039 deletions(-) delete mode 100644 spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcBackReferencePropertyValueProvider.java create mode 100644 spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java rename spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/{SqlParametersFactoryTest.java => SqlParametersFactoryUnitTests.java} (86%) create mode 100644 spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/CompositeIdAggregateTemplateHsqlIntegrationTests-hsql.sql create mode 100644 spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/CompositeIdRepositoryIntegrationTests.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/TupleExpression.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/TupleVisitor.java create mode 100644 spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/ColumnInfosUnitTests.java create mode 100644 spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java index ff0b67f44e..f0febcad79 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java @@ -121,6 +121,7 @@ public Object insert(T instance, Class domainType, Identifier identifier, public Object[] insert(List> insertSubjects, Class domainType, IdValueSource idValueSource) { Assert.notEmpty(insertSubjects, "Batch insert must contain at least one InsertSubject"); + SqlIdentifierParameterSource[] sqlParameterSources = insertSubjects.stream() .map(insertSubject -> sqlParametersFactory.forInsert( // insertSubject.getInstance(), // @@ -171,7 +172,7 @@ public boolean updateWithVersion(S instance, Class domainType, Number pre public void delete(Object id, Class domainType) { String deleteByIdSql = sql(domainType).getDeleteById(); - SqlParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType, ID_SQL_PARAMETER); + SqlParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType); operations.update(deleteByIdSql, parameter); } @@ -192,7 +193,7 @@ public void deleteWithVersion(Object id, Class domainType, Number previou RelationalPersistentEntity persistentEntity = getRequiredPersistentEntity(domainType); - SqlIdentifierParameterSource parameterSource = sqlParametersFactory.forQueryById(id, domainType, ID_SQL_PARAMETER); + SqlIdentifierParameterSource parameterSource = sqlParametersFactory.forQueryById(id, domainType); parameterSource.addValue(VERSION_SQL_PARAMETER, previousVersion); int affectedRows = operations.update(sql(domainType).getDeleteByIdAndVersion(), parameterSource); @@ -212,8 +213,7 @@ public void delete(Object rootId, PersistentPropertyPath prope public void acquireLockById(Object id, LockMode lockMode, Class domainType) { String acquireLockByIdSql = sql(domainType).getAcquireLockById(lockMode); - SqlIdentifierParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType, ID_SQL_PARAMETER); + SqlIdentifierParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType); operations.query(acquireLockByIdSql, parameter, ResultSet::next); } @@ -273,7 +273,7 @@ public long count(Class domainType) { public T findById(Object id, Class domainType) { String findOneSql = sql(domainType).getFindOne(); - SqlIdentifierParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType, ID_SQL_PARAMETER); + SqlIdentifierParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType); try { return operations.queryForObject(findOneSql, parameter, getRowMapper(domainType)); @@ -359,7 +359,7 @@ public Object mapRow(ResultSet rs, int rowNum) throws SQLException { public boolean existsById(Object id, Class domainType) { String existsSql = sql(domainType).getExists(); - SqlParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType, ID_SQL_PARAMETER); + SqlParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType); Boolean result = operations.queryForObject(existsSql, parameter, Boolean.class); Assert.state(result != null, "The result of an exists query must not be null"); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcBackReferencePropertyValueProvider.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcBackReferencePropertyValueProvider.java deleted file mode 100644 index 34f9e88de5..0000000000 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcBackReferencePropertyValueProvider.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2020-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.jdbc.core.convert; - -import org.springframework.data.mapping.model.PropertyValueProvider; -import org.springframework.data.relational.core.mapping.AggregatePath; -import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; - -/** - * {@link PropertyValueProvider} obtaining values from a {@link ResultSetAccessor}. For a given id property it provides - * the value in the resultset under which other entities refer back to it. - * - * @author Jens Schauder - * @author Kurt Niemi - * @author Mikhail Polivakha - * @since 2.0 - */ -class JdbcBackReferencePropertyValueProvider implements PropertyValueProvider { - - private final AggregatePath basePath; - private final ResultSetAccessor resultSet; - - /** - * @param basePath path from the aggregate root relative to which all properties get resolved. - * @param resultSet the {@link ResultSetAccessor} from which to obtain the actual values. - */ - JdbcBackReferencePropertyValueProvider(AggregatePath basePath, ResultSetAccessor resultSet) { - - this.resultSet = resultSet; - this.basePath = basePath; - } - - @Override - public T getPropertyValue(RelationalPersistentProperty property) { - return (T) resultSet.getObject(basePath.append(property).getTableInfo().reverseColumnInfo().alias().getReference()); - } - - public JdbcBackReferencePropertyValueProvider extendBy(RelationalPersistentProperty property) { - return new JdbcBackReferencePropertyValueProvider(basePath.append(property), resultSet); - } -} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java index 22944aaad2..41e6b2c488 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java @@ -15,7 +15,13 @@ */ package org.springframework.data.jdbc.core.convert; +import java.util.function.Function; + +import org.springframework.data.mapping.PersistentPropertyPathAccessor; import org.springframework.data.relational.core.mapping.AggregatePath; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.util.Assert; /** @@ -41,13 +47,31 @@ public static JdbcIdentifierBuilder empty() { */ public static JdbcIdentifierBuilder forBackReferences(JdbcConverter converter, AggregatePath path, Object value) { - Identifier identifier = Identifier.of( // - path.getTableInfo().reverseColumnInfo().name(), // - value, // - converter.getColumnType(path.getIdDefiningParentPath().getRequiredIdProperty()) // - ); + RelationalPersistentProperty idProperty = path.getIdDefiningParentPath().getRequiredIdProperty(); + AggregatePath.ColumnInfos reverseColumnInfos = path.getTableInfo().reverseColumnInfos(); + + // create property accessor + RelationalMappingContext mappingContext = converter.getMappingContext(); + RelationalPersistentEntity persistentEntity = mappingContext.getPersistentEntity(idProperty.getType()); + + Function valueProvider; + if (persistentEntity == null) { + valueProvider = ap -> value; + } else { + PersistentPropertyPathAccessor propertyPathAccessor = persistentEntity.getPropertyPathAccessor(value); + valueProvider = ap -> propertyPathAccessor.getProperty(ap.getRequiredPersistentPropertyPath()); + } + + final Identifier[] identifierHolder = new Identifier[] { Identifier.empty() }; + + reverseColumnInfos.forEach((ap, ci) -> { + + RelationalPersistentProperty property = ap.getRequiredLeafProperty(); + identifierHolder[0] = identifierHolder[0].withPart(ci.name(), valueProvider.apply(ap), + converter.getColumnType(property)); + }); - return new JdbcIdentifierBuilder(identifier); + return new JdbcIdentifierBuilder(identifierHolder[0]); } /** @@ -62,8 +86,9 @@ public JdbcIdentifierBuilder withQualifier(AggregatePath path, Object value) { Assert.notNull(path, "Path must not be null"); Assert.notNull(value, "Value must not be null"); - identifier = identifier.withPart(path.getTableInfo().qualifierColumnInfo().name(), value, - path.getTableInfo().qualifierColumnType()); + AggregatePath.TableInfo tableInfo = path.getTableInfo(); + identifier = identifier.withPart(tableInfo.qualifierColumnInfo().name(), value, + tableInfo.qualifierColumnType()); return this; } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java index 7460931dab..80e1975de2 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java @@ -80,7 +80,7 @@ public class MappingJdbcConverter extends MappingRelationalConverter implements * {@link #MappingJdbcConverter(RelationalMappingContext, RelationResolver, CustomConversions, JdbcTypeFactory)} * (MappingContext, RelationResolver, JdbcTypeFactory)} to convert arrays and large objects into JDBC-specific types. * - * @param context must not be {@literal null}. + * @param context must not be {@literal null}. * @param relationResolver used to fetch additional relations from the database. Must not be {@literal null}. */ public MappingJdbcConverter(RelationalMappingContext context, RelationResolver relationResolver) { @@ -98,12 +98,12 @@ public MappingJdbcConverter(RelationalMappingContext context, RelationResolver r /** * Creates a new {@link MappingJdbcConverter} given {@link MappingContext}. * - * @param context must not be {@literal null}. + * @param context must not be {@literal null}. * @param relationResolver used to fetch additional relations from the database. Must not be {@literal null}. - * @param typeFactory must not be {@literal null} + * @param typeFactory must not be {@literal null} */ public MappingJdbcConverter(RelationalMappingContext context, RelationResolver relationResolver, - CustomConversions conversions, JdbcTypeFactory typeFactory) { + CustomConversions conversions, JdbcTypeFactory typeFactory) { super(context, conversions); @@ -220,7 +220,7 @@ private boolean canWriteAsJdbcValue(@Nullable Object value) { return true; } - if (value instanceof AggregateReference aggregateReference) { + if (value instanceof AggregateReference aggregateReference) { return canWriteAsJdbcValue(aggregateReference.getId()); } @@ -285,7 +285,7 @@ public R readAndResolve(TypeInformation type, RowDocument source, Identif @Override protected RelationalPropertyValueProvider newValueProvider(RowDocumentAccessor documentAccessor, - ValueExpressionEvaluator evaluator, ConversionContext context) { + ValueExpressionEvaluator evaluator, ConversionContext context) { if (context instanceof ResolvingConversionContext rcc) { @@ -314,7 +314,7 @@ class ResolvingRelationalPropertyValueProvider implements RelationalPropertyValu private final Identifier identifier; private ResolvingRelationalPropertyValueProvider(AggregatePathValueProvider delegate, RowDocumentAccessor accessor, - ResolvingConversionContext context, Identifier identifier) { + ResolvingConversionContext context, Identifier identifier) { AggregatePath path = context.aggregatePath(); @@ -323,7 +323,7 @@ private ResolvingRelationalPropertyValueProvider(AggregatePathValueProvider dele this.context = context; this.identifier = path.isEntity() ? potentiallyAppendIdentifier(identifier, path.getRequiredLeafEntity(), - property -> delegate.getValue(path.append(property))) + property -> delegate.getValue(path.append(property))) : identifier; } @@ -331,7 +331,7 @@ private ResolvingRelationalPropertyValueProvider(AggregatePathValueProvider dele * Conditionally append the identifier if the entity has an identifier property. */ static Identifier potentiallyAppendIdentifier(Identifier base, RelationalPersistentEntity entity, - Function getter) { + Function getter) { if (entity.hasIdProperty()) { @@ -361,24 +361,9 @@ public T getPropertyValue(RelationalPersistentProperty property) { if (property.isCollectionLike() || property.isMap()) { - Identifier identifierToUse = this.identifier; - AggregatePath idDefiningParentPath = aggregatePath.getIdDefiningParentPath(); + Identifier identifier = constructIdentifier(aggregatePath); - // note that the idDefiningParentPath might not itself have an id property, but have a combination of back - // references and possibly keys, that form an id - if (idDefiningParentPath.hasIdProperty()) { - - RelationalPersistentProperty identifier = idDefiningParentPath.getRequiredIdProperty(); - AggregatePath idPath = idDefiningParentPath.append(identifier); - Object value = delegate.getValue(idPath); - - Assert.state(value != null, "Identifier value must not be null at this point"); - - identifierToUse = Identifier.of(aggregatePath.getTableInfo().reverseColumnInfo().name(), value, - identifier.getActualType()); - } - - Iterable allByPath = relationResolver.findAllByPath(identifierToUse, + Iterable allByPath = relationResolver.findAllByPath(identifier, aggregatePath.getRequiredPersistentPropertyPath()); if (property.isCollectionLike()) { @@ -403,6 +388,29 @@ public T getPropertyValue(RelationalPersistentProperty property) { return (T) delegate.getValue(aggregatePath); } + private Identifier constructIdentifier(AggregatePath aggregatePath) { + + Identifier identifierToUse = this.identifier; + AggregatePath idDefiningParentPath = aggregatePath.getIdDefiningParentPath(); + + // note that the idDefiningParentPath might not itself have an id property, but have a combination of back + // references and possibly keys, that form an id + if (idDefiningParentPath.hasIdProperty()) { + + RelationalPersistentProperty idProperty = idDefiningParentPath.getRequiredIdProperty(); + AggregatePath idPath = idProperty.isEntity() ? idDefiningParentPath.append(idProperty) : idDefiningParentPath; + Identifier[] buildingIdentifier = new Identifier[] { Identifier.empty() }; + aggregatePath.getTableInfo().reverseColumnInfos().forEach((ap, ci) -> { + + Object value = delegate.getValue(idPath.append(ap)); + buildingIdentifier[0] = buildingIdentifier[0].withPart(ci.name(), value, + ap.getRequiredLeafProperty().getActualType()); + }); + identifierToUse = buildingIdentifier[0]; + } + return identifierToUse; + } + @Override public boolean hasValue(RelationalPersistentProperty property) { @@ -423,7 +431,7 @@ public boolean hasValue(RelationalPersistentProperty property) { return delegate.hasValue(toUse); } - return delegate.hasValue(aggregatePath.getTableInfo().reverseColumnInfo().alias()); + return delegate.hasValue(aggregatePath.getTableInfo().reverseColumnInfos().any().alias()); } return delegate.hasValue(aggregatePath); @@ -449,7 +457,7 @@ public boolean hasNonEmptyValue(RelationalPersistentProperty property) { return delegate.hasValue(toUse); } - return delegate.hasValue(aggregatePath.getTableInfo().reverseColumnInfo().alias()); + return delegate.hasValue(aggregatePath.getTableInfo().reverseColumnInfos().any().alias()); } return delegate.hasNonEmptyValue(aggregatePath); @@ -460,7 +468,7 @@ public RelationalPropertyValueProvider withContext(ConversionContext context) { return context == this.context ? this : new ResolvingRelationalPropertyValueProvider(delegate.withContext(context), accessor, - (ResolvingConversionContext) context, identifier); + (ResolvingConversionContext) context, identifier); } } @@ -472,7 +480,7 @@ public RelationalPropertyValueProvider withContext(ConversionContext context) { * @param identifier */ private record ResolvingConversionContext(ConversionContext delegate, AggregatePath aggregatePath, - Identifier identifier) implements ConversionContext { + Identifier identifier) implements ConversionContext { @Override public S convert(Object source, TypeInformation typeHint) { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java index 7663e6cd4f..b1b4dfdc2d 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java @@ -40,10 +40,6 @@ class SqlContext { this.table = Table.create(entity.getQualifiedTableName()); } - Column getIdColumn() { - return table.column(entity.getIdColumn()); - } - Column getVersionColumn() { return table.column(entity.getRequiredVersionProperty().getColumnName()); } @@ -60,11 +56,20 @@ Table getTable(AggregatePath path) { } Column getColumn(AggregatePath path) { + AggregatePath.ColumnInfo columnInfo = path.getColumnInfo(); return getTable(path).column(columnInfo.name()).as(columnInfo.alias()); } - Column getReverseColumn(AggregatePath path) { - return getTable(path).column(path.getTableInfo().reverseColumnInfo().name()).as(path.getTableInfo().reverseColumnInfo().alias()); + /** + * A token reverse column, used in selects to identify, if an entity is present or {@literal null}. + * + * @param path must not be null. + * @return a {@literal Column} that is part of the effective primary key for the given path. + */ + Column getAnyReverseColumn(AggregatePath path) { + + AggregatePath.ColumnInfo columnInfo = path.getTableInfo().reverseColumnInfos().any(); + return getTable(path).column(columnInfo.name()).as(columnInfo.alias()); } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java index 783f12f157..ad5af788a7 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java @@ -37,6 +37,7 @@ import org.springframework.data.relational.core.sql.render.RenderContext; import org.springframework.data.relational.core.sql.render.SqlRenderer; import org.springframework.data.util.Lazy; +import org.springframework.data.util.Pair; import org.springframework.data.util.Predicates; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.lang.Nullable; @@ -64,9 +65,7 @@ class SqlGenerator { static final SqlIdentifier VERSION_SQL_PARAMETER = SqlIdentifier.unquoted("___oldOptimisticLockingVersion"); - static final SqlIdentifier ID_SQL_PARAMETER = SqlIdentifier.unquoted("id"); static final SqlIdentifier IDS_SQL_PARAMETER = SqlIdentifier.unquoted("ids"); - static final SqlIdentifier ROOT_ID_PARAMETER = SqlIdentifier.unquoted("rootId"); /** * Length of an aggregate path that is one longer then the root path. @@ -75,7 +74,6 @@ class SqlGenerator { private final RelationalPersistentEntity entity; private final RelationalMappingContext mappingContext; - private final RenderContext renderContext; private final SqlContext sqlContext; private final SqlRenderer sqlRenderer; @@ -98,6 +96,10 @@ class SqlGenerator { private final QueryMapper queryMapper; private final Dialect dialect; + private final Function, Condition> inCondition; + private final Function, Condition> equalityCondition; + private final Function, Condition> notNullCondition; + /** * Create a new {@link SqlGenerator} given {@link RelationalMappingContext} and {@link RelationalPersistentEntity}. * @@ -112,11 +114,15 @@ class SqlGenerator { this.mappingContext = mappingContext; this.entity = entity; this.sqlContext = new SqlContext(entity); - this.renderContext = new RenderContextFactory(dialect).createRenderContext(); - this.sqlRenderer = SqlRenderer.create(renderContext); + this.sqlRenderer = SqlRenderer.create(new RenderContextFactory(dialect).createRenderContext()); this.columns = new Columns(entity, mappingContext, converter); this.queryMapper = new QueryMapper(converter); this.dialect = dialect; + + inCondition = inCondition(); + equalityCondition = equalityCondition(); + notNullCondition = isNotNullCondition(); + } /** @@ -158,44 +164,53 @@ private static boolean isDeeplyNested(AggregatePath path) { * given {@literal path} to those that reference the root entities specified by the {@literal rootCondition}. * * @param path specifies the table and id to select - * @param rootCondition the condition on the root of the path determining what to select - * @param filterColumn the column to apply the IN-condition to. + * @param conditionFunction a function for construction a where clause + * @param columns map making all columns available as a map from {@link AggregatePath} * @return the IN condition */ - private Condition getSubselectCondition(AggregatePath path, Function rootCondition, - Column filterColumn) { + private Condition getSubselectCondition(AggregatePath path, + Function, Condition> conditionFunction, Map columns) { AggregatePath parentPath = path.getParentPath(); if (!parentPath.hasIdProperty()) { if (isDeeplyNested(parentPath)) { - return getSubselectCondition(parentPath, rootCondition, filterColumn); + return getSubselectCondition(parentPath, conditionFunction, columns); } - return rootCondition.apply(filterColumn); + return conditionFunction.apply(columns); } - Table subSelectTable = Table.create(parentPath.getTableInfo().qualifiedTableName()); - Column idColumn = subSelectTable.column(parentPath.getTableInfo().idColumnName()); - Column selectFilterColumn = subSelectTable.column(parentPath.getTableInfo().effectiveIdColumnName()); + AggregatePath.TableInfo parentPathTableInfo = parentPath.getTableInfo(); + Table subSelectTable = Table.create(parentPathTableInfo.qualifiedTableName()); + + Map selectFilterColumns = new TreeMap<>(); + parentPathTableInfo.effectiveIdColumnInfos().forEach( // + (ap, ci) -> // + selectFilterColumns.put(ap, subSelectTable.column(ci.name())) // + ); Condition innerCondition; if (isFirstNonRoot(parentPath)) { // if the parent is the root of the path - // apply the rootCondition - innerCondition = rootCondition.apply(selectFilterColumn); + innerCondition = conditionFunction.apply(selectFilterColumns); } else { - // otherwise, we need another layer of subselect - innerCondition = getSubselectCondition(parentPath, rootCondition, selectFilterColumn); + innerCondition = getSubselectCondition(parentPath, conditionFunction, selectFilterColumns); } + List idColumns = parentPathTableInfo.idColumnInfos().toList(ci -> subSelectTable.column(ci.name())); + Select select = Select.builder() // - .select(idColumn) // + .select(idColumns) // .from(subSelectTable) // .where(innerCondition).build(); - return filterColumn.in(select); + return Conditions.in(toExpression(columns), select); + } + + private Expression toExpression(Map columnsMap) { + return TupleExpression.maybeWrap(new ArrayList<>(columnsMap.values())); } private BindMarker getBindMarker(SqlIdentifier columnName) { @@ -441,7 +456,7 @@ String createDeleteAllSql(@Nullable PersistentPropertyPath path) { - return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path), - filterColumn -> filterColumn.isEqualTo(getBindMarker(ROOT_ID_PARAMETER))); + return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path), equalityCondition); } /** @@ -464,17 +478,82 @@ String createDeleteByPath(PersistentPropertyPath p * @return the statement as a {@link String}. Guaranteed to be not {@literal null}. */ String createDeleteInByPath(PersistentPropertyPath path) { + return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path), inCondition); + } + + /** + * Constructs a function for constructing a where condition. The where condition will be of the form + * {@literal IN :bind-marker} + */ + private Function, Condition> inCondition() { + + return columnMap -> { + + List columns = List.copyOf(columnMap.values()); + + if (columns.size() == 1) { + return Conditions.in(columns.get(0), getBindMarker(IDS_SQL_PARAMETER)); + } + return Conditions.in(TupleExpression.create(columns), getBindMarker(IDS_SQL_PARAMETER)); + }; + } + + /** + * Constructs a function for constructing a where. The where condition will be of the form + * {@literal = :bind-marker-a AND = :bind-marker-b ...} + */ + private Function, Condition> equalityCondition() { + + AggregatePath.ColumnInfos idColumnInfos = mappingContext.getAggregatePath(entity).getTableInfo().idColumnInfos(); - return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path), - filterColumn -> filterColumn.in(getBindMarker(IDS_SQL_PARAMETER))); + return columnMap -> { + + Condition result = null; + for (Map.Entry entry : columnMap.entrySet()) { + BindMarker bindMarker = getBindMarker(idColumnInfos.get(entry.getKey()).name()); + Comparison singleCondition = entry.getValue().isEqualTo(bindMarker); + + result = result == null ? singleCondition : result.and(singleCondition); + } + return result; + }; + } + + /** + * Constructs a function for constructing where a condition. The where condition will be of the form + * {@literal IS NOT NULL AND IS NOT NULL ... } + */ + private Function, Condition> isNotNullCondition() { + + return columnMap -> { + + Condition result = null; + for (Column column : columnMap.values()) { + Condition singleCondition = column.isNotNull(); + + result = result == null ? singleCondition : result.and(singleCondition); + } + return result; + }; } private String createFindOneSql() { - Select select = selectBuilder().where(getIdColumn().isEqualTo(getBindMarker(ID_SQL_PARAMETER))) // - .build(); + return render(selectBuilder().where(equalityIdWhereCondition()).build()); + } - return render(select); + private Condition equalityIdWhereCondition() { + + Condition aggregate = null; + for (Column column : getIdColumns()) { + + Comparison condition = column.isEqualTo(getBindMarker(column.getName())); + aggregate = aggregate == null ? condition : aggregate.and(condition); + } + + Assert.state(aggregate != null, "We need at least one id column"); + + return aggregate; } private String createAcquireLockById(LockMode lockMode) { @@ -482,9 +561,9 @@ private String createAcquireLockById(LockMode lockMode) { Table table = this.getTable(); Select select = StatementBuilder // - .select(getIdColumn()) // + .select(getSingleNonNullColumn()) // .from(table) // - .where(getIdColumn().isEqualTo(getBindMarker(ID_SQL_PARAMETER))) // + .where(equalityIdWhereCondition()) // .lock(lockMode) // .build(); @@ -496,7 +575,7 @@ private String createAcquireLockAll(LockMode lockMode) { Table table = this.getTable(); Select select = StatementBuilder // - .select(getIdColumn()) // + .select(getSingleNonNullColumn()) // .from(table) // .lock(lockMode) // .build(); @@ -525,14 +604,24 @@ private SelectBuilder.SelectWhere selectBuilder(Collection keyCol Table table = getTable(); Projection projection = getProjection(keyColumns, query, table); - SelectBuilder.SelectAndFrom selectBuilder = StatementBuilder.select(projection.columns()); - SelectBuilder.SelectJoin baseSelect = selectBuilder.from(table); + SelectBuilder.SelectJoin baseSelect = StatementBuilder.select(projection.columns()).from(table); + + return (SelectBuilder.SelectWhere) addJoins(baseSelect, joinTables); + } + + private static SelectBuilder.SelectJoin addJoins(SelectBuilder.SelectJoin baseSelect, List joinTables) { for (Join join : projection.joins()) { - baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(join.joinColumn).equals(join.parentId); - } - return (SelectBuilder.SelectWhere) baseSelect; + Condition condition = null; + for (Pair columnPair : join.columns) { + Comparison elementalCondition = columnPair.getFirst().isEqualTo(columnPair.getSecond()); + condition = condition == null ? elementalCondition : condition.and(elementalCondition); + } + + baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(Objects.requireNonNull(condition)); + } + return baseSelect; } private Projection getProjection(Collection keyColumns, Query query, Table table) { @@ -644,7 +733,7 @@ Column getColumn(AggregatePath path) { return null; } - return sqlContext.getReverseColumn(path); + return sqlContext.getAnyReverseColumn(path); } return sqlContext.getColumn(path); @@ -673,32 +762,46 @@ Join getJoin(AggregatePath path) { } Table currentTable = sqlContext.getTable(path); + AggregatePath.ColumnInfos backRefColumnInfos = path.getTableInfo().reverseColumnInfos(); AggregatePath idDefiningParentPath = path.getIdDefiningParentPath(); Table parentTable = sqlContext.getTable(idDefiningParentPath); + AggregatePath.ColumnInfos idColumnInfos = idDefiningParentPath.getTableInfo().idColumnInfos(); + + List> joinConditions = new ArrayList<>(); + backRefColumnInfos.forEach((ap, ci) -> { + joinConditions.add(Pair.of(currentTable.column(ci.name()), parentTable.column(idColumnInfos.get(ap).name()))); + }); return new Join( // currentTable, // - currentTable.column(path.getTableInfo().reverseColumnInfo().name()), // - parentTable.column(idDefiningParentPath.getTableInfo().idColumnName()) // + joinConditions // ); } private String createFindAllInListSql() { - Select select = selectBuilder().where(getIdColumn().in(getBindMarker(IDS_SQL_PARAMETER))).build(); + In condition = idInWhereClause(); + Select select = selectBuilder().where(condition).build(); return render(select); } - private String createExistsSql() { + private In idInWhereClause() { + List idColumns = getIdColumns(); + Expression expression = idColumns.size() == 1 ? idColumns.get(0) : TupleExpression.create(idColumns); + + return Conditions.in(expression, getBindMarker(IDS_SQL_PARAMETER)); + } + + private String createExistsSql() { Table table = getTable(); Select select = StatementBuilder // - .select(Functions.count(getIdColumn())) // + .select(Functions.count(getSingleNonNullColumn())) // .from(table) // - .where(getIdColumn().isEqualTo(getBindMarker(ID_SQL_PARAMETER))) // + .where(equalityIdWhereCondition()) // .build(); return render(select); @@ -769,7 +872,7 @@ private UpdateBuilder.UpdateWhereAndOr createBaseUpdate() { return Update.builder() // .table(table) // .set(assignments) // - .where(getIdColumn().isEqualTo(getBindMarker(entity.getIdColumn()))); + .where(equalityIdWhereCondition()); } private String createDeleteByIdSql() { @@ -792,16 +895,17 @@ private String createDeleteByIdAndVersionSql() { private DeleteBuilder.DeleteWhereAndOr createBaseDeleteById(Table table) { return Delete.builder().from(table) // - .where(getIdColumn().isEqualTo(getBindMarker(ID_SQL_PARAMETER))); + .where(equalityIdWhereCondition()); } private DeleteBuilder.DeleteWhereAndOr createBaseDeleteByIdIn(Table table) { return Delete.builder().from(table) // - .where(getIdColumn().in(getBindMarker(IDS_SQL_PARAMETER))); + .where(idInWhereClause()); } - private String createDeleteByPathAndCriteria(AggregatePath path, Function rootCondition) { + private String createDeleteByPathAndCriteria(AggregatePath path, + Function, Condition> multiIdCondition) { Table table = Table.create(path.getTableInfo().qualifiedTableName()); @@ -809,16 +913,18 @@ private String createDeleteByPathAndCriteria(AggregatePath path, Function columns = new TreeMap<>(); + AggregatePath.ColumnInfos columnInfos = path.getTableInfo().reverseColumnInfos(); + columnInfos.forEach((ag, ci) -> columns.put(ag, table.column(ci.name()))); if (isFirstNonRoot(path)) { delete = builder // - .where(rootCondition.apply(filterColumn)) // + .where(multiIdCondition.apply(columns)) // .build(); } else { - Condition condition = getSubselectCondition(path, rootCondition, filterColumn); + Condition condition = getSubselectCondition(path, multiIdCondition, columns); delete = builder.where(condition).build(); } @@ -831,7 +937,7 @@ private String createDeleteByListSql() { Delete delete = Delete.builder() // .from(table) // - .where(getIdColumn().in(getBindMarker(IDS_SQL_PARAMETER))) // + .where(idInWhereClause()) // .build(); return render(delete); @@ -857,8 +963,22 @@ private Table getTable() { return sqlContext.getTable(); } - private Column getIdColumn() { - return sqlContext.getIdColumn(); + /** + * @return a single column of the primary key to be used in places where one need something not null to be selected. + */ + private Column getSingleNonNullColumn() { + + AggregatePath.ColumnInfos columnInfos = mappingContext.getAggregatePath(entity).getTableInfo().idColumnInfos(); + return columnInfos.any((ap, ci) -> sqlContext.getTable(columnInfos.fullPath(ap)).column(ci.name()).as(ci.alias())); + } + + private List getIdColumns() { + + AggregatePath.ColumnInfos columnInfos = mappingContext.getAggregatePath(entity).getTableInfo().idColumnInfos(); + List result = new ArrayList<>(columnInfos.size()); + columnInfos.forEach((ap, ci) -> result.add(sqlContext.getColumn(columnInfos.fullPath(ap)))); + + return result; } private Column getVersionColumn() { @@ -1015,7 +1135,8 @@ private SelectBuilder.SelectJoin getExistsSelect() { .select(dialect.getExistsFunction()) // .from(table); - // add possible joins + // collect joins + List joins = new ArrayList<>(); for (PersistentPropertyPath path : mappingContext .findPersistentPropertyPaths(entity.getType(), p -> true)) { @@ -1024,10 +1145,11 @@ private SelectBuilder.SelectJoin getExistsSelect() { // add a join if necessary Join join = getJoin(aggregatePath); if (join != null) { - baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(join.joinColumn).equals(join.parentId); + joins.add(join); } } - return baseSelect; + + return addJoins(baseSelect, joins); } /** @@ -1049,6 +1171,7 @@ private SelectBuilder.SelectJoin getSelectCountWithExpression(Expression... coun .select(Functions.count(countExpressions)) // .from(table); + List joins = new ArrayList<>(); // add possible joins for (PersistentPropertyPath path : mappingContext .findPersistentPropertyPaths(entity.getType(), p -> true)) { @@ -1058,10 +1181,10 @@ private SelectBuilder.SelectJoin getSelectCountWithExpression(Expression... coun // add a join if necessary Join join = getJoin(extPath); if (join != null) { - baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(join.joinColumn).equals(join.parentId); + joins.add(join); } } - return baseSelect; + return addJoins(baseSelect, joins); } private SelectBuilder.SelectOrdered applyQueryOnSelect(Query query, MapSqlParameterSource parameterSource, @@ -1102,62 +1225,7 @@ SelectBuilder.SelectOrdered applyCriteria(@Nullable CriteriaDefinition criteria, /** * Value object representing a {@code JOIN} association. */ - static final class Join { - - private final Table joinTable; - private final Column joinColumn; - private final Column parentId; - - Join(Table joinTable, Column joinColumn, Column parentId) { - - Assert.notNull(joinTable, "JoinTable must not be null"); - Assert.notNull(joinColumn, "JoinColumn must not be null"); - Assert.notNull(parentId, "ParentId must not be null"); - - this.joinTable = joinTable; - this.joinColumn = joinColumn; - this.parentId = parentId; - } - - Table getJoinTable() { - return this.joinTable; - } - - Column getJoinColumn() { - return this.joinColumn; - } - - Column getParentId() { - return this.parentId; - } - - @Override - public boolean equals(@Nullable Object o) { - - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - Join join = (Join) o; - return joinTable.equals(join.joinTable) && joinColumn.equals(join.joinColumn) && parentId.equals(join.parentId); - } - - @Override - public int hashCode() { - return Objects.hash(joinTable, joinColumn, parentId); - } - - @Override - public String toString() { - - return "Join{" + // - "joinTable=" + joinTable + // - ", joinColumn=" + joinColumn + // - ", parentId=" + parentId + // - '}'; - } + record Join(Table joinTable, List> columns) { } /** diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java index 8bf9bb869f..4e9ee941ed 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java @@ -25,7 +25,9 @@ import org.springframework.data.jdbc.support.JdbcUtil; import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.PersistentPropertyPathAccessor; import org.springframework.data.relational.core.conversion.IdValueSource; +import org.springframework.data.relational.core.mapping.AggregatePath; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; @@ -78,9 +80,15 @@ SqlIdentifierParameterSource forInsert(T instance, Class domainType, Iden if (IdValueSource.PROVIDED.equals(idValueSource)) { - RelationalPersistentProperty idProperty = persistentEntity.getRequiredIdProperty(); - Object idValue = persistentEntity.getIdentifierAccessor(instance).getRequiredIdentifier(); - addConvertedPropertyValue(parameterSource, idProperty, idValue, idProperty.getColumnName()); + PersistentPropertyPathAccessor propertyPathAccessor = persistentEntity.getPropertyPathAccessor(instance); + + AggregatePath.ColumnInfos columnInfos = context.getAggregatePath(persistentEntity).getTableInfo().idColumnInfos(); + columnInfos.forEach((ap, __) -> { + Object idValue = propertyPathAccessor.getProperty(columnInfos.fullPath(ap).getRequiredPersistentPropertyPath()); + RelationalPersistentProperty idProperty = ap.getRequiredLeafProperty(); + addConvertedPropertyValue(parameterSource, idProperty, idValue, idProperty.getColumnName()); + }); + } return parameterSource; } @@ -104,20 +112,40 @@ SqlIdentifierParameterSource forUpdate(T instance, Class domainType) { * * @param id the entity id. Must not be {@code null}. * @param domainType the type of the instance. Must not be {@code null}. - * @param name the name to be used for the id parameter. * @return the {@link SqlIdentifierParameterSource} for the query. Guaranteed to not be {@code null}. * @since 2.4 */ - SqlIdentifierParameterSource forQueryById(Object id, Class domainType, SqlIdentifier name) { + SqlIdentifierParameterSource forQueryById(Object id, Class domainType) { SqlIdentifierParameterSource parameterSource = new SqlIdentifierParameterSource(); - addConvertedPropertyValue( // - parameterSource, // - getRequiredPersistentEntity(domainType).getRequiredIdProperty(), // - id, // - name // - ); + RelationalPersistentEntity entity = getRequiredPersistentEntity(domainType); + RelationalPersistentProperty singleIdProperty = entity.getRequiredIdProperty(); + + if (singleIdProperty.isEntity()) { + + RelationalPersistentEntity complexId = context.getPersistentEntity(singleIdProperty); + PersistentPropertyPathAccessor accessor = complexId.getPropertyPathAccessor(id); + + context.getAggregatePath(entity).getTableInfo().idColumnInfos().forEach((ap, ci) -> { + Object idValue = accessor.getProperty(ap.getRequiredPersistentPropertyPath()); + + addConvertedPropertyValue( // + parameterSource, // + ap.getRequiredLeafProperty(), // + idValue, // + ci.name() // + ); + }); + } else { + + addConvertedPropertyValue( // + parameterSource, // + singleIdProperty, // + id, // + singleIdProperty.getColumnName() // + ); + } return parameterSource; } @@ -133,9 +161,32 @@ SqlIdentifierParameterSource forQueryByIds(Iterable ids, Class domainT SqlIdentifierParameterSource parameterSource = new SqlIdentifierParameterSource(); - addConvertedPropertyValuesAsList(parameterSource, getRequiredPersistentEntity(domainType).getRequiredIdProperty(), - ids); + RelationalPersistentEntity entity = context.getPersistentEntity(domainType); + RelationalPersistentProperty singleIdProperty = entity.getRequiredIdProperty(); + + if (singleIdProperty.isEntity()) { + + RelationalPersistentEntity complexId = context.getPersistentEntity(singleIdProperty); + AggregatePath.ColumnInfos idColumnInfos = context.getAggregatePath(entity).getTableInfo().idColumnInfos(); + + List parameterValues = new ArrayList<>(); + for (Object id : ids) { + + PersistentPropertyPathAccessor accessor = complexId.getPropertyPathAccessor(id); + + List tupleList = new ArrayList<>(); + idColumnInfos.forEach((ap, ci) -> { + tupleList.add(accessor.getProperty(ap.getRequiredPersistentPropertyPath())); + }); + parameterValues.add(tupleList.toArray(new Object[0])); + } + + parameterSource.addValue(SqlGenerator.IDS_SQL_PARAMETER, parameterValues); + } else { + addConvertedPropertyValuesAsList(parameterSource, getRequiredPersistentEntity(domainType).getRequiredIdProperty(), + ids); + } return parameterSource; } @@ -156,21 +207,6 @@ SqlIdentifierParameterSource forQueryByIdentifier(Identifier identifier) { return parameterSource; } - /** - * Utility to create {@link Predicate}s. - */ - static class Predicates { - - /** - * Include all {@link Predicate} returning {@literal false} to never skip a property. - * - * @return the include all {@link Predicate}. - */ - static Predicate includeAll() { - return it -> false; - } - } - private void addConvertedPropertyValue(SqlIdentifierParameterSource parameterSource, RelationalPersistentProperty property, @Nullable Object value, SqlIdentifier name) { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java index a7d187b441..a02c681b66 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java @@ -17,12 +17,10 @@ import java.util.ArrayList; import java.util.List; -import java.util.stream.Stream; import org.springframework.data.domain.Sort; import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.jdbc.core.convert.QueryMapper; -import org.springframework.data.mapping.Parameter; import org.springframework.data.mapping.PersistentPropertyPath; import org.springframework.data.relational.core.dialect.Dialect; import org.springframework.data.relational.core.dialect.RenderContextFactory; @@ -31,14 +29,9 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.query.Criteria; -import org.springframework.data.relational.core.sql.Condition; -import org.springframework.data.relational.core.sql.Conditions; -import org.springframework.data.relational.core.sql.Delete; +import org.springframework.data.relational.core.sql.*; import org.springframework.data.relational.core.sql.DeleteBuilder.DeleteWhere; -import org.springframework.data.relational.core.sql.Select; import org.springframework.data.relational.core.sql.SelectBuilder.SelectWhere; -import org.springframework.data.relational.core.sql.StatementBuilder; -import org.springframework.data.relational.core.sql.Table; import org.springframework.data.relational.core.sql.render.SqlRenderer; import org.springframework.data.relational.repository.query.RelationalEntityMetadata; import org.springframework.data.relational.repository.query.RelationalParameterAccessor; @@ -49,8 +42,8 @@ import org.springframework.util.Assert; /** - * Implementation of {@link RelationalQueryCreator} that creates {@link List} of deletion {@link ParametrizedQuery} - * from a {@link PartTree}. + * Implementation of {@link RelationalQueryCreator} that creates {@link List} of deletion {@link ParametrizedQuery} from + * a {@link PartTree}. * * @author Yunyoung LEE * @author Nikita Konev @@ -96,13 +89,16 @@ protected List complete(@Nullable Criteria criteria, Sort sor Table table = Table.create(entityMetadata.getTableName()); MapSqlParameterSource parameterSource = new MapSqlParameterSource(); - SqlContext sqlContext = new SqlContext(entity); + SqlContext sqlContext = new SqlContext(); Condition condition = criteria == null ? null : queryMapper.getMappedObject(parameterSource, criteria, table, entity); + List idColumns = context.getAggregatePath(entity).getTableInfo().idColumnInfos() + .toList(ci -> table.column(ci.name())); + // create select criteria query for subselect - SelectWhere selectBuilder = StatementBuilder.select(sqlContext.getIdColumn()).from(table); + SelectWhere selectBuilder = StatementBuilder.select(idColumns).from(table); Select select = condition == null ? selectBuilder.build() : selectBuilder.where(condition).build(); // create delete relation queries @@ -139,19 +135,28 @@ private void deleteRelations(List deleteChain, RelationalPersistentEntit if (aggregatePath.isEntity() && !aggregatePath.isEmbedded()) { - SqlContext sqlContext = new SqlContext(aggregatePath.getLeafEntity()); + SqlContext sqlContext = new SqlContext(); + + // MariaDB prior to 11.6 does not support aliases for delete statements + Table table = sqlContext.getUnaliasedTable(aggregatePath); + + List reverseColumns = aggregatePath.getTableInfo().reverseColumnInfos() + .toList(ci -> table.column(ci.name())); + Expression expression = TupleExpression.maybeWrap(reverseColumns); + + Condition inCondition = Conditions.in(expression, parentSelect); - Condition inCondition = Conditions - .in(sqlContext.getTable().column(aggregatePath.getTableInfo().reverseColumnInfo().name()), parentSelect); + List parentIdColumns = aggregatePath.getIdDefiningParentPath().getTableInfo().idColumnInfos() + .toList(ci -> table.column(ci.name())); Select select = StatementBuilder.select( // - sqlContext.getTable().column(aggregatePath.getIdDefiningParentPath().getTableInfo().idColumnName()) // - ).from(sqlContext.getTable()) // + parentIdColumns // + ).from(table) // .where(inCondition) // .build(); deleteRelations(deleteChain, aggregatePath.getLeafEntity(), select); - deleteChain.add(StatementBuilder.delete(sqlContext.getTable()).where(inCondition).build()); + deleteChain.add(StatementBuilder.delete(table).where(inCondition).build()); } } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java index cc28ff2f18..7d22cd2fdc 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java @@ -17,7 +17,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.Objects; import java.util.Optional; import org.springframework.data.domain.Pageable; @@ -32,14 +31,7 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.query.Criteria; -import org.springframework.data.relational.core.sql.Column; -import org.springframework.data.relational.core.sql.Expression; -import org.springframework.data.relational.core.sql.Expressions; -import org.springframework.data.relational.core.sql.Functions; -import org.springframework.data.relational.core.sql.Select; -import org.springframework.data.relational.core.sql.SelectBuilder; -import org.springframework.data.relational.core.sql.StatementBuilder; -import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.*; import org.springframework.data.relational.core.sql.render.SqlRenderer; import org.springframework.data.relational.repository.Lock; import org.springframework.data.relational.repository.query.RelationalEntityMetadata; @@ -222,7 +214,8 @@ SelectBuilder.SelectLimitOffset createSelectClause(RelationalPersistentEntity SelectBuilder.SelectJoin builder; if (tree.isExistsProjection()) { - Column idColumn = table.column(entity.getIdColumn()); + AggregatePath.ColumnInfo anyIdColumnInfo = context.getAggregatePath(entity).getTableInfo().idColumnInfos().any(); + Column idColumn = table.column(anyIdColumnInfo.name()); builder = Select.builder().select(idColumn).from(table); } else if (tree.isCountProjection()) { builder = Select.builder().select(Functions.count(Expressions.asterisk())).from(table); @@ -237,7 +230,7 @@ private SelectBuilder.SelectJoin selectBuilder(Table table) { List columnExpressions = new ArrayList<>(); RelationalPersistentEntity entity = entityMetadata.getTableEntity(); - SqlContext sqlContext = new SqlContext(entity); + SqlContext sqlContext = new SqlContext(); List joinTables = new ArrayList<>(); for (PersistentPropertyPath path : context @@ -267,7 +260,19 @@ private SelectBuilder.SelectJoin selectBuilder(Table table) { SelectBuilder.SelectJoin baseSelect = selectBuilder.from(table); for (Join join : joinTables) { - baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(join.joinColumn).equals(join.parentId); + + Condition condition = null; + + for (int i = 0; i < join.joinColumns.size(); i++) { + Column parentColumn = join.parentId.get(i); + Column joinColumn = join.joinColumns.get(i); + Comparison singleCondition = joinColumn.isEqualTo(parentColumn); + condition = condition == null ? singleCondition : condition.and(singleCondition); + } + + Assert.state(condition != null, "No condition found"); + + baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(condition); } return baseSelect; @@ -276,7 +281,7 @@ private SelectBuilder.SelectJoin selectBuilder(Table table) { /** * Create a {@link Column} for {@link AggregatePath}. * - * @param sqlContext + * @param sqlContext for generating SQL constructs. * @param path the path to the column in question. * @return the statement as a {@link String}. Guaranteed to be not {@literal null}. */ @@ -293,9 +298,6 @@ private Column getColumn(SqlContext sqlContext, AggregatePath path) { if (path.isEntity()) { - // Simple entities without id include there backreference as an synthetic id in order to distinguish null entities - // from entities with only null values. - if (path.isQualified() // || path.isCollectionLike() // || path.hasIdProperty() // @@ -303,7 +305,9 @@ private Column getColumn(SqlContext sqlContext, AggregatePath path) { return null; } - return sqlContext.getReverseColumn(path); + // Simple entities without id include there backreference as an synthetic id in order to distinguish null entities + // from entities with only null values. + return sqlContext.getAnyReverseColumn(path); } return sqlContext.getColumn(path); @@ -321,53 +325,25 @@ Join getJoin(SqlContext sqlContext, AggregatePath path) { AggregatePath idDefiningParentPath = path.getIdDefiningParentPath(); Table parentTable = sqlContext.getTable(idDefiningParentPath); + List reverseColumns = path.getTableInfo().reverseColumnInfos().toList(ci -> currentTable.column(ci.name())); + List idColumns = idDefiningParentPath.getTableInfo().idColumnInfos() + .toList(ci -> parentTable.column(ci.name())); return new Join( // currentTable, // - currentTable.column(path.getTableInfo().reverseColumnInfo().name()), // - parentTable.column(idDefiningParentPath.getTableInfo().idColumnName()) // + reverseColumns, // + idColumns // ); } /** * Value object representing a {@code JOIN} association. */ - static private final class Join { + private record Join(Table joinTable, List joinColumns, List parentId) { - private final Table joinTable; - private final Column joinColumn; - private final Column parentId; - - Join(Table joinTable, Column joinColumn, Column parentId) { - - Assert.notNull(joinTable, "JoinTable must not be null"); - Assert.notNull(joinColumn, "JoinColumn must not be null"); - Assert.notNull(parentId, "ParentId must not be null"); - - this.joinTable = joinTable; - this.joinColumn = joinColumn; - this.parentId = parentId; - } - - @Override - public boolean equals(@Nullable Object o) { - - if (this == o) - return true; - if (o == null || getClass() != o.getClass()) - return false; - Join join = (Join) o; - return joinTable.equals(join.joinTable) && joinColumn.equals(join.joinColumn) && parentId.equals(join.parentId); - } - - @Override - public int hashCode() { - return Objects.hash(joinTable, joinColumn, parentId); + Join { + Assert.isTrue(joinColumns.size() == parentId.size(), + "Both sides of a join condition must have the same number of columns"); } - @Override - public String toString() { - - return "Join{" + "joinTable=" + joinTable + ", joinColumn=" + joinColumn + ", parentId=" + parentId + '}'; - } } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/SqlContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/SqlContext.java index 4d34666631..ab6db1ab51 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/SqlContext.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/SqlContext.java @@ -16,13 +16,12 @@ package org.springframework.data.jdbc.repository.query; import org.springframework.data.relational.core.mapping.AggregatePath; -import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.sql.Column; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; /** - * Utility to get from path to SQL DSL elements. This is a temporary class and duplicates + * Utility to get from path to SQL DSL elements. This is a temporary class and duplicates parts of * {@link org.springframework.data.jdbc.core.convert.SqlContext}. * * @author Jens Schauder @@ -32,42 +31,29 @@ */ class SqlContext { - private final RelationalPersistentEntity entity; - private final Table table; - - SqlContext(RelationalPersistentEntity entity) { - - this.entity = entity; - this.table = Table.create(entity.getQualifiedTableName()); - } + Table getTable(AggregatePath path) { - Column getIdColumn() { - return table.column(entity.getIdColumn()); + Table table = getUnaliasedTable(path); + AggregatePath.TableInfo tableInfo = path.getTableInfo(); + SqlIdentifier tableAlias = tableInfo.tableAlias(); + return tableAlias == null ? table : table.as(tableAlias); } - Column getVersionColumn() { - return table.column(entity.getRequiredVersionProperty().getColumnName()); - } + Column getColumn(AggregatePath path) { - Table getTable() { - return table; + AggregatePath.ColumnInfo columnInfo = path.getColumnInfo(); + return getTable(path).column(columnInfo.name()).as(columnInfo.alias()); } - Table getTable(AggregatePath path) { + Column getAnyReverseColumn(AggregatePath path) { - SqlIdentifier tableAlias = path.getTableInfo().tableAlias(); - Table table = Table.create(path.getTableInfo().qualifiedTableName()); - return tableAlias == null ? table : table.as(tableAlias); + AggregatePath.ColumnInfo anyReverseColumnInfo = path.getTableInfo().reverseColumnInfos().any(); + return getTable(path).column(anyReverseColumnInfo.name()).as(anyReverseColumnInfo.alias()); } - Column getColumn(AggregatePath path) { - AggregatePath.ColumnInfo columnInfo = path.getColumnInfo(); - AggregatePath.ColumnInfo columnInfo1 = path.getColumnInfo(); - return getTable(path).column(columnInfo1.name()).as(columnInfo.alias()); - } + public Table getUnaliasedTable(AggregatePath path) { - Column getReverseColumn(AggregatePath path) { - return getTable(path).column(path.getTableInfo().reverseColumnInfo().name()) - .as(path.getTableInfo().reverseColumnInfo().alias()); + AggregatePath.TableInfo tableInfo = path.getTableInfo(); + return Table.create(tableInfo.qualifiedTableName()); } } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java new file mode 100644 index 0000000000..e053bc091f --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java @@ -0,0 +1,308 @@ +/* + * Copyright 2017-2024 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.jdbc.core; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.annotation.Id; +import org.springframework.data.domain.Sort; +import org.springframework.data.jdbc.core.convert.DataAccessStrategy; +import org.springframework.data.jdbc.core.convert.JdbcConverter; +import org.springframework.data.jdbc.testing.DatabaseType; +import org.springframework.data.jdbc.testing.EnabledOnDatabase; +import org.springframework.data.jdbc.testing.IntegrationTest; +import org.springframework.data.jdbc.testing.TestConfiguration; +import org.springframework.data.relational.core.mapping.Embedded; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.core.query.Query; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; + +/** + * Integration tests for {@link JdbcAggregateTemplate} and it's handling of entities with embedded entities as keys. + * + * @author Jens Schauder + */ +@IntegrationTest +@EnabledOnDatabase(DatabaseType.HSQL) +public class CompositeIdAggregateTemplateHsqlIntegrationTests { + + @Autowired JdbcAggregateOperations template; + @Autowired private NamedParameterJdbcOperations namedParameterJdbcTemplate; + + @Test // GH-574 + void saveAndLoadSimpleEntity() { + + SimpleEntity entity = template.insert(new SimpleEntity(new WrappedPk(23L), "alpha")); + + assertThat(entity.wrappedPk).isNotNull() // + .extracting(WrappedPk::id).isNotNull(); + + SimpleEntity reloaded = template.findById(entity.wrappedPk, SimpleEntity.class); + + assertThat(reloaded).isEqualTo(entity); + } + + @Test // GH-574 + void saveAndLoadEntityWithList() { + + WithList entity = template + .insert(new WithList(new WrappedPk(23L), "alpha", List.of(new Child("Romulus"), new Child("Remus")))); + + assertThat(entity.wrappedPk).isNotNull() // + .extracting(WrappedPk::id).isNotNull(); + + WithList reloaded = template.findById(entity.wrappedPk, WithList.class); + + assertThat(reloaded).isEqualTo(entity); + } + + @Test // GH-574 + void saveAndLoadSimpleEntityWithEmbeddedPk() { + + SimpleEntityWithEmbeddedPk entity = template + .insert(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha")); + + SimpleEntityWithEmbeddedPk reloaded = template.findById(entity.embeddedPk, SimpleEntityWithEmbeddedPk.class); + + assertThat(reloaded).isEqualTo(entity); + } + + @Test // GH-574 + void saveAndLoadSimpleEntitiesWithEmbeddedPk() { + + List entities = (List) template + .insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"), + new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "y"), "beta"), + new SimpleEntityWithEmbeddedPk(new EmbeddedPk(24L, "y"), "gamma"))); + + List firstTwoPks = entities.stream().limit(2).map(SimpleEntityWithEmbeddedPk::embeddedPk).toList(); + Iterable reloaded = template.findAllById(firstTwoPks, SimpleEntityWithEmbeddedPk.class); + + assertThat(reloaded).containsExactlyInAnyOrder(entities.get(0), entities.get(1)); + } + + @Test // GH-574 + void deleteSingleSimpleEntityWithEmbeddedPk() { + + List entities = (List) template + .insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"), + new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "y"), "beta"), + new SimpleEntityWithEmbeddedPk(new EmbeddedPk(24L, "y"), "gamma"))); + + template.delete(entities.get(1)); + + Iterable reloaded = template.findAll(SimpleEntityWithEmbeddedPk.class); + + assertThat(reloaded).containsExactlyInAnyOrder(entities.get(0), entities.get(2)); + } + + @Test // GH-574 + void deleteMultipleSimpleEntityWithEmbeddedPk() { + + List entities = (List) template + .insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"), + new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "y"), "beta"), + new SimpleEntityWithEmbeddedPk(new EmbeddedPk(24L, "y"), "gamma"))); + + template.deleteAll(List.of(entities.get(1), entities.get(0))); + + Iterable reloaded = template.findAll(SimpleEntityWithEmbeddedPk.class); + + assertThat(reloaded).containsExactly(entities.get(2)); + } + + @Test // GH-574 + void existsSingleSimpleEntityWithEmbeddedPk() { + + List entities = (List) template + .insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"), + new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "y"), "beta"), + new SimpleEntityWithEmbeddedPk(new EmbeddedPk(24L, "y"), "gamma"))); + + assertThat(template.existsById(entities.get(1).embeddedPk, SimpleEntityWithEmbeddedPk.class)).isTrue(); + assertThat(template.existsById(new EmbeddedPk(24L, "x"), SimpleEntityWithEmbeddedPk.class)).isFalse(); + + } + + @Test // GH-574 + void updateSingleSimpleEntityWithEmbeddedPk() { + + List entities = (List) template + .insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"), + new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "y"), "beta"), + new SimpleEntityWithEmbeddedPk(new EmbeddedPk(24L, "y"), "gamma"))); + + SimpleEntityWithEmbeddedPk updated = new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "ALPHA"); + template.save(updated); + + Iterable reloaded = template.findAll(SimpleEntityWithEmbeddedPk.class); + + assertThat(reloaded).containsExactlyInAnyOrder(updated, entities.get(1), entities.get(2)); + } + + @Test // GH-574 + void saveAndLoadSingleReferenceAggregate() { + + SingleReference entity = template.insert(new SingleReference(new EmbeddedPk(23L, "x"), "alpha", new Child("Alf"))); + + SingleReference reloaded = template.findById(entity.embeddedPk, SingleReference.class); + + assertThat(reloaded).isEqualTo(entity); + } + + @Test // GH-574 + void updateSingleReferenceAggregate() { + + EmbeddedPk id = new EmbeddedPk(23L, "x"); + template.insert(new SingleReference(id, "alpha", new Child("Alf"))); + + SingleReference updated = new SingleReference(id, "beta", new Child("Barny")); + template.save(updated); + + List all = template.findAll(SingleReference.class); + + assertThat(all).containsExactly(updated); + } + + @Test // GH-574 + void saveAndLoadWithListAndCompositeId() { + + WithListAndCompositeId entity = template.insert( // + new WithListAndCompositeId( // + new EmbeddedPk(23L, "x"), "alpha", // + List.of( // + new Child("Alf"), // + new Child("Bob"), // + new Child("Flo") // + ) // + ) // + ); + + WithListAndCompositeId reloaded = template.findById(entity.embeddedPk, WithListAndCompositeId.class); + + assertThat(reloaded).isEqualTo(entity); + } + + @Test // GH-574 + void sortByCompositeIdParts() { + + SimpleEntityWithEmbeddedPk alpha = template.insert( // + new SimpleEntityWithEmbeddedPk( // + new EmbeddedPk(23L, "x"), "alpha" // + )); + SimpleEntityWithEmbeddedPk bravo = template.insert( // + new SimpleEntityWithEmbeddedPk( // + new EmbeddedPk(22L, "a"), "bravo" // + )); + SimpleEntityWithEmbeddedPk charlie = template.insert( // + new SimpleEntityWithEmbeddedPk( // + new EmbeddedPk(21L, "z"), "charlie" // + ) // + ); + + assertThat( // + template.findAll(SimpleEntityWithEmbeddedPk.class, Sort.by("embeddedPk.one"))) // + .containsExactly( // + charlie, bravo, alpha // + ); + + assertThat( // + template.findAll(SimpleEntityWithEmbeddedPk.class, Sort.by("embeddedPk.two").descending())) // + .containsExactly( // + charlie, alpha, bravo // + ); + } + + @Test // GH-574 + void projectByCompositeIdParts() { + + SimpleEntityWithEmbeddedPk alpha = template.insert( // + new SimpleEntityWithEmbeddedPk( // + new EmbeddedPk(23L, "x"), "alpha" // + )); + + Query projectingQuery = Query.empty().columns( "embeddedPk.two", "name"); + SimpleEntityWithEmbeddedPk projected = template.findOne(projectingQuery, SimpleEntityWithEmbeddedPk.class).orElseThrow(); + + // Projection still does a full select, otherwise one would be null. + // See https://github.com/spring-projects/spring-data-relational/issues/1821 + assertThat(projected).isEqualTo(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha")); + } + + private record WrappedPk(Long id) { + } + + private record SimpleEntity( // + @Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) WrappedPk wrappedPk, // + String name // + ) { + } + + private record Child(String name) { + } + + private record WithList( // + @Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) WrappedPk wrappedPk, // + String name, List children) { + } + + private record EmbeddedPk(Long one, String two) { + } + + private record SimpleEntityWithEmbeddedPk( // + @Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) EmbeddedPk embeddedPk, // + String name // + ) { + } + + private record SingleReference( // + @Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) EmbeddedPk embeddedPk, // + String name, // + Child child) { + } + + private record WithListAndCompositeId( // + @Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) EmbeddedPk embeddedPk, // + String name, // + List child) { + } + + @Configuration + @Import(TestConfiguration.class) + static class Config { + + @Bean + Class testClass() { + return CompositeIdAggregateTemplateHsqlIntegrationTests.class; + } + + @Bean + JdbcAggregateOperations operations(ApplicationEventPublisher publisher, RelationalMappingContext context, + DataAccessStrategy dataAccessStrategy, JdbcConverter converter) { + return new JdbcAggregateTemplate(publisher, context, converter, dataAccessStrategy); + } + } +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java index 5873ce23a1..b5ec5ece9f 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java @@ -22,11 +22,13 @@ import java.util.Map; import java.util.UUID; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.data.annotation.Id; import org.springframework.data.jdbc.core.PersistentPropertyPathTestUtils; import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; import org.springframework.data.relational.core.mapping.AggregatePath; +import org.springframework.data.relational.core.mapping.Embedded; /** * Unit tests for the {@link JdbcIdentifierBuilder}. @@ -40,90 +42,144 @@ public class JdbcIdentifierBuilderUnitTests { throw new UnsupportedOperationException(); }); - @Test // DATAJDBC-326 - public void parametersWithPropertyKeysUseTheParentPropertyJdbcType() { - - Identifier identifier = JdbcIdentifierBuilder.forBackReferences(converter, getPath("child"), "eins").build(); - - assertThat(identifier.getParts()) // - .extracting("name", "value", "targetType") // - .containsExactly( // - tuple(quoted("DUMMY_ENTITY"), "eins", UUID.class) // - ); + @Nested + class WithSimpleId { + @Test // DATAJDBC-326 + void parametersWithPropertyKeysUseTheParentPropertyJdbcType() { + + Identifier identifier = JdbcIdentifierBuilder.forBackReferences(converter, getPath("child"), "eins").build(); + + assertThat(identifier.getParts()) // + .extracting("name", "value", "targetType") // + .containsExactly( // + tuple(quoted("DUMMY_ENTITY"), "eins", UUID.class) // + ); + } + + @Test // DATAJDBC-326 + void qualifiersForMaps() { + + AggregatePath path = getPath("children"); + + Identifier identifier = JdbcIdentifierBuilder // + .forBackReferences(converter, path, "parent-eins") // + .withQualifier(path, "map-key-eins") // + .build(); + + assertThat(identifier.getParts()) // + .extracting("name", "value", "targetType") // + .containsExactlyInAnyOrder( // + tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class), // + tuple(quoted("DUMMY_ENTITY_KEY"), "map-key-eins", String.class) // + ); + } + + @Test // DATAJDBC-326 + void qualifiersForLists() { + + AggregatePath path = getPath("moreChildren"); + + Identifier identifier = JdbcIdentifierBuilder // + .forBackReferences(converter, path, "parent-eins") // + .withQualifier(path, "list-index-eins") // + .build(); + + assertThat(identifier.getParts()) // + .extracting("name", "value", "targetType") // + .containsExactlyInAnyOrder( // + tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class), // + tuple(quoted("DUMMY_ENTITY_KEY"), "list-index-eins", Integer.class) // + ); + } + + @Test // DATAJDBC-326 + void backreferenceAcrossEmbeddable() { + + Identifier identifier = JdbcIdentifierBuilder // + .forBackReferences(converter, getPath("embeddable.child"), "parent-eins") // + .build(); + + assertThat(identifier.getParts()) // + .extracting("name", "value", "targetType") // + .containsExactly( // + tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class) // + ); + } + + @Test // DATAJDBC-326 + void backreferenceAcrossNoId() { + + Identifier identifier = JdbcIdentifierBuilder // + .forBackReferences(converter, getPath("noId.child"), "parent-eins") // + .build(); + + assertThat(identifier.getParts()) // + .extracting("name", "value", "targetType") // + .containsExactly( // + tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class) // + ); + } + + private AggregatePath getPath(String dotPath) { + return JdbcIdentifierBuilderUnitTests.this.getPath(dotPath, DummyEntity.class); + } } - @Test // DATAJDBC-326 - public void qualifiersForMaps() { + @Nested + class WithCompositeId { - AggregatePath path = getPath("children"); + CompositeId exampleId = new CompositeId("parent-eins", 23); - Identifier identifier = JdbcIdentifierBuilder // - .forBackReferences(converter, path, "parent-eins") // - .withQualifier(path, "map-key-eins") // - .build(); + @Test // GH-574 + void forBackReferences() { - assertThat(identifier.getParts()) // - .extracting("name", "value", "targetType") // - .containsExactlyInAnyOrder( // - tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class), // - tuple(quoted("DUMMY_ENTITY_KEY"), "map-key-eins", String.class) // - ); - } + AggregatePath path = getPath("children"); - @Test // DATAJDBC-326 - public void qualifiersForLists() { + Identifier identifier = JdbcIdentifierBuilder // + .forBackReferences(converter, path, exampleId) // + .build(); - AggregatePath path = getPath("moreChildren"); + assertThat(identifier.getParts()) // + .extracting("name", "value", "targetType") // + .containsExactlyInAnyOrder( // + tuple(quoted("DUMMY_ENTITY_WITH_COMPOSITE_ID_ONE"), exampleId.one, String.class), // + tuple(quoted("DUMMY_ENTITY_WITH_COMPOSITE_ID_TWO"), exampleId.two, Integer.class) // + ); + } - Identifier identifier = JdbcIdentifierBuilder // - .forBackReferences(converter, path, "parent-eins") // - .withQualifier(path, "list-index-eins") // - .build(); + private AggregatePath getPath(String dotPath) { + return JdbcIdentifierBuilderUnitTests.this.getPath(dotPath, DummyEntityWithCompositeId.class); + } + } - assertThat(identifier.getParts()) // - .extracting("name", "value", "targetType") // - .containsExactlyInAnyOrder( // - tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class), // - tuple(quoted("DUMMY_ENTITY_KEY"), "list-index-eins", Integer.class) // - ); + private AggregatePath getPath(String dotPath, Class entityType) { + return context.getAggregatePath(PersistentPropertyPathTestUtils.getPath(dotPath, entityType, context)); } - @Test // DATAJDBC-326 - public void backreferenceAcrossEmbeddable() { + @SuppressWarnings("unused") + static class DummyEntity { - Identifier identifier = JdbcIdentifierBuilder // - .forBackReferences(converter, getPath("embeddable.child"), "parent-eins") // - .build(); + @Id UUID id; + String one; + Long two; + Child child; - assertThat(identifier.getParts()) // - .extracting("name", "value", "targetType") // - .containsExactly( // - tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class) // - ); - } + Map children; - @Test // DATAJDBC-326 - public void backreferenceAcrossNoId() { + List moreChildren; - Identifier identifier = JdbcIdentifierBuilder // - .forBackReferences(converter, getPath("noId.child"), "parent-eins") // - .build(); + Embeddable embeddable; - assertThat(identifier.getParts()) // - .extracting("name", "value", "targetType") // - .containsExactly( // - tuple(quoted("DUMMY_ENTITY"), "parent-eins", UUID.class) // - ); + NoId noId; } - private AggregatePath getPath(String dotPath) { - return context.getAggregatePath(PersistentPropertyPathTestUtils.getPath(dotPath, DummyEntity.class, context)); + record CompositeId(String one, Integer two) { } - @SuppressWarnings("unused") - static class DummyEntity { + static class DummyEntityWithCompositeId { - @Id UUID id; + @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) + @Id CompositeId id; String one; Long two; Child child; diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorContextBasedNamingStrategyUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorContextBasedNamingStrategyUnitTests.java index 745698211b..f25f421c30 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorContextBasedNamingStrategyUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorContextBasedNamingStrategyUnitTests.java @@ -89,7 +89,7 @@ public void cascadingDeleteFirstLevel() { assertThat(sql).isEqualTo( // "DELETE FROM " // + user + ".referenced_entity WHERE " // - + user + ".referenced_entity.dummy_entity = :rootId" // + + user + ".referenced_entity.dummy_entity = :id" // ); }); } @@ -107,7 +107,7 @@ public void cascadingDeleteAllSecondLevel() { "DELETE FROM " + user + ".second_level_referenced_entity " // + "WHERE " + user + ".second_level_referenced_entity.referenced_entity IN " // + "(SELECT " + user + ".referenced_entity.l1id FROM " + user + ".referenced_entity " // - + "WHERE " + user + ".referenced_entity.dummy_entity = :rootId)"); + + "WHERE " + user + ".referenced_entity.dummy_entity = :id)"); }); } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java index 7c510617b2..a6a7c63bae 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java @@ -20,17 +20,18 @@ import static org.assertj.core.api.SoftAssertions.*; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.data.annotation.Id; import org.springframework.data.jdbc.core.PersistentPropertyPathTestUtils; import org.springframework.data.jdbc.core.mapping.AggregateReference; import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; +import org.springframework.data.mapping.PersistentPropertyPath; import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.mapping.Embedded; import org.springframework.data.relational.core.mapping.Embedded.OnEmpty; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.mapping.Table; import org.springframework.data.relational.core.sql.Aliased; import org.springframework.data.relational.core.sql.SqlIdentifier; @@ -41,6 +42,7 @@ * * @author Bastian Wilhelm * @author Mark Paluch + * @author Jens Schauder */ class SqlGeneratorEmbeddedUnitTests { @@ -84,6 +86,139 @@ void findOne() { }); } + @Test // GH-574 + void findOneWrappedId() { + + SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithWrappedId.class); + + String sql = sqlGenerator.getFindOne(); + + assertSoftly(softly -> { + + softly.assertThat(sql).startsWith("SELECT") // + .contains("dummy_entity_with_wrapped_id.name AS name") // + .contains("dummy_entity_with_wrapped_id.id") // + .contains("WHERE dummy_entity_with_wrapped_id.id = :id"); + }); + } + + @Test // GH-574 + void findOneEmbeddedId() { + + SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + + String sql = sqlGenerator.getFindOne(); + + assertSoftly(softly -> { + + softly.assertThat(sql).startsWith("SELECT") // + .contains("dummy_entity_with_embedded_id.name AS name") // + .contains("dummy_entity_with_embedded_id.one") // + .contains("dummy_entity_with_embedded_id.two") // + .contains(" WHERE ") // + .contains("dummy_entity_with_embedded_id.one = :one") // + .contains("dummy_entity_with_embedded_id.two = :two"); + }); + } + + @Test // GH-574 + void deleteByIdEmbeddedId() { + + SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + + String sql = sqlGenerator.getDeleteById(); + + assertSoftly(softly -> { + + softly.assertThat(sql).startsWith("DELETE") // + .contains(" WHERE ") // + .contains("dummy_entity_with_embedded_id.one = :one") // + .contains("dummy_entity_with_embedded_id.two = :two"); + }); + } + + @Test // GH-574 + void deleteByIdInEmbeddedId() { + + SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + + String sql = sqlGenerator.getDeleteByIdIn(); + + assertSoftly(softly -> { + + softly.assertThat(sql).startsWith("DELETE") // + .contains(" WHERE ") // + .contains("(dummy_entity_with_embedded_id.one, dummy_entity_with_embedded_id.two) IN (:ids)"); + }); + } + + @Test // GH-574 + void deleteByPathEmbeddedId() { + + SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + PersistentPropertyPath path = PersistentPropertyPathTestUtils.getPath("other", + DummyEntityWithEmbeddedIdAndReference.class, context); + + String sql = sqlGenerator.createDeleteByPath(path); + + assertSoftly(softly -> { + + softly.assertThat(sql).startsWith("DELETE FROM other_entity WHERE") // + .contains("other_entity.dummy_entity_with_embedded_id_and_reference_one = :one") // + .contains("other_entity.dummy_entity_with_embedded_id_and_reference_two = :two"); + }); + } + + @Test // GH-574 + void deleteInByPathEmbeddedId() { + + SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + PersistentPropertyPath path = PersistentPropertyPathTestUtils.getPath("other", + DummyEntityWithEmbeddedIdAndReference.class, context); + + String sql = sqlGenerator.createDeleteInByPath(path); + + assertSoftly(softly -> { + + softly.assertThat(sql).startsWith("DELETE FROM other_entity WHERE") // + .contains(" WHERE ") // + .contains( + "(other_entity.dummy_entity_with_embedded_id_and_reference_one, other_entity.dummy_entity_with_embedded_id_and_reference_two) IN (:ids)"); + }); + } + + @Test // GH-574 + void updateWithEmbeddedId() { + + SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + + String sql = sqlGenerator.getUpdate(); + + assertSoftly(softly -> { + + softly.assertThat(sql).startsWith("UPDATE") // + .contains(" WHERE ") // + .contains("dummy_entity_with_embedded_id.one = :one") // + .contains("dummy_entity_with_embedded_id.two = :two"); + }); + } + + @Test // GH-574 + void existsByIdEmbeddedId() { + + SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + + String sql = sqlGenerator.getExists(); + + assertSoftly(softly -> { + + softly.assertThat(sql).startsWith("SELECT COUNT") // + .contains(" WHERE ") // + .contains("dummy_entity_with_embedded_id.one = :one") // + .contains("dummy_entity_with_embedded_id.two = :two"); + }); + } + @Test // DATAJDBC-111 void findAll() { final String sql = sqlGenerator.getFindAll(); @@ -109,7 +244,8 @@ void findAll() { @Test // DATAJDBC-111 void findAllInList() { - final String sql = sqlGenerator.getFindAllInList(); + + String sql = sqlGenerator.getFindAllInList(); assertSoftly(softly -> { @@ -130,6 +266,45 @@ void findAllInList() { }); } + @Test // GH-574 + void findAllInListEmbeddedId() { + + SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + + String sql = sqlGenerator.getFindAllInList(); + + assertSoftly(softly -> { + + softly.assertThat(sql).startsWith("SELECT") // + .contains("dummy_entity_with_embedded_id.name AS name") // + .contains("dummy_entity_with_embedded_id.one") // + .contains("dummy_entity_with_embedded_id.two") // + .contains(" WHERE (dummy_entity_with_embedded_id.one, dummy_entity_with_embedded_id.two) IN (:ids)"); + }); + } + + @Test // GH-574 + void findOneWithReference() { + + SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedIdAndReference.class); + + String sql = sqlGenerator.getFindOne(); + + assertSoftly(softly -> { + + softly.assertThat(sql).startsWith("SELECT") // + .contains(" LEFT OUTER JOIN other_entity other ") // + .contains(" ON ") // + .contains( + " other.dummy_entity_with_embedded_id_and_reference_one = dummy_entity_with_embedded_id_and_reference.one ") // + .contains( + " other.dummy_entity_with_embedded_id_and_reference_two = dummy_entity_with_embedded_id_and_reference.two ") // + .contains(" WHERE ") // + .contains("dummy_entity_with_embedded_id_and_reference.one = :one") // + .contains("dummy_entity_with_embedded_id_and_reference.two = :two"); + }); + } + @Test // DATAJDBC-111 void insert() { final String sql = sqlGenerator.getInsert(emptySet()); @@ -175,21 +350,14 @@ void update() { } @Test // DATAJDBC-340 - @Disabled // this is just broken right now void deleteByPath() { + sqlGenerator = createSqlGenerator(DummyEntity2.class); + final String sql = sqlGenerator .createDeleteByPath(PersistentPropertyPathTestUtils.getPath("embedded.other", DummyEntity2.class, context)); - assertThat(sql).containsSequence("DELETE FROM other_entity", // - "WHERE", // - "embedded_with_reference IN (", // - "SELECT ", // - "id ", // - "FROM", // - "dummy_entity2", // - "WHERE", // - "embedded_with_reference = :rootId"); + assertThat(sql).isEqualTo("DELETE FROM other_entity WHERE other_entity.dummy_entity2 = :id"); } @Test // DATAJDBC-340 @@ -276,12 +444,18 @@ void joinForEmbeddedWithReference() { SqlGenerator.Join join = generateJoin("embedded.other", DummyEntity2.class); assertSoftly(softly -> { - - softly.assertThat(join.getJoinTable().getName()).isEqualTo(SqlIdentifier.unquoted("other_entity")); - softly.assertThat(join.getJoinColumn().getTable()).isEqualTo(join.getJoinTable()); - softly.assertThat(join.getJoinColumn().getName()).isEqualTo(SqlIdentifier.unquoted("dummy_entity2")); - softly.assertThat(join.getParentId().getName()).isEqualTo(SqlIdentifier.unquoted("id")); - softly.assertThat(join.getParentId().getTable().getName()).isEqualTo(SqlIdentifier.unquoted("dummy_entity2")); + softly.assertThat(join.joinTable().getName()).isEqualTo(SqlIdentifier.unquoted("other_entity")); + softly.assertThat(join.columns()).extracting( // + pair -> pair.getFirst().getTable(), // + pair -> pair.getFirst().getName(), // + pair -> pair.getSecond().getTable().getName(), // + pair -> pair.getSecond().getName() // + ).contains(tuple( // + join.joinTable(), // + SqlIdentifier.unquoted("dummy_entity2"), // + SqlIdentifier.unquoted("dummy_entity2"), // + SqlIdentifier.unquoted("id") // + )); }); } @@ -301,6 +475,7 @@ void columnForEmbeddedWithReferenceProperty() { SqlIdentifier.unquoted("prefix_other_value")); } + @Nullable private SqlGenerator.Join generateJoin(String path, Class type) { return createSqlGenerator(type) .getJoin(context.getAggregatePath(PersistentPropertyPathTestUtils.getPath(path, type, context))); @@ -315,6 +490,7 @@ private SqlIdentifier getAlias(Object maybeAliased) { return null; } + @Nullable private org.springframework.data.relational.core.sql.Column generatedColumn(String path, Class type) { return createSqlGenerator(type) @@ -332,15 +508,47 @@ static class DummyEntity { @Embedded(onEmpty = OnEmpty.USE_NULL) CascadedEmbedded embeddable; } + record WrappedId(Long id) { + } + + static class DummyEntityWithWrappedId { + + @Id + @Embedded(onEmpty = OnEmpty.USE_NULL) WrappedId wrappedId; + + String name; + } + + record EmbeddedId(Long one, String two) { + } + + static class DummyEntityWithEmbeddedId { + + @Id + @Embedded(onEmpty = OnEmpty.USE_NULL) EmbeddedId embeddedId; + + String name; + + } + + static class DummyEntityWithEmbeddedIdAndReference { + + @Id + @Embedded(onEmpty = OnEmpty.USE_NULL) EmbeddedId embeddedId; + + String name; + OtherEntity other; + } + @SuppressWarnings("unused") static class CascadedEmbedded { String test; - @Embedded(onEmpty = OnEmpty.USE_NULL, prefix = "prefix2_") Embeddable prefixedEmbeddable; - @Embedded(onEmpty = OnEmpty.USE_NULL) Embeddable embeddable; + @Embedded(onEmpty = OnEmpty.USE_NULL, prefix = "prefix2_") NoId prefixedEmbeddable; + @Embedded(onEmpty = OnEmpty.USE_NULL) NoId embeddable; } @SuppressWarnings("unused") - static class Embeddable { + static class NoId { Long attr1; String attr2; } @@ -362,8 +570,7 @@ static class OtherEntity { } @Table("a") - private - record WithEmbeddedAndAggregateReference(@Id long id, + private record WithEmbeddedAndAggregateReference(@Id long id, @Embedded.Nullable(prefix = "nested_") WithAggregateReference nested) { } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorFixedNamingStrategyUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorFixedNamingStrategyUnitTests.java index 502b310b52..5ecbdd9cc8 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorFixedNamingStrategyUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorFixedNamingStrategyUnitTests.java @@ -30,11 +30,12 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; /** - * Unit tests the {@link SqlGenerator} with a fixed {@link NamingStrategy} implementation containing a hard wired + * Unit tests the {@link SqlGenerator} with a fixed {@link NamingStrategy} implementation containing a hard-wired * schema, table, and property prefix. * * @author Greg Turnquist * @author Mark Paluch + * @author Jens Schauder */ class SqlGeneratorFixedNamingStrategyUnitTests { @@ -90,7 +91,7 @@ void findOneWithOverriddenFixedTableName() { + "FROM \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\" " + "LEFT OUTER JOIN \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\" \"ref\" ON \"ref\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\" = \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\".\"FIXEDCUSTOMPROPERTYPREFIX_ID\" L" + "EFT OUTER JOIN \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_SECONDLEVELREFERENCEDENTITY\" \"ref_further\" ON \"ref_further\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\" = \"ref\".\"FIXEDCUSTOMPROPERTYPREFIX_L1ID\" " - + "WHERE \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\".\"FIXEDCUSTOMPROPERTYPREFIX_ID\" = :id"); + + "WHERE \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\".\"FIXEDCUSTOMPROPERTYPREFIX_ID\" = :FixedCustomPropertyPrefix_id"); softAssertions.assertAll(); } @@ -121,7 +122,7 @@ void cascadingDeleteFirstLevel() { String sql = sqlGenerator.createDeleteByPath(getPath("ref")); assertThat(sql).isEqualTo("DELETE FROM \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\" " - + "WHERE \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\" = :rootId"); + + "WHERE \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\" = :FixedCustomPropertyPrefix_id"); } @Test // DATAJDBC-107 @@ -136,7 +137,7 @@ void cascadingDeleteAllSecondLevel() { + "WHERE \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_SECONDLEVELREFERENCEDENTITY\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\" IN " + "(SELECT \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\".\"FIXEDCUSTOMPROPERTYPREFIX_L1ID\" " + "FROM \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\" " - + "WHERE \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\" = :rootId)"); + + "WHERE \"FIXEDCUSTOMSCHEMA\".\"FIXEDCUSTOMTABLEPREFIX_REFERENCEDENTITY\".\"FIXEDCUSTOMTABLEPREFIX_DUMMYENTITY\" = :FixedCustomPropertyPrefix_id)"); } @Test // DATAJDBC-107 diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java index 62e95245d7..abced6804b 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java @@ -160,7 +160,7 @@ void cascadingDeleteFirstLevel() { String sql = sqlGenerator.createDeleteByPath(getPath("ref", DummyEntity.class)); - assertThat(sql).isEqualTo("DELETE FROM referenced_entity WHERE referenced_entity.dummy_entity = :rootId"); + assertThat(sql).isEqualTo("DELETE FROM referenced_entity WHERE referenced_entity.dummy_entity = :id1"); } @Test // GH-537 @@ -177,7 +177,7 @@ void cascadingDeleteByPathSecondLevel() { String sql = sqlGenerator.createDeleteByPath(getPath("ref.further", DummyEntity.class)); assertThat(sql).isEqualTo( - "DELETE FROM second_level_referenced_entity WHERE second_level_referenced_entity.referenced_entity IN (SELECT referenced_entity.x_l1id FROM referenced_entity WHERE referenced_entity.dummy_entity = :rootId)"); + "DELETE FROM second_level_referenced_entity WHERE second_level_referenced_entity.referenced_entity IN (SELECT referenced_entity.x_l1id FROM referenced_entity WHERE referenced_entity.dummy_entity = :id1)"); } @Test // GH-537 @@ -227,7 +227,7 @@ void deleteMapByPath() { String sql = sqlGenerator.createDeleteByPath(getPath("mappedElements", DummyEntity.class)); - assertThat(sql).isEqualTo("DELETE FROM element WHERE element.dummy_entity = :rootId"); + assertThat(sql).isEqualTo("DELETE FROM element WHERE element.dummy_entity = :id1"); } @Test // DATAJDBC-101 @@ -702,7 +702,7 @@ void readOnlyPropertyIncludedIntoQuery_when_generateFindOneSql() { + "entity_with_read_only_property.x_name AS x_name, " // + "entity_with_read_only_property.x_read_only_value AS x_read_only_value " // + "FROM entity_with_read_only_property " // - + "WHERE entity_with_read_only_property.x_id = :id" // + + "WHERE entity_with_read_only_property.x_id = :x_id" // ); } @@ -721,7 +721,7 @@ void deletingLongChain() { "WHERE chain2.chain3 IN (" + // "SELECT chain3.x_three " + // "FROM chain3 " + // - "WHERE chain3.chain4 = :rootId" + // + "WHERE chain3.chain4 = :x_four" + // ")))"); } @@ -730,7 +730,7 @@ void deletingLongChainNoId() { assertThat(createSqlGenerator(NoIdChain4.class) .createDeleteByPath(getPath("chain3.chain2.chain1.chain0", NoIdChain4.class))) // - .isEqualTo("DELETE FROM no_id_chain0 WHERE no_id_chain0.no_id_chain4 = :rootId"); + .isEqualTo("DELETE FROM no_id_chain0 WHERE no_id_chain0.no_id_chain4 = :x_four"); } @Test // DATAJDBC-359 @@ -746,7 +746,7 @@ void deletingLongChainNoIdWithBackreferenceNotReferencingTheRoot() { + "WHERE no_id_chain4.id_no_id_chain IN (" // + "SELECT id_no_id_chain.x_id " // + "FROM id_no_id_chain " // - + "WHERE id_no_id_chain.id_id_no_id_chain = :rootId" // + + "WHERE id_no_id_chain.id_id_no_id_chain = :x_id" // + "))"); } @@ -762,11 +762,18 @@ void joinForSimpleReference() { assertSoftly(softly -> { - softly.assertThat(join.getJoinTable().getName()).isEqualTo(SqlIdentifier.quoted("REFERENCED_ENTITY")); - softly.assertThat(join.getJoinColumn().getTable()).isEqualTo(join.getJoinTable()); - softly.assertThat(join.getJoinColumn().getName()).isEqualTo(SqlIdentifier.quoted("DUMMY_ENTITY")); - softly.assertThat(join.getParentId().getName()).isEqualTo(SqlIdentifier.quoted("id1")); - softly.assertThat(join.getParentId().getTable().getName()).isEqualTo(SqlIdentifier.quoted("DUMMY_ENTITY")); + softly.assertThat(join.joinTable().getName()).isEqualTo(SqlIdentifier.quoted("REFERENCED_ENTITY")); + softly.assertThat(join.columns()).extracting( // + pair -> pair.getFirst().getTable(), // + pair -> pair.getFirst().getName(), // + pair -> pair.getSecond().getTable().getName(), // + pair -> pair.getSecond().getName() // + ).contains(tuple( // + join.joinTable(), // + SqlIdentifier.quoted("DUMMY_ENTITY"), // + SqlIdentifier.quoted("DUMMY_ENTITY"), // + SqlIdentifier.quoted("id1") // + )); }); } @@ -793,13 +800,18 @@ void joinForSecondLevelReference() { SqlGenerator.Join join = generateJoin("ref.further", DummyEntity.class); assertSoftly(softly -> { - - softly.assertThat(join.getJoinTable().getName()) - .isEqualTo(SqlIdentifier.quoted("SECOND_LEVEL_REFERENCED_ENTITY")); - softly.assertThat(join.getJoinColumn().getTable()).isEqualTo(join.getJoinTable()); - softly.assertThat(join.getJoinColumn().getName()).isEqualTo(SqlIdentifier.quoted("REFERENCED_ENTITY")); - softly.assertThat(join.getParentId().getName()).isEqualTo(SqlIdentifier.quoted("X_L1ID")); - softly.assertThat(join.getParentId().getTable().getName()).isEqualTo(SqlIdentifier.quoted("REFERENCED_ENTITY")); + softly.assertThat(join.joinTable().getName()).isEqualTo(SqlIdentifier.quoted("SECOND_LEVEL_REFERENCED_ENTITY")); + softly.assertThat(join.columns()).extracting( // + pair -> pair.getFirst().getTable(), // + pair -> pair.getFirst().getName(), // + pair -> pair.getSecond().getTable().getName(), // + pair -> pair.getSecond().getName() // + ).contains(tuple( // + join.joinTable(), // + SqlIdentifier.quoted("REFERENCED_ENTITY"), // + SqlIdentifier.quoted("REFERENCED_ENTITY"), // + SqlIdentifier.quoted("X_L1ID") // + )); }); } @@ -807,19 +819,25 @@ void joinForSecondLevelReference() { void joinForOneToOneWithoutId() { SqlGenerator.Join join = generateJoin("child", ParentOfNoIdChild.class); - Table joinTable = join.getJoinTable(); + Table joinTable = join.joinTable(); assertSoftly(softly -> { softly.assertThat(joinTable.getName()).isEqualTo(SqlIdentifier.quoted("NO_ID_CHILD")); softly.assertThat(joinTable).isInstanceOf(Aliased.class); softly.assertThat(((Aliased) joinTable).getAlias()).isEqualTo(SqlIdentifier.quoted("child")); - softly.assertThat(join.getJoinColumn().getTable()).isEqualTo(joinTable); - softly.assertThat(join.getJoinColumn().getName()).isEqualTo(SqlIdentifier.quoted("PARENT_OF_NO_ID_CHILD")); - softly.assertThat(join.getParentId().getName()).isEqualTo(SqlIdentifier.quoted("X_ID")); - softly.assertThat(join.getParentId().getTable().getName()) - .isEqualTo(SqlIdentifier.quoted("PARENT_OF_NO_ID_CHILD")); + softly.assertThat(join.columns()).extracting( // + pair -> pair.getFirst().getTable(), // + pair -> pair.getFirst().getName(), // + pair -> pair.getSecond().getTable().getName(), // + pair -> pair.getSecond().getName() // + ).contains(tuple( // + join.joinTable(), // + SqlIdentifier.quoted("PARENT_OF_NO_ID_CHILD"), // + SqlIdentifier.quoted("PARENT_OF_NO_ID_CHILD"), // + SqlIdentifier.quoted("X_ID") // + )); }); } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryTest.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryUnitTests.java similarity index 86% rename from spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryTest.java rename to spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryUnitTests.java index 9efdb3aeab..b7371c6a7f 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryTest.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryUnitTests.java @@ -26,6 +26,7 @@ import java.util.List; import java.util.Objects; +import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.Test; import org.springframework.core.convert.converter.Converter; import org.springframework.data.annotation.Id; @@ -34,6 +35,7 @@ import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; import org.springframework.data.relational.core.conversion.IdValueSource; import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Embedded; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.jdbc.core.JdbcOperations; @@ -43,7 +45,7 @@ * * @author Chirag Tailor */ -class SqlParametersFactoryTest { +class SqlParametersFactoryUnitTests { RelationalMappingContext context = new JdbcMappingContext(); RelationResolver relationResolver = mock(RelationResolver.class); @@ -51,20 +53,20 @@ class SqlParametersFactoryTest { SqlParametersFactory sqlParametersFactory = new SqlParametersFactory(context, converter); @Test // DATAJDBC-412 - public void considersConfiguredWriteConverterForIdValueObjects_onRead() { + void considersConfiguredWriteConverterForIdValueObjects_onRead() { SqlParametersFactory sqlParametersFactory = createSqlParametersFactoryWithConverters( singletonList(IdValueToStringConverter.INSTANCE)); String rawId = "batman"; SqlIdentifierParameterSource sqlParameterSource = sqlParametersFactory.forQueryById(new IdValue(rawId), - WithValueObjectId.class, SqlGenerator.ID_SQL_PARAMETER); + WithValueObjectId.class); assertThat(sqlParameterSource.getValue("id")).isEqualTo(rawId); } @Test // DATAJDBC-349 - public void considersConfiguredWriteConverterForIdValueObjectsWhichReferencedInOneToManyRelationship() { + void considersConfiguredWriteConverterForIdValueObjectsWhichReferencedInOneToManyRelationship() { SqlParametersFactory sqlParametersFactory = createSqlParametersFactoryWithConverters( singletonList(IdValueToStringConverter.INSTANCE)); @@ -85,8 +87,7 @@ public void considersConfiguredWriteConverterForIdValueObjectsWhichReferencedInO assertThat(sqlParameterSource.getValue("DUMMYENTITYROOT")).isEqualTo(rawId); } - @Test - // DATAJDBC-146 + @Test // DATAJDBC-146 void identifiersGetAddedAsParameters() { long id = 4711L; @@ -100,8 +101,7 @@ void identifiersGetAddedAsParameters() { assertThat(sqlParameterSource.getValue("reference")).isEqualTo(reference); } - @Test - // DATAJDBC-146 + @Test // DATAJDBC-146 void additionalIdentifierForIdDoesNotLeadToDuplicateParameters() { long id = 4711L; @@ -113,8 +113,7 @@ void additionalIdentifierForIdDoesNotLeadToDuplicateParameters() { assertThat(sqlParameterSource.getValue("id")).isEqualTo(id); } - @Test - // DATAJDBC-235 + @Test // DATAJDBC-235 void considersConfiguredWriteConverter() { SqlParametersFactory sqlParametersFactory = createSqlParametersFactoryWithConverters( @@ -128,8 +127,7 @@ void considersConfiguredWriteConverter() { assertThat(sqlParameterSource.getValue("flag")).isEqualTo("T"); } - @Test - // DATAJDBC-412 + @Test // DATAJDBC-412 void considersConfiguredWriteConverterForIdValueObjects_onWrite() { SqlParametersFactory sqlParametersFactory = createSqlParametersFactoryWithConverters( @@ -146,8 +144,7 @@ void considersConfiguredWriteConverterForIdValueObjects_onWrite() { assertThat(sqlParameterSource.getValue("value")).isEqualTo(value); } - @Test - // GH-1405 + @Test // GH-1405 void parameterNamesGetSanitized() { WithIllegalCharacters entity = new WithIllegalCharacters(23L, "aValue"); @@ -162,6 +159,22 @@ void parameterNamesGetSanitized() { assertThat(sqlParameterSource.getValue("val&ue")).isNull(); } + @Test // GH-574 + void parametersForInsertForEmbeddedWrappedId() { + + SingleEmbeddedIdEntity entity = new SingleEmbeddedIdEntity(new WrappedPk(23L), "alpha"); + + SqlIdentifierParameterSource parameterSource = sqlParametersFactory.forInsert(entity, SingleEmbeddedIdEntity.class, + Identifier.empty(), IdValueSource.PROVIDED); + + SoftAssertions.assertSoftly(softly -> { + + softly.assertThat(parameterSource.getParameterNames()).containsExactlyInAnyOrder("id", "name"); + softly.assertThat(parameterSource.getValue("id")).isEqualTo(23L); + softly.assertThat(parameterSource.getValue("name")).isEqualTo("alpha"); + }); + } + @WritingConverter enum IdValueToStringConverter implements Converter { @@ -299,6 +312,17 @@ private SqlParametersFactory createSqlParametersFactoryWithConverters(List co MappingJdbcConverter converter = new MappingJdbcConverter(context, relationResolver, new JdbcCustomConversions(converters), new DefaultJdbcTypeFactory(mock(JdbcOperations.class))); + context.setSimpleTypeHolder(converter.getConversions().getSimpleTypeHolder()); + return new SqlParametersFactory(context, converter); } + + private record WrappedPk(Long id) { + } + + private record SingleEmbeddedIdEntity( // + @Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) WrappedPk wrappedPk, // + String name // + ) { + } } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryEmbeddedWithCollectionIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryEmbeddedWithCollectionIntegrationTests.java index 4e566f054c..456d9fd0b0 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryEmbeddedWithCollectionIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryEmbeddedWithCollectionIntegrationTests.java @@ -18,7 +18,6 @@ import static java.util.Arrays.*; import static org.assertj.core.api.Assertions.*; -import java.sql.SQLException; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -68,7 +67,7 @@ DummyEntityRepository dummyEntityRepository(JdbcRepositoryFactory factory) { @Autowired Dialect dialect; @Test // DATAJDBC-111 - void savesAnEntity() throws SQLException { + void savesAnEntity() { DummyEntity entity = repository.save(createDummyEntity()); diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java index a941d1830c..ef297a193e 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java @@ -40,6 +40,7 @@ import org.springframework.data.relational.core.dialect.Escaper; import org.springframework.data.relational.core.dialect.H2Dialect; import org.springframework.data.relational.core.mapping.Embedded; +import org.springframework.data.relational.core.mapping.Embedded.Nullable; import org.springframework.data.relational.core.mapping.MappedCollection; import org.springframework.data.relational.core.mapping.Table; import org.springframework.data.relational.core.sql.LockMode; @@ -66,8 +67,8 @@ public class PartTreeJdbcQueryUnitTests { private static final String TABLE = "\"users\""; - private static final String ALL_FIELDS = "\"users\".\"ID\" AS \"ID\", \"users\".\"AGE\" AS \"AGE\", \"users\".\"ACTIVE\" AS \"ACTIVE\", \"users\".\"LAST_NAME\" AS \"LAST_NAME\", \"users\".\"FIRST_NAME\" AS \"FIRST_NAME\", \"users\".\"DATE_OF_BIRTH\" AS \"DATE_OF_BIRTH\", \"users\".\"HOBBY_REFERENCE\" AS \"HOBBY_REFERENCE\", \"hated\".\"NAME\" AS \"HATED_NAME\", \"users\".\"USER_CITY\" AS \"USER_CITY\", \"users\".\"USER_STREET\" AS \"USER_STREET\""; - private static final String JOIN_CLAUSE = "FROM \"users\" LEFT OUTER JOIN \"HOBBY\" \"hated\" ON \"hated\".\"USERS\" = \"users\".\"ID\""; + private static final String ALL_FIELDS = "\"users\".\"AGE\" AS \"AGE\", \"users\".\"ACTIVE\" AS \"ACTIVE\", \"users\".\"LAST_NAME\" AS \"LAST_NAME\", \"users\".\"FIRST_NAME\" AS \"FIRST_NAME\", \"users\".\"DATE_OF_BIRTH\" AS \"DATE_OF_BIRTH\", \"users\".\"HOBBY_REFERENCE\" AS \"HOBBY_REFERENCE\", \"users\".\"ID\" AS \"ID\", \"users\".\"SUB_ID\" AS \"SUB_ID\", \"hated\".\"NAME\" AS \"HATED_NAME\", \"users\".\"USER_CITY\" AS \"USER_CITY\", \"users\".\"USER_STREET\" AS \"USER_STREET\""; + private static final String JOIN_CLAUSE = "FROM \"users\" LEFT OUTER JOIN \"HOBBY\" \"hated\" ON \"hated\".\"USERS_ID\" = \"users\".\"ID\" AND \"hated\".\"USERS_SUB_ID\" = \"users\".\"SUB_ID\""; private static final String BASE_SELECT = "SELECT " + ALL_FIELDS + " " + JOIN_CLAUSE; JdbcMappingContext mappingContext = new JdbcMappingContext(); @@ -779,7 +780,8 @@ interface UserRepository extends Repository { @Table("users") static class User { - @Id Long id; + @Id + @Nullable UserId id; String firstName; String lastName; Date dateOfBirth; @@ -787,7 +789,7 @@ static class User { Boolean active; @Embedded(prefix = "user_", onEmpty = Embedded.OnEmpty.USE_NULL) Address address; - @Embedded.Nullable AnotherEmbedded anotherEmbedded; + @Nullable AnotherEmbedded anotherEmbedded; List hobbies; Hobby hated; @@ -795,6 +797,9 @@ static class User { AggregateReference hobbyReference; } + record UserId(Long id, String subId) { + } + record Address(String street, String city) { } diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/CompositeIdAggregateTemplateHsqlIntegrationTests-hsql.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/CompositeIdAggregateTemplateHsqlIntegrationTests-hsql.sql new file mode 100644 index 0000000000..604cbefb2a --- /dev/null +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/CompositeIdAggregateTemplateHsqlIntegrationTests-hsql.sql @@ -0,0 +1,46 @@ +CREATE TABLE SIMPLE_ENTITY +( + ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + NAME VARCHAR(100) +); + +CREATE TABLE WITH_LIST_AND_COMPOSITE_ID +( + ONE BIGINT, + TWO VARCHAR(100), + NAME VARCHAR(100), + PRIMARY KEY (ONE, TWO) +); +CREATE TABLE WITH_LIST +( + ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + NAME VARCHAR(100) +); + +CREATE TABLE CHILD +( + WITH_LIST_ID BIGINT REFERENCES WITH_LIST (ID), + WITH_LIST_KEY INT, + WITH_LIST_AND_COMPOSITE_ID_ONE BIGINT, + WITH_LIST_AND_COMPOSITE_ID_TWO VARCHAR(100), + WITH_LIST_AND_COMPOSITE_ID_KEY INT, + NAME VARCHAR(100), + SINGLE_REFERENCE_ONE BIGINT, + SINGLE_REFERENCE_TWO VARCHAR(100) +); + +CREATE TABLE SIMPLE_ENTITY_WITH_EMBEDDED_PK +( + ONE BIGINT, + TWO VARCHAR(100), + NAME VARCHAR(100), + PRIMARY KEY (ONE, TWO) +); + +CREATE TABLE SINGLE_REFERENCE +( + ONE BIGINT, + TWO VARCHAR(100), + NAME VARCHAR(100), + PRIMARY KEY (ONE, TWO) +); diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-hsql.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-hsql.sql index 824959d5d7..392e9cc101 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-hsql.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-hsql.sql @@ -1,328 +1,328 @@ CREATE TABLE LEGO_SET ( - "id1" BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, - NAME VARCHAR(30) + "id1" BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + NAME VARCHAR(30) ); CREATE TABLE MANUAL ( - "id2" BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, - LEGO_SET BIGINT, - "alternative" BIGINT, - CONTENT VARCHAR(2000) + "id2" BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + LEGO_SET BIGINT, + "alternative" BIGINT, + CONTENT VARCHAR(2000) ); ALTER TABLE MANUAL - ADD FOREIGN KEY (LEGO_SET) - REFERENCES LEGO_SET ("id1"); + ADD FOREIGN KEY (LEGO_SET) + REFERENCES LEGO_SET ("id1"); CREATE TABLE ONE_TO_ONE_PARENT ( - "id3" BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, - content VARCHAR(30) + "id3" BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + content VARCHAR(30) ); CREATE TABLE Child_No_Id ( - ONE_TO_ONE_PARENT INTEGER PRIMARY KEY, - content VARCHAR(30) + ONE_TO_ONE_PARENT INTEGER PRIMARY KEY, + content VARCHAR(30) ); CREATE TABLE SIMPLE_LIST_PARENT ( - ID BIGINT GENERATED BY DEFAULT AS IDENTITY ( START WITH 1 ) PRIMARY KEY, - NAME VARCHAR(100) + ID BIGINT GENERATED BY DEFAULT AS IDENTITY ( START WITH 1 ) PRIMARY KEY, + NAME VARCHAR(100) ); CREATE TABLE LIST_PARENT ( - "id4" BIGINT GENERATED BY DEFAULT AS IDENTITY ( START WITH 1 ) PRIMARY KEY, - NAME VARCHAR(100) + "id4" BIGINT GENERATED BY DEFAULT AS IDENTITY ( START WITH 1 ) PRIMARY KEY, + NAME VARCHAR(100) ); CREATE TABLE ELEMENT_NO_ID ( - CONTENT VARCHAR(100), - SIMPLE_LIST_PARENT_KEY BIGINT, - SIMPLE_LIST_PARENT BIGINT, - LIST_PARENT_KEY BIGINT, - LIST_PARENT BIGINT + CONTENT VARCHAR(100), + SIMPLE_LIST_PARENT_KEY BIGINT, + SIMPLE_LIST_PARENT BIGINT, + LIST_PARENT_KEY BIGINT, + LIST_PARENT BIGINT ); ALTER TABLE ELEMENT_NO_ID - ADD FOREIGN KEY (LIST_PARENT) - REFERENCES LIST_PARENT ("id4"); + ADD FOREIGN KEY (LIST_PARENT) + REFERENCES LIST_PARENT ("id4"); CREATE TABLE ARRAY_OWNER ( - ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, - DIGITS VARCHAR(20) ARRAY[10] NOT NULL, - MULTIDIMENSIONAL VARCHAR(20) ARRAY[10] NULL + ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + DIGITS VARCHAR(20) ARRAY[10] NOT NULL, + MULTIDIMENSIONAL VARCHAR(20) ARRAY[10] NULL ); CREATE TABLE BYTE_ARRAY_OWNER ( - ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, - BINARY_DATA VARBINARY(20) NOT NULL + ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + BINARY_DATA VARBINARY(20) NOT NULL ); CREATE TABLE DOUBLE_LIST_OWNER ( - ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, - DIGITS DOUBLE PRECISION ARRAY[10] + ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + DIGITS DOUBLE PRECISION ARRAY[10] ); CREATE TABLE FLOAT_LIST_OWNER ( - ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, - DIGITS FLOAT ARRAY[10] + ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + DIGITS FLOAT ARRAY[10] ); CREATE TABLE CHAIN4 ( - FOUR BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY, - FOUR_VALUE VARCHAR(20) + FOUR BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY, + FOUR_VALUE VARCHAR(20) ); CREATE TABLE CHAIN3 ( - THREE BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 30) PRIMARY KEY, - THREE_VALUE VARCHAR(20), - CHAIN4 BIGINT, - FOREIGN KEY (CHAIN4) REFERENCES CHAIN4 (FOUR) + THREE BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 30) PRIMARY KEY, + THREE_VALUE VARCHAR(20), + CHAIN4 BIGINT, + FOREIGN KEY (CHAIN4) REFERENCES CHAIN4 (FOUR) ); CREATE TABLE CHAIN2 ( - TWO BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 20) PRIMARY KEY, - TWO_VALUE VARCHAR(20), - CHAIN3 BIGINT, - FOREIGN KEY (CHAIN3) REFERENCES CHAIN3 (THREE) + TWO BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 20) PRIMARY KEY, + TWO_VALUE VARCHAR(20), + CHAIN3 BIGINT, + FOREIGN KEY (CHAIN3) REFERENCES CHAIN3 (THREE) ); CREATE TABLE CHAIN1 ( - ONE BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 10) PRIMARY KEY, - ONE_VALUE VARCHAR(20), - CHAIN2 BIGINT, - FOREIGN KEY (CHAIN2) REFERENCES CHAIN2 (TWO) + ONE BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 10) PRIMARY KEY, + ONE_VALUE VARCHAR(20), + CHAIN2 BIGINT, + FOREIGN KEY (CHAIN2) REFERENCES CHAIN2 (TWO) ); CREATE TABLE CHAIN0 ( - ZERO BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 0) PRIMARY KEY, - ZERO_VALUE VARCHAR(20), - CHAIN1 BIGINT, - FOREIGN KEY (CHAIN1) REFERENCES CHAIN1 (ONE) + ZERO BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 0) PRIMARY KEY, + ZERO_VALUE VARCHAR(20), + CHAIN1 BIGINT, + FOREIGN KEY (CHAIN1) REFERENCES CHAIN1 (ONE) ); CREATE TABLE NO_ID_CHAIN4 ( - FOUR BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY, - FOUR_VALUE VARCHAR(20) + FOUR BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY, + FOUR_VALUE VARCHAR(20) ); CREATE TABLE NO_ID_CHAIN3 ( - THREE_VALUE VARCHAR(20), - NO_ID_CHAIN4 BIGINT, - FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR) + THREE_VALUE VARCHAR(20), + NO_ID_CHAIN4 BIGINT, + FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR) ); CREATE TABLE NO_ID_CHAIN2 ( - TWO_VALUE VARCHAR(20), - NO_ID_CHAIN4 BIGINT, - FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR) + TWO_VALUE VARCHAR(20), + NO_ID_CHAIN4 BIGINT, + FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR) ); CREATE TABLE NO_ID_CHAIN1 ( - ONE_VALUE VARCHAR(20), - NO_ID_CHAIN4 BIGINT, - FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR) + ONE_VALUE VARCHAR(20), + NO_ID_CHAIN4 BIGINT, + FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR) ); CREATE TABLE NO_ID_CHAIN0 ( - ZERO_VALUE VARCHAR(20), - NO_ID_CHAIN4 BIGINT, - FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR) + ZERO_VALUE VARCHAR(20), + NO_ID_CHAIN4 BIGINT, + FOREIGN KEY (NO_ID_CHAIN4) REFERENCES NO_ID_CHAIN4 (FOUR) ); CREATE TABLE NO_ID_LIST_CHAIN4 ( - FOUR BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY, - FOUR_VALUE VARCHAR(20) + FOUR BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY, + FOUR_VALUE VARCHAR(20) ); CREATE TABLE NO_ID_LIST_CHAIN3 ( - THREE_VALUE VARCHAR(20), - NO_ID_LIST_CHAIN4 BIGINT, - NO_ID_LIST_CHAIN4_KEY BIGINT, - PRIMARY KEY (NO_ID_LIST_CHAIN4, - NO_ID_LIST_CHAIN4_KEY), - FOREIGN KEY (NO_ID_LIST_CHAIN4) REFERENCES NO_ID_LIST_CHAIN4 (FOUR) + THREE_VALUE VARCHAR(20), + NO_ID_LIST_CHAIN4 BIGINT, + NO_ID_LIST_CHAIN4_KEY BIGINT, + PRIMARY KEY (NO_ID_LIST_CHAIN4, + NO_ID_LIST_CHAIN4_KEY), + FOREIGN KEY (NO_ID_LIST_CHAIN4) REFERENCES NO_ID_LIST_CHAIN4 (FOUR) ); CREATE TABLE NO_ID_LIST_CHAIN2 ( - TWO_VALUE VARCHAR(20), - NO_ID_LIST_CHAIN4 BIGINT, - NO_ID_LIST_CHAIN4_KEY BIGINT, - NO_ID_LIST_CHAIN3_KEY BIGINT, - PRIMARY KEY (NO_ID_LIST_CHAIN4, - NO_ID_LIST_CHAIN4_KEY, - NO_ID_LIST_CHAIN3_KEY), - FOREIGN KEY ( - NO_ID_LIST_CHAIN4, - NO_ID_LIST_CHAIN4_KEY - ) REFERENCES NO_ID_LIST_CHAIN3 ( - NO_ID_LIST_CHAIN4, - NO_ID_LIST_CHAIN4_KEY - ) + TWO_VALUE VARCHAR(20), + NO_ID_LIST_CHAIN4 BIGINT, + NO_ID_LIST_CHAIN4_KEY BIGINT, + NO_ID_LIST_CHAIN3_KEY BIGINT, + PRIMARY KEY (NO_ID_LIST_CHAIN4, + NO_ID_LIST_CHAIN4_KEY, + NO_ID_LIST_CHAIN3_KEY), + FOREIGN KEY ( + NO_ID_LIST_CHAIN4, + NO_ID_LIST_CHAIN4_KEY + ) REFERENCES NO_ID_LIST_CHAIN3 ( + NO_ID_LIST_CHAIN4, + NO_ID_LIST_CHAIN4_KEY + ) ); CREATE TABLE NO_ID_LIST_CHAIN1 ( - ONE_VALUE VARCHAR(20), - NO_ID_LIST_CHAIN4 BIGINT, - NO_ID_LIST_CHAIN4_KEY BIGINT, - NO_ID_LIST_CHAIN3_KEY BIGINT, - NO_ID_LIST_CHAIN2_KEY BIGINT, - PRIMARY KEY (NO_ID_LIST_CHAIN4, - NO_ID_LIST_CHAIN4_KEY, - NO_ID_LIST_CHAIN3_KEY, - NO_ID_LIST_CHAIN2_KEY), - FOREIGN KEY ( - NO_ID_LIST_CHAIN4, - NO_ID_LIST_CHAIN4_KEY, - NO_ID_LIST_CHAIN3_KEY - ) REFERENCES NO_ID_LIST_CHAIN2 ( - NO_ID_LIST_CHAIN4, - NO_ID_LIST_CHAIN4_KEY, - NO_ID_LIST_CHAIN3_KEY - ) + ONE_VALUE VARCHAR(20), + NO_ID_LIST_CHAIN4 BIGINT, + NO_ID_LIST_CHAIN4_KEY BIGINT, + NO_ID_LIST_CHAIN3_KEY BIGINT, + NO_ID_LIST_CHAIN2_KEY BIGINT, + PRIMARY KEY (NO_ID_LIST_CHAIN4, + NO_ID_LIST_CHAIN4_KEY, + NO_ID_LIST_CHAIN3_KEY, + NO_ID_LIST_CHAIN2_KEY), + FOREIGN KEY ( + NO_ID_LIST_CHAIN4, + NO_ID_LIST_CHAIN4_KEY, + NO_ID_LIST_CHAIN3_KEY + ) REFERENCES NO_ID_LIST_CHAIN2 ( + NO_ID_LIST_CHAIN4, + NO_ID_LIST_CHAIN4_KEY, + NO_ID_LIST_CHAIN3_KEY + ) ); CREATE TABLE NO_ID_LIST_CHAIN0 ( - ZERO_VALUE VARCHAR(20), - NO_ID_LIST_CHAIN4 BIGINT, - NO_ID_LIST_CHAIN4_KEY BIGINT, - NO_ID_LIST_CHAIN3_KEY BIGINT, - NO_ID_LIST_CHAIN2_KEY BIGINT, - NO_ID_LIST_CHAIN1_KEY BIGINT, - PRIMARY KEY (NO_ID_LIST_CHAIN4, - NO_ID_LIST_CHAIN4_KEY, - NO_ID_LIST_CHAIN3_KEY, - NO_ID_LIST_CHAIN2_KEY, - NO_ID_LIST_CHAIN1_KEY), - FOREIGN KEY ( - NO_ID_LIST_CHAIN4, - NO_ID_LIST_CHAIN4_KEY, - NO_ID_LIST_CHAIN3_KEY, - NO_ID_LIST_CHAIN2_KEY - ) REFERENCES NO_ID_LIST_CHAIN1 ( - NO_ID_LIST_CHAIN4, - NO_ID_LIST_CHAIN4_KEY, - NO_ID_LIST_CHAIN3_KEY, - NO_ID_LIST_CHAIN2_KEY - ) + ZERO_VALUE VARCHAR(20), + NO_ID_LIST_CHAIN4 BIGINT, + NO_ID_LIST_CHAIN4_KEY BIGINT, + NO_ID_LIST_CHAIN3_KEY BIGINT, + NO_ID_LIST_CHAIN2_KEY BIGINT, + NO_ID_LIST_CHAIN1_KEY BIGINT, + PRIMARY KEY (NO_ID_LIST_CHAIN4, + NO_ID_LIST_CHAIN4_KEY, + NO_ID_LIST_CHAIN3_KEY, + NO_ID_LIST_CHAIN2_KEY, + NO_ID_LIST_CHAIN1_KEY), + FOREIGN KEY ( + NO_ID_LIST_CHAIN4, + NO_ID_LIST_CHAIN4_KEY, + NO_ID_LIST_CHAIN3_KEY, + NO_ID_LIST_CHAIN2_KEY + ) REFERENCES NO_ID_LIST_CHAIN1 ( + NO_ID_LIST_CHAIN4, + NO_ID_LIST_CHAIN4_KEY, + NO_ID_LIST_CHAIN3_KEY, + NO_ID_LIST_CHAIN2_KEY + ) ); - CREATE TABLE NO_ID_MAP_CHAIN4 ( - FOUR BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY, - FOUR_VALUE VARCHAR(20) + FOUR BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY, + FOUR_VALUE VARCHAR(20) ); CREATE TABLE NO_ID_MAP_CHAIN3 ( - THREE_VALUE VARCHAR(20), - NO_ID_MAP_CHAIN4 BIGINT, - NO_ID_MAP_CHAIN4_KEY VARCHAR(20), - PRIMARY KEY (NO_ID_MAP_CHAIN4, - NO_ID_MAP_CHAIN4_KEY), - FOREIGN KEY (NO_ID_MAP_CHAIN4) REFERENCES NO_ID_MAP_CHAIN4 (FOUR) + THREE_VALUE VARCHAR(20), + NO_ID_MAP_CHAIN4 BIGINT, + NO_ID_MAP_CHAIN4_KEY VARCHAR(20), + PRIMARY KEY (NO_ID_MAP_CHAIN4, + NO_ID_MAP_CHAIN4_KEY), + FOREIGN KEY (NO_ID_MAP_CHAIN4) REFERENCES NO_ID_MAP_CHAIN4 (FOUR) ); CREATE TABLE NO_ID_MAP_CHAIN2 ( - TWO_VALUE VARCHAR(20), - NO_ID_MAP_CHAIN4 BIGINT, - NO_ID_MAP_CHAIN4_KEY VARCHAR(20), - NO_ID_MAP_CHAIN3_KEY VARCHAR(20), - PRIMARY KEY (NO_ID_MAP_CHAIN4, - NO_ID_MAP_CHAIN4_KEY, - NO_ID_MAP_CHAIN3_KEY), - FOREIGN KEY ( - NO_ID_MAP_CHAIN4, - NO_ID_MAP_CHAIN4_KEY - ) REFERENCES NO_ID_MAP_CHAIN3 ( - NO_ID_MAP_CHAIN4, - NO_ID_MAP_CHAIN4_KEY - ) + TWO_VALUE VARCHAR(20), + NO_ID_MAP_CHAIN4 BIGINT, + NO_ID_MAP_CHAIN4_KEY VARCHAR(20), + NO_ID_MAP_CHAIN3_KEY VARCHAR(20), + PRIMARY KEY (NO_ID_MAP_CHAIN4, + NO_ID_MAP_CHAIN4_KEY, + NO_ID_MAP_CHAIN3_KEY), + FOREIGN KEY ( + NO_ID_MAP_CHAIN4, + NO_ID_MAP_CHAIN4_KEY + ) REFERENCES NO_ID_MAP_CHAIN3 ( + NO_ID_MAP_CHAIN4, + NO_ID_MAP_CHAIN4_KEY + ) ); CREATE TABLE NO_ID_MAP_CHAIN1 ( - ONE_VALUE VARCHAR(20), - NO_ID_MAP_CHAIN4 BIGINT, - NO_ID_MAP_CHAIN4_KEY VARCHAR(20), - NO_ID_MAP_CHAIN3_KEY VARCHAR(20), - NO_ID_MAP_CHAIN2_KEY VARCHAR(20), - PRIMARY KEY (NO_ID_MAP_CHAIN4, - NO_ID_MAP_CHAIN4_KEY, - NO_ID_MAP_CHAIN3_KEY, - NO_ID_MAP_CHAIN2_KEY), - FOREIGN KEY ( - NO_ID_MAP_CHAIN4, - NO_ID_MAP_CHAIN4_KEY, - NO_ID_MAP_CHAIN3_KEY - ) REFERENCES NO_ID_MAP_CHAIN2 ( - NO_ID_MAP_CHAIN4, - NO_ID_MAP_CHAIN4_KEY, - NO_ID_MAP_CHAIN3_KEY - ) + ONE_VALUE VARCHAR(20), + NO_ID_MAP_CHAIN4 BIGINT, + NO_ID_MAP_CHAIN4_KEY VARCHAR(20), + NO_ID_MAP_CHAIN3_KEY VARCHAR(20), + NO_ID_MAP_CHAIN2_KEY VARCHAR(20), + PRIMARY KEY (NO_ID_MAP_CHAIN4, + NO_ID_MAP_CHAIN4_KEY, + NO_ID_MAP_CHAIN3_KEY, + NO_ID_MAP_CHAIN2_KEY), + FOREIGN KEY ( + NO_ID_MAP_CHAIN4, + NO_ID_MAP_CHAIN4_KEY, + NO_ID_MAP_CHAIN3_KEY + ) REFERENCES NO_ID_MAP_CHAIN2 ( + NO_ID_MAP_CHAIN4, + NO_ID_MAP_CHAIN4_KEY, + NO_ID_MAP_CHAIN3_KEY + ) ); CREATE TABLE NO_ID_MAP_CHAIN0 ( - ZERO_VALUE VARCHAR(20), - NO_ID_MAP_CHAIN4 BIGINT, - NO_ID_MAP_CHAIN4_KEY VARCHAR(20), - NO_ID_MAP_CHAIN3_KEY VARCHAR(20), - NO_ID_MAP_CHAIN2_KEY VARCHAR(20), - NO_ID_MAP_CHAIN1_KEY VARCHAR(20), - PRIMARY KEY (NO_ID_MAP_CHAIN4, - NO_ID_MAP_CHAIN4_KEY, - NO_ID_MAP_CHAIN3_KEY, - NO_ID_MAP_CHAIN2_KEY, - NO_ID_MAP_CHAIN1_KEY), - FOREIGN KEY ( - NO_ID_MAP_CHAIN4, - NO_ID_MAP_CHAIN4_KEY, - NO_ID_MAP_CHAIN3_KEY, - NO_ID_MAP_CHAIN2_KEY - ) REFERENCES NO_ID_MAP_CHAIN1 ( - NO_ID_MAP_CHAIN4, - NO_ID_MAP_CHAIN4_KEY, - NO_ID_MAP_CHAIN3_KEY, - NO_ID_MAP_CHAIN2_KEY - ) -); - -CREATE TABLE WITH_READ_ONLY ( - ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY, - NAME VARCHAR(200), + ZERO_VALUE VARCHAR(20), + NO_ID_MAP_CHAIN4 BIGINT, + NO_ID_MAP_CHAIN4_KEY VARCHAR(20), + NO_ID_MAP_CHAIN3_KEY VARCHAR(20), + NO_ID_MAP_CHAIN2_KEY VARCHAR(20), + NO_ID_MAP_CHAIN1_KEY VARCHAR(20), + PRIMARY KEY (NO_ID_MAP_CHAIN4, + NO_ID_MAP_CHAIN4_KEY, + NO_ID_MAP_CHAIN3_KEY, + NO_ID_MAP_CHAIN2_KEY, + NO_ID_MAP_CHAIN1_KEY), + FOREIGN KEY ( + NO_ID_MAP_CHAIN4, + NO_ID_MAP_CHAIN4_KEY, + NO_ID_MAP_CHAIN3_KEY, + NO_ID_MAP_CHAIN2_KEY + ) REFERENCES NO_ID_MAP_CHAIN1 ( + NO_ID_MAP_CHAIN4, + NO_ID_MAP_CHAIN4_KEY, + NO_ID_MAP_CHAIN3_KEY, + NO_ID_MAP_CHAIN2_KEY + ) +); + +CREATE TABLE WITH_READ_ONLY +( + ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY, + NAME VARCHAR(200), READ_ONLY VARCHAR(200) DEFAULT 'from-db' ); CREATE TABLE VERSIONED_AGGREGATE ( - ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, - VERSION BIGINT + ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + VERSION BIGINT ); @@ -334,7 +334,7 @@ CREATE TABLE WITH_LOCAL_DATE_TIME CREATE TABLE WITH_INSERT_ONLY ( - ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, INSERT_ONLY VARCHAR(100) ); @@ -345,30 +345,30 @@ CREATE TABLE WITH_ID_ONLY CREATE TABLE MULTIPLE_COLLECTIONS ( - ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, NAME VARCHAR(100) ); CREATE TABLE SET_ELEMENT ( MULTIPLE_COLLECTIONS BIGINT, - NAME VARCHAR(100) + NAME VARCHAR(100) ); CREATE TABLE LIST_ELEMENT ( - MULTIPLE_COLLECTIONS BIGINT, + MULTIPLE_COLLECTIONS BIGINT, MULTIPLE_COLLECTIONS_KEY INT, - NAME VARCHAR(100) + NAME VARCHAR(100) ); CREATE TABLE MAP_ELEMENT ( - MULTIPLE_COLLECTIONS BIGINT, + MULTIPLE_COLLECTIONS BIGINT, MULTIPLE_COLLECTIONS_KEY VARCHAR(10), - ENUM_MAP_OWNER BIGINT, - ENUM_MAP_OWNER_KEY VARCHAR(10), - NAME VARCHAR(100) + ENUM_MAP_OWNER BIGINT, + ENUM_MAP_OWNER_KEY VARCHAR(10), + NAME VARCHAR(100) ); CREATE TABLE AUTHOR @@ -379,12 +379,12 @@ CREATE TABLE AUTHOR CREATE TABLE BOOK ( AUTHOR BIGINT, - NAME VARCHAR(100) + NAME VARCHAR(100) ); CREATE TABLE ENUM_MAP_OWNER ( - ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, NAME VARCHAR(100) ); @@ -397,7 +397,7 @@ CREATE TABLE WITH_ONE_TO_ONE CREATE TABLE REFERENCED ( "renamed" VARCHAR(100), - ID BIGINT + ID BIGINT ); CREATE TABLE FIRST @@ -416,11 +416,17 @@ CREATE TABLE SEC CREATE TABLE THIRD ( - SEC BIGINT NOT NULL, - NAME VARCHAR(20) NOT NULL, + SEC BIGINT NOT NULL, + NAME VARCHAR(20) NOT NULL, FOREIGN KEY (SEC) REFERENCES SEC (ID) ); +CREATE TABLE SINGLE_EMBEDDED_ID_ENTITY +( + ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + NAME VARCHAR(100) +); + CREATE TABLE BEFORE_CONVERT_CALLBACK_FOR_SAVE_BATCH ( ID VARCHAR PRIMARY KEY, diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java index 3508facbb8..e3b33e4547 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java @@ -31,12 +31,16 @@ import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Window; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.PropertyHandler; +import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.r2dbc.convert.R2dbcConverter; import org.springframework.data.r2dbc.core.R2dbcEntityOperations; import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; import org.springframework.data.r2dbc.core.ReactiveDataAccessStrategy; import org.springframework.data.r2dbc.core.ReactiveSelectOperation; import org.springframework.data.r2dbc.repository.R2dbcRepository; +import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.query.Criteria; import org.springframework.data.relational.core.query.Query; @@ -67,6 +71,7 @@ public class SimpleR2dbcRepository implements R2dbcRepository { private final R2dbcEntityOperations entityOperations; private final Lazy idProperty; private final RelationalExampleMapper exampleMapper; + private MappingContext, ? extends RelationalPersistentProperty> mappingContext; /** * Create a new {@link SimpleR2dbcRepository}. @@ -81,11 +86,11 @@ public SimpleR2dbcRepository(RelationalEntityInformation entity, R2dbcEnt this.entity = entity; this.entityOperations = entityOperations; - this.idProperty = Lazy.of(() -> converter // - .getMappingContext() // + this.mappingContext = converter.getMappingContext(); + this.idProperty = Lazy.of(() -> mappingContext // .getRequiredPersistentEntity(this.entity.getJavaType()) // .getRequiredIdProperty()); - this.exampleMapper = new RelationalExampleMapper(converter.getMappingContext()); + this.exampleMapper = new RelationalExampleMapper(mappingContext); } /** @@ -359,7 +364,28 @@ private RelationalPersistentProperty getIdProperty() { } private Query getIdQuery(Object id) { - return Query.query(Criteria.where(getIdProperty().getName()).is(id)); + + Criteria criteria; + + RelationalPersistentProperty idProperty = getIdProperty(); + if (idProperty.isEmbedded()) { + + Criteria[] criteriaHolder = new Criteria[] { Criteria.empty() }; + + RelationalPersistentEntity idEntity = mappingContext.getRequiredPersistentEntity(idProperty.getType()); + PersistentPropertyAccessor accessor = idEntity.getPropertyAccessor(id); + idEntity.doWithProperties(new PropertyHandler() { + @Override + public void doWithPersistentProperty(RelationalPersistentProperty persistentProperty) { + criteriaHolder[0] = criteriaHolder [0].and(persistentProperty.getName()).is(accessor.getProperty(persistentProperty)); + } + }); + criteria = criteriaHolder[0]; + } else { + criteria = Criteria.where(idProperty.getName()).is(id); + } + + return Query.query(criteria); } /** diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplateUnitTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplateUnitTests.java index 77707d4dda..8c11f18781 100644 --- a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplateUnitTests.java +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplateUnitTests.java @@ -299,8 +299,6 @@ void shouldSelectOneDoNotOverrideExistingLimit() { @Test // GH-220 void shouldUpdateByQuery() { - MockRowMetadata metadata = MockRowMetadata.builder() - .columnMetadata(MockColumnMetadata.builder().name("name").type(R2dbcType.VARCHAR).build()).build(); MockResult result = MockResult.builder().rowsUpdated(1).build(); recorder.addStubbing(s -> s.startsWith("UPDATE"), result); @@ -321,8 +319,6 @@ void shouldUpdateByQuery() { @Test // GH-220 void shouldDeleteByQuery() { - MockRowMetadata metadata = MockRowMetadata.builder() - .columnMetadata(MockColumnMetadata.builder().name("name").type(R2dbcType.VARCHAR).build()).build(); MockResult result = MockResult.builder().rowsUpdated(1).build(); recorder.addStubbing(s -> s.startsWith("DELETE"), result); diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/CompositeIdRepositoryIntegrationTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/CompositeIdRepositoryIntegrationTests.java new file mode 100644 index 0000000000..eeedcf1355 --- /dev/null +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/CompositeIdRepositoryIntegrationTests.java @@ -0,0 +1,122 @@ +/* + * Copyright 2019-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.r2dbc.repository; + +import io.r2dbc.spi.ConnectionFactory; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.dao.DataAccessException; +import org.springframework.data.annotation.Id; +import org.springframework.data.r2dbc.config.AbstractR2dbcConfiguration; +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; +import org.springframework.data.r2dbc.testing.H2TestSupport; +import org.springframework.data.relational.core.mapping.Embedded; +import org.springframework.data.relational.core.mapping.Table; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.*; + +/** + * Integration tests for repositories of entities with a composite id. + * + * @author Jens Schauder + */ +@ExtendWith(SpringExtension.class) +public class CompositeIdRepositoryIntegrationTests { + + @Autowired private WithCompositeIdRepository repository; + private JdbcTemplate jdbc; + + @Configuration + @EnableR2dbcRepositories(includeFilters = @ComponentScan.Filter(value = WithCompositeIdRepository.class, + type = FilterType.ASSIGNABLE_TYPE), considerNestedRepositories = true) + static class TestConfiguration extends AbstractR2dbcConfiguration { + @Override + public ConnectionFactory connectionFactory() { + return H2TestSupport.createConnectionFactory(); + } + + } + + @BeforeEach + void before() { + + this.jdbc = new JdbcTemplate(createDataSource()); + + try { + this.jdbc.execute("DROP TABLE with_composite_id"); + } catch (DataAccessException e) {} + + this.jdbc.execute(""" + CREATE TABLE with_composite_id ( + one int, + two varchar(255), + name varchar(255), + primary key (one, two))"""); + this.jdbc.execute("INSERT INTO with_composite_id VALUES (42, 'HBAR','Walter')"); + this.jdbc.execute("INSERT INTO with_composite_id VALUES (23, '2PI','Jesse')"); + } + + /** + * Creates a {@link DataSource} to be used in this test. + * + * @return the {@link DataSource} to be used in this test. + */ + protected DataSource createDataSource() { + return H2TestSupport.createDataSource(); + } + + /** + * Creates a {@link ConnectionFactory} to be used in this test. + * + * @return the {@link ConnectionFactory} to be used in this test. + */ + protected ConnectionFactory createConnectionFactory() { + return H2TestSupport.createConnectionFactory(); + } + + @Test // GH-574 + void findAllById() { + repository.findById(new CompositeId(42, "HBAR")) // + .as(StepVerifier::create) // + .consumeNextWith(actual ->{ + assertThat(actual.name).isEqualTo("Walter"); + }).verifyComplete(); + } + + interface WithCompositeIdRepository extends ReactiveCrudRepository { + + } + + @Table("with_composite_id") + record WithCompositeId(@Id @Embedded.Nullable CompositeId pk, String name) { + } + + record CompositeId(Integer one, String two) { + } + +} 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..f721e34eea 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,12 +16,21 @@ 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.TreeMap; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Function; 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.SqlIdentifier; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -34,7 +43,7 @@ * @author Jens Schauder * @author Mark Paluch */ -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 +61,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 3.5 + */ + AggregatePath append(AggregatePath path); + /** * @return {@literal true} if this is a root path for the underlying type. */ @@ -227,6 +245,9 @@ default Stream stream() { */ AggregatePath getIdDefiningParentPath(); + @Nullable + AggregatePath getTail(); + record TableInfo( /* @@ -240,7 +261,7 @@ record TableInfo( */ @Nullable SqlIdentifier tableAlias, - ColumnInfo reverseColumnInfo, + ColumnInfos reverseColumnInfos, /* * The column used for the list index or map key of the leaf property of this path. @@ -256,13 +277,7 @@ record TableInfo( /* * The column name of the id column of the ancestor path that represents an actual table. */ - SqlIdentifier idColumnName, - - /* - * 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) { + ColumnInfos idColumnInfos) { static TableInfo of(AggregatePath path) { @@ -273,18 +288,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 reverseColumnInfos = computeReverseColumnInfo(path); ColumnInfo qualifierColumnInfo = null; if (!path.isRoot()) { @@ -300,27 +304,114 @@ 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, reverseColumnInfos, 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 computeReverseColumnInfo(AggregatePath path) { + + AggregatePath tableOwner = AggregatePathTraversal.getTableOwningPath(path); + + if (tableOwner.isRoot()) { + return ColumnInfos.empty(tableOwner); + } + + AggregatePath idDefiningParentPath = tableOwner.getIdDefiningParentPath(); + RelationalPersistentProperty leafProperty = tableOwner.getRequiredLeafProperty(); + + RelationalPersistentProperty idProperty = idDefiningParentPath.getLeafEntity().getIdProperty(); + + if (idProperty != null) { + if (idProperty.isEntity()) { + + AggregatePath idBasePath = idDefiningParentPath.append(idProperty); + ColumInfosBuilder ciBuilder = new ColumInfosBuilder(idBasePath); + + RelationalPersistentEntity idEntity = idBasePath.getRequiredLeafEntity(); + idEntity.doWithProperties((PropertyHandler) p -> { + AggregatePath idElementPath = idBasePath.append(p); + SqlIdentifier name = idElementPath.getColumnInfo().name(); + name = name.transform(n -> idDefiningParentPath.getTableInfo().qualifiedTableName.getReference() + "_" + n); + + ciBuilder.add(idElementPath, name, name); + }); + + return ciBuilder.build(); + + } else { + + ColumInfosBuilder ciBuilder = new ColumInfosBuilder(idDefiningParentPath); + SqlIdentifier reverseColumnName = leafProperty + .getReverseColumnName(idDefiningParentPath.getRequiredLeafEntity()); + + ciBuilder.add(idProperty, reverseColumnName, + AggregatePathTableUtils.prefixWithTableAlias(path, reverseColumnName)); + + return ciBuilder.build(); + } + } else { - /* 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) { + ColumInfosBuilder ciBuilder = new ColumInfosBuilder(idDefiningParentPath); + SqlIdentifier reverseColumnName = leafProperty + .getReverseColumnName(idDefiningParentPath.getRequiredLeafEntity()); + + ciBuilder.add(idDefiningParentPath, reverseColumnName, + AggregatePathTableUtils.prefixWithTableAlias(path, reverseColumnName)); + + return ciBuilder.build(); + } + + } + + @Deprecated(forRemoval = true) + public ColumnInfo reverseColumnInfo() { + return reverseColumnInfos.unique(); + } + + public ColumnInfos effectiveIdColumnInfos() { + return reverseColumnInfos.columnInfos.isEmpty() ? idColumnInfos : reverseColumnInfos; + } + } + + /** + * @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. + */ + 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 +429,102 @@ 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}. This is relevant for + * composite ids and references to such ids. + **/ + class ColumnInfos { + + private final AggregatePath basePath; + private final Map columnInfos; + + /** + * @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} + */ + private ColumnInfos(AggregatePath basePath, Map columnInfos) { + + this.basePath = basePath; + this.columnInfos = columnInfos; + } + + public static ColumnInfos empty(AggregatePath base) { + return new ColumnInfos(base, new HashMap<>()); + } + + public ColumnInfo unique() { + + Collection values = columnInfos.values(); + Assert.state(values.size() == 1, "ColumnInfo is not unique"); + return values.iterator().next(); + } + + public ColumnInfo any() { + + Collection values = columnInfos.values(); + return values.iterator().next(); + } + + public boolean isEmpty() { + return columnInfos.isEmpty(); + } + + public List toList(Function mapper) { + return columnInfos.values().stream().map(mapper).toList(); + } + + public void forEach(BiConsumer consumer) { + columnInfos.forEach(consumer); + } + + public T any(BiFunction consumer) { + + Map.Entry any = columnInfos.entrySet().iterator().next(); + return consumer.apply(any.getKey(), any.getValue()); + } + + public ColumnInfo get(AggregatePath path) { + return columnInfos.get(path); + } + + public AggregatePath fullPath(AggregatePath ap) { + return basePath.append(ap); + } + + public int size() { + return columnInfos.size(); + } + } + + class ColumInfosBuilder { + private final AggregatePath basePath; + + private final Map columnInfoMap = new TreeMap<>(); + + public ColumInfosBuilder(AggregatePath basePath) { + this.basePath = basePath; + } + + void add(AggregatePath path, SqlIdentifier name, SqlIdentifier alias) { + add(path, new ColumnInfo(name, alias)); + } + + public void add(RelationalPersistentProperty property, SqlIdentifier name, SqlIdentifier alias) { + add(basePath.append(property), name, alias); + } + + ColumnInfos build() { + return new ColumnInfos(basePath, columnInfoMap); + } + + public void add(AggregatePath path, ColumnInfo columnInfo) { + columnInfoMap.put(path.substract(basePath), columnInfo); + } + } + + @Nullable + AggregatePath substract(@Nullable AggregatePath basePath); + } 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/DefaultAggregatePath.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/DefaultAggregatePath.java index b0bcc78cb2..e0e1073da5 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 @@ -21,6 +21,7 @@ import org.springframework.data.mapping.PersistentPropertyPath; import org.springframework.data.util.Lazy; +import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ConcurrentLruCache; @@ -97,6 +98,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 newPath = isRoot() // @@ -194,6 +209,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 substract(@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.substract(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 +296,6 @@ public int hashCode() { return Objects.hash(context, rootType, path); } - @Override public String toString() { return "AggregatePath[" @@ -248,6 +303,11 @@ public String toString() { + ((isRoot()) ? "/" : path.toDotPath()); } + @Override + public int compareTo(@NonNull AggregatePath other) { + return toDotPath().compareTo(other.toDotPath()); + } + private static class AggregatePathIterator implements Iterator { private @Nullable AggregatePath current; 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 78bfa01d37..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"); } 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 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..210ab1f7ec 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,9 @@ default SqlIdentifier getQualifiedTableName() { * Returns the column representing the identifier. * * @return will never be {@literal null}. + * @deprecated 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..ecc46d3ede 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 3.5 + */ + public AnalyticFunction partitionBy(Collection 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 3.5 + */ + public AnalyticFunction orderBy(Collection 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/TupleExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/TupleExpression.java new file mode 100644 index 0000000000..e1699197ea --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/TupleExpression.java @@ -0,0 +1,54 @@ +package org.springframework.data.relational.core.sql; + +import org.jetbrains.annotations.NotNull; + +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 3.5 + */ +public class TupleExpression extends AbstractSegment implements Expression { + + private final List expressions; + + private static Segment[] children(List expressions) { + return expressions.toArray(new Segment[0]); + } + + private TupleExpression(List expressions) { + + super(children(expressions)); + + this.expressions = expressions; + } + + public static TupleExpression create(Expression... expressions) { + return new TupleExpression(List.of(expressions)); + } + + public static TupleExpression create(List expressions) { + return new TupleExpression(expressions); + } + + public static Expression maybeWrap(List columns) { + + if (columns.size() == 1) { + return columns.get(0); + } + return new TupleExpression(columns); + } + + @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..fef8d8f688 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/TupleVisitor.java @@ -0,0 +1,72 @@ +/* + * Copyright 2019-2024 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 3.5 + */ +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..183050166f 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,13 @@ 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().reverseColumnInfos().toList( // + ci -> table.column(ci.name()) // + ) // + ).as(rowCountAlias); columns.add(count); String backReferenceAlias = null; @@ -178,7 +182,7 @@ 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().reverseColumnInfos().unique().name()).as(backReferenceAlias)); keyAlias = aliases.getKeyAlias(basePath); Expression keyExpression = basePath.isQualified() @@ -238,9 +242,10 @@ private String getIdentifierProperty(List paths) { private static AnalyticFunction createRowNumberExpression(AggregatePath basePath, Table table, String rowNumberAlias) { + AggregatePath.ColumnInfos reverseColumnInfos = basePath.getTableInfo().reverseColumnInfos(); return AnalyticFunction.create("row_number") // - .partitionBy(table.column(basePath.getTableInfo().reverseColumnInfo().name())) // - .orderBy(table.column(basePath.getTableInfo().reverseColumnInfo().name())) // + .partitionBy(reverseColumnInfos.toList(ci -> table.column(ci.name()))) // + .orderBy(reverseColumnInfos.toList(ci -> table.column(ci.name()))) // .as(rowNumberAlias); } 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..54f0bf0482 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/ColumnInfosUnitTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2024 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; + +/** + * Unit tests for the construction of {@link org.springframework.data.relational.core.mapping.AggregatePath.ColumnInfos} + * + * @author Jens Schauder + */ +class ColumnInfosUnitTests { + + 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.toList(ci -> { + throw new IllegalStateException("This should never get called"); + })).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.toList(ci -> ci.name())).containsExactly(ID); + } + + @Test // GH-574 + void multiElementColumnInfos() { + + AggregatePath.ColumnInfos columnInfos = basePath(DummyEntityWithCompositeId.class).getTableInfo().idColumnInfos(); + + assertThat(columnInfos.isEmpty()).isFalse(); + assertThat(columnInfos.any().name()).isEqualTo(SqlIdentifier.quoted("ONE")); + assertThrows(IllegalStateException.class, columnInfos::unique); + assertThat(columnInfos.toList(ci -> ci.name())) // + .containsExactly( // + SqlIdentifier.quoted("ONE"), // + 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 DummyEntityWithCompositeId(@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..1abdc4ddd1 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,6 +22,8 @@ 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; @@ -62,9 +64,9 @@ void getParentPath() { assertSoftly(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.assertThat((Object) path("second.third2.value").getParentPath()).isEqualTo(path("second.third2")); + softly.assertThat((Object) path("second.third2").getParentPath()).isEqualTo(path("second")); + softly.assertThat((Object) path("second").getParentPath()).isEqualTo(path()); softly.assertThatThrownBy(() -> path().getParentPath()).isInstanceOf(IllegalStateException.class); }); @@ -94,14 +96,15 @@ void idDefiningPath() { assertSoftly(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.assertThat((Object) path("second.third2.value").getIdDefiningParentPath()).isEqualTo(path()); + softly.assertThat((Object) path("second.third.value").getIdDefiningParentPath()).isEqualTo(path()); + softly.assertThat((Object) path("secondList.third2.value").getIdDefiningParentPath()).isEqualTo(path()); + softly.assertThat((Object) path("secondList.third.value").getIdDefiningParentPath()).isEqualTo(path()); + softly.assertThat((Object) path("second2.third2.value").getIdDefiningParentPath()).isEqualTo(path()); + softly.assertThat((Object) path("second2.third.value").getIdDefiningParentPath()).isEqualTo(path()); + softly.assertThat((Object) path("withId.second.third2.value").getIdDefiningParentPath()) + .isEqualTo(path("withId")); + softly.assertThat((Object) path("withId.second.third.value").getIdDefiningParentPath()).isEqualTo(path("withId")); }); } @@ -121,13 +124,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 +143,17 @@ void reverseColumnName() { }); } + @Test // GH-574 + void reverseColumnNames() { + + assertSoftly(softly -> { + softly.assertThat(path(CompoundIdEntity.class, "second").getTableInfo().reverseColumnInfos().toList(x -> x)) + .extracting(AggregatePath.ColumnInfo::name) + .containsExactlyInAnyOrder(quoted("COMPOUND_ID_ENTITY_ONE"), quoted("COMPOUND_ID_ENTITY_TWO")); + + }); + } + @Test // GH-1525 void getQualifierColumn() { @@ -172,8 +186,9 @@ void extendBy() { assertSoftly(softly -> { - softly.assertThat(path().append(entity.getRequiredPersistentProperty("withId"))).isEqualTo(path("withId")); - softly.assertThat(path("withId").append(path("withId").getRequiredIdProperty())) + softly.assertThat((Object) path().append(entity.getRequiredPersistentProperty("withId"))) + .isEqualTo(path("withId")); + softly.assertThat((Object) path("withId").append(path("withId").getRequiredIdProperty())) .isEqualTo(path("withId.withIdId")); }); } @@ -229,9 +244,9 @@ 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 (so the parent is - // multi-valued but not third2). + // embedded path into Second, held by + // List (so the parent is + // multi-valued but not third2). // TODO: This test fails because MultiValued considers parents. // softly.assertThat(path("secondList.third.value").isMultiValued()).isFalse(); softly.assertThat(path("secondList").isMultiValued()).isTrue(); @@ -306,13 +321,13 @@ void getTableAlias() { softly.assertThat(path("second.third2").getTableInfo().tableAlias()).isEqualTo(quoted("second")); softly.assertThat(path("second.third2.value").getTableInfo().tableAlias()).isEqualTo(quoted("second")); softly.assertThat(path("second.third").getTableInfo().tableAlias()).isEqualTo(quoted("second_third")); // missing - // _ + // _ softly.assertThat(path("second.third.value").getTableInfo().tableAlias()).isEqualTo(quoted("second_third")); // missing - // _ + // _ softly.assertThat(path("secondList.third2").getTableInfo().tableAlias()).isEqualTo(quoted("secondList")); softly.assertThat(path("secondList.third2.value").getTableInfo().tableAlias()).isEqualTo(quoted("secondList")); softly.assertThat(path("secondList.third").getTableInfo().tableAlias()).isEqualTo(quoted("secondList_third")); // missing - // _ + // _ softly.assertThat(path("secondList.third.value").getTableInfo().tableAlias()) .isEqualTo(quoted("secondList_third")); // missing _ softly.assertThat(path("secondList").getTableInfo().tableAlias()).isEqualTo(quoted("secondList")); @@ -416,20 +431,6 @@ void getBaseProperty() { }); } - @Test // GH-1525 - void getIdColumnName() { - - assertSoftly(softly -> { - - softly.assertThat(path().getTableInfo().idColumnName()).isEqualTo(quoted("ENTITY_ID")); - softly.assertThat(path("withId").getTableInfo().idColumnName()).isEqualTo(quoted("WITH_ID_ID")); - - softly.assertThat(path("second").getTableInfo().idColumnName()).isNull(); - softly.assertThat(path("second.third2").getTableInfo().idColumnName()).isNull(); - softly.assertThat(path("withId.second").getTableInfo().idColumnName()).isNull(); - }); - } - @Test // GH-1525 void toDotPath() { @@ -452,43 +453,89 @@ void getRequiredPersistentPropertyPath() { }); } - @Test // GH-1525 - void getEffectiveIdColumnName() { + @Test + // GH-1525 + void getLength() { assertSoftly(softly -> { + softly.assertThat(path().getLength()).isEqualTo(1); + softly.assertThat(path().stream().collect(Collectors.toList())).hasSize(1); - softly.assertThat(path().getTableInfo().effectiveIdColumnName()).isEqualTo(quoted("ENTITY_ID")); - softly.assertThat(path("second.third2").getTableInfo().effectiveIdColumnName()).isEqualTo(quoted("DUMMY_ENTITY")); - softly.assertThat(path("withId.second.third").getTableInfo().effectiveIdColumnName()) - .isEqualTo(quoted("WITH_ID")); - softly.assertThat(path("withId.second.third2.value").getTableInfo().effectiveIdColumnName()) - .isEqualTo(quoted("WITH_ID")); + softly.assertThat(path("second.third2").getLength()).isEqualTo(3); + softly.assertThat(path("second.third2").stream().collect(Collectors.toList())).hasSize(3); + + softly.assertThat(path("withId.second.third").getLength()).isEqualTo(4); + softly.assertThat(path("withId.second.third2.value").getLength()).isEqualTo(5); }); } - @Test // GH-1525 - void getLength() { + @Test // GH-574 + void getTail() { - assertThat(path().getLength()).isEqualTo(1); - assertThat(path().stream().collect(Collectors.toList())).hasSize(1); + assertSoftly(softly -> { - assertThat(path("second.third2").getLength()).isEqualTo(3); - assertThat(path("second.third2").stream().collect(Collectors.toList())).hasSize(3); + softly.assertThat((Object) path().getTail()).isEqualTo(null); + softly.assertThat((Object) path("second").getTail()).isEqualTo(null); + softly.assertThat(path("second.third").getTail().toDotPath()).isEqualTo("third"); + softly.assertThat(path("second.third.value").getTail().toDotPath()).isEqualTo("third.value"); + }); + } + + @Test // GH-74 + void append() { + + assertSoftly(softly -> { + + softly.assertThat(path("second").append(path()).toDotPath()).isEqualTo("second"); + softly.assertThat(path().append(path("second")).toDotPath()).isEqualTo("second"); + softly.assertThat(path().append(path("second.third")).toDotPath()).isEqualTo("second.third"); + AggregatePath value = path("second.third.value").getTail().getTail(); + softly.assertThat(path("second.third").append(value).toDotPath()).isEqualTo("second.third.value"); + }); + } + + @Test // GH-574 + void sortPaths() { + + Set sorted = new TreeSet<>(); + + AggregatePath alpha = path(); + AggregatePath as = path("second"); + AggregatePath ast = path("second.third"); + AggregatePath aw = path("withId"); + + sorted.add(aw); + sorted.add(ast); + sorted.add(as); + sorted.add(alpha); + + assertThat(sorted).containsExactly(alpha, as, ast, aw); - assertThat(path("withId.second.third").getLength()).isEqualTo(4); - assertThat(path("withId.second.third2.value").getLength()).isEqualTo(5); } private AggregatePath path() { return context.getAggregatePath(entity); } + private AggregatePath path(RelationalPersistentEntity entity) { + return context.getAggregatePath(entity); + } + + private AggregatePath path(Class entityType, String path) { + return context.getAggregatePath(createSimplePath(entityType, path)); + } + private AggregatePath path(String path) { return context.getAggregatePath(createSimplePath(path)); } PersistentPropertyPath createSimplePath(String path) { - return PersistentPropertyPathTestUtils.getPath(context, path, DummyEntity.class); + return createSimplePath(entity.getType(), path); + } + + PersistentPropertyPath createSimplePath(Class entityType, String path) { + + return PersistentPropertyPathTestUtils.getPath(context, path, entityType); } @SuppressWarnings("unused") @@ -502,6 +549,12 @@ static class DummyEntity { WithId withId; } + record CompoundId(Long one, String two) { + } + + record CompoundIdEntity(@Id CompoundId id, Second second) { + } + @SuppressWarnings("unused") static class Second { Third third; diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java index 4af641fb13..231c819cc7 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java @@ -60,7 +60,7 @@ public void canObtainAggregatePath() { EntityWithUuid.class); AggregatePath aggregatePath = context.getAggregatePath(path); - assertThat(aggregatePath).isNotNull(); + assertThat((Object) aggregatePath).isNotNull(); } @Test // GH-1525 @@ -75,7 +75,7 @@ public void innerAggregatePathsGetCached() { AggregatePath one = context.getAggregatePath(path); AggregatePath two = context.getAggregatePath(path); - assertThat(one).isSameAs(two); + assertThat((Object) one).isSameAs(two); } @Test // GH-1525 @@ -87,7 +87,7 @@ public void rootAggregatePathsGetCached() { AggregatePath one = context.getAggregatePath(context.getRequiredPersistentEntity(EntityWithUuid.class)); AggregatePath two = context.getAggregatePath(context.getRequiredPersistentEntity(EntityWithUuid.class)); - assertThat(one).isSameAs(two); + assertThat((Object) one).isSameAs(two); } @Test // GH-1586 @@ -117,7 +117,7 @@ void aggregatePathsOfBasePropertyForDifferentInheritedEntitiesAreDifferent() { AggregatePath aggregatePath1 = context.getAggregatePath(path1); AggregatePath aggregatePath2 = context.getAggregatePath(path2); - assertThat(aggregatePath1).isNotEqualTo(aggregatePath2); + assertThat((Object) aggregatePath1).isNotEqualTo(aggregatePath2); } static class EntityWithUuid { @@ -128,6 +128,14 @@ static class WithEmbedded { @Embedded.Empty(prefix = "prnt_") Parent parent; } + static class WithEmbeddedId { + @Embedded.Nullable + @Id CompositeId id; + } + + private record CompositeId(int a, int b) { + } + static class Parent { @Embedded.Empty(prefix = "chld_") Child child; @@ -144,5 +152,4 @@ static class Base { static class Inherit1 extends Base {} static class Inherit2 extends Base {} - } diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java new file mode 100644 index 0000000000..b69a9ee10e --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024 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 org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +class TupleExpressionUnitTests { + + @Test // GH-574 + void singleExpressionDoesNotGetWrapped() { + + Column testColumn = Column.create("name", Table.create("employee")); + + Expression wrapped = TupleExpression.maybeWrap(List.of(testColumn)); + + assertThat(wrapped).isSameAs(testColumn); + } + + @Test // GH-574 + void multipleExpressionsDoGetWrapped() { + + Column testColumn1 = Column.create("first", Table.create("employee")); + Column testColumn2 = Column.create("last", Table.create("employee")); + + Expression wrapped = TupleExpression.maybeWrap(List.of(testColumn1, testColumn2)); + + assertThat(wrapped).isInstanceOf(TupleExpression.class); + } + +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/DeleteRendererUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/DeleteRendererUnitTests.java index b451fea90b..09edd54b55 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/DeleteRendererUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/DeleteRendererUnitTests.java @@ -27,10 +27,10 @@ * * @author Mark Paluch */ -public class DeleteRendererUnitTests { +class DeleteRendererUnitTests { @Test // DATAJDBC-335 - public void shouldRenderWithoutWhere() { + void shouldRenderWithoutWhere() { Table bar = SQL.table("bar"); @@ -40,7 +40,7 @@ public void shouldRenderWithoutWhere() { } @Test // DATAJDBC-335 - public void shouldRenderWithCondition() { + void shouldRenderWithCondition() { Table table = Table.create("bar"); @@ -52,7 +52,7 @@ public void shouldRenderWithCondition() { } @Test // DATAJDBC-335 - public void shouldConsiderTableAlias() { + void shouldConsiderTableAlias() { Table table = Table.create("bar").as("my_bar"); diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java index 4f2121656e..788605a294 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java @@ -17,6 +17,8 @@ import static org.assertj.core.api.Assertions.*; +import java.util.List; + import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.data.relational.core.dialect.PostgresDialect; @@ -24,8 +26,6 @@ import org.springframework.data.relational.core.sql.*; import org.springframework.util.StringUtils; -import java.util.List; - /** * Unit tests for {@link SqlRenderer}. * @@ -115,196 +115,6 @@ void shouldRenderCountFunctionWithAliasedColumn() { assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT COUNT(bar.foo), bar.foo AS foo_bar FROM bar"); } - @Test // DATAJDBC-309 - void shouldRenderSimpleJoin() { - - Table employee = SQL.table("employee"); - Table department = SQL.table("department"); - - Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) // - .join(department).on(employee.column("department_id")).equals(department.column("id")) // - .build(); - - assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " - + "JOIN department ON employee.department_id = department.id"); - } - - @Test // DATAJDBC-340 - void shouldRenderOuterJoin() { - - Table employee = SQL.table("employee"); - Table department = SQL.table("department"); - - Select select = Select.builder().select(employee.column("id"), department.column("name")) // - .from(employee) // - .leftOuterJoin(department).on(employee.column("department_id")).equals(department.column("id")) // - .build(); - - assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " - + "LEFT OUTER JOIN department ON employee.department_id = department.id"); - } - - @Test // GH-1421 - void shouldRenderFullOuterJoin() { - - Table employee = SQL.table("employee"); - Table department = SQL.table("department"); - - Select select = Select.builder().select(employee.column("id"), department.column("name")) // - .from(employee) // - .join(department, Join.JoinType.FULL_OUTER_JOIN).on(employee.column("department_id")) - .equals(department.column("id")) // - .build(); - - assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " - + "FULL OUTER JOIN department ON employee.department_id = department.id"); - } - - @Test // DATAJDBC-309 - void shouldRenderSimpleJoinWithAnd() { - - Table employee = SQL.table("employee"); - Table department = SQL.table("department"); - - Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) // - .join(department).on(employee.column("department_id")).equals(department.column("id")) // - .and(employee.column("tenant")).equals(department.column("tenant")) // - .build(); - - assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " // - + "JOIN department ON employee.department_id = department.id " // - + "AND employee.tenant = department.tenant"); - } - - @Test // #995 - void shouldRenderArbitraryJoinCondition() { - - Table employee = SQL.table("employee"); - Table department = SQL.table("department"); - - Select select = Select.builder() // - .select(employee.column("id"), department.column("name")) // - .from(employee) // - .join(department) // - .on(Conditions.isEqual(employee.column("department_id"), department.column("id")) // - .or(Conditions.isNotEqual(employee.column("tenant"), department.column("tenant")) // - )).build(); - - assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " // - + "JOIN department ON employee.department_id = department.id " // - + "OR employee.tenant != department.tenant"); - } - - @Test // #1009 - void shouldRenderJoinWithJustExpression() { - - Table employee = SQL.table("employee"); - Table department = SQL.table("department"); - - Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) // - .join(department).on(Expressions.just("alpha")).equals(Expressions.just("beta")) // - .build(); - - assertThat(SqlRenderer.toString(select)) - .isEqualTo("SELECT employee.id, department.name FROM employee " + "JOIN department ON alpha = beta"); - } - - @Test // DATAJDBC-309 - void shouldRenderMultipleJoinWithAnd() { - - Table employee = SQL.table("employee"); - Table department = SQL.table("department"); - Table tenant = SQL.table("tenant").as("tenant_base"); - - Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) // - .join(department).on(employee.column("department_id")).equals(department.column("id")) // - .and(employee.column("tenant")).equals(department.column("tenant")) // - .join(tenant).on(tenant.column("tenant_id")).equals(department.column("tenant")) // - .build(); - - assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " // - + "JOIN department ON employee.department_id = department.id " // - + "AND employee.tenant = department.tenant " // - + "JOIN tenant tenant_base ON tenant_base.tenant_id = department.tenant"); - } - - @Test // GH-1003 - void shouldRenderJoinWithInlineQuery() { - - Table employee = SQL.table("employee"); - Table department = SQL.table("department"); - - Select innerSelect = Select.builder() - .select(employee.column("id"), employee.column("department_Id"), employee.column("name")).from(employee) - .build(); - - InlineQuery one = InlineQuery.create(innerSelect, "one"); - - Select select = Select.builder().select(one.column("id"), department.column("name")).from(department) // - .join(one).on(one.column("department_id")).equals(department.column("id")) // - .build(); - - String sql = SqlRenderer.toString(select); - - assertThat(sql).isEqualTo("SELECT one.id, department.name FROM department " // - + "JOIN (SELECT employee.id, employee.department_Id, employee.name FROM employee) one " // - + "ON one.department_id = department.id"); - } - - @Test // GH-1362 - void shouldRenderNestedJoins() { - - Table merchantCustomers = Table.create("merchants_customers"); - Table customerDetails = Table.create("customer_details"); - - Select innerSelect = Select.builder().select(customerDetails.column("cd_user_id")).from(customerDetails) - .join(merchantCustomers) - .on(merchantCustomers.column("mc_user_id").isEqualTo(customerDetails.column("cd_user_id"))).build(); - - InlineQuery innerTable = InlineQuery.create(innerSelect, "inner"); - - Select select = Select.builder().select(merchantCustomers.asterisk()) // - .from(merchantCustomers) // - .join(innerTable).on(innerTable.column("i_user_id").isEqualTo(merchantCustomers.column("mc_user_id"))) // - .build(); - - String sql = SqlRenderer.toString(select); - - assertThat(sql).isEqualTo("SELECT merchants_customers.* FROM merchants_customers " + // - "JOIN (" + // - "SELECT customer_details.cd_user_id " + // - "FROM customer_details " + // - "JOIN merchants_customers ON merchants_customers.mc_user_id = customer_details.cd_user_id" + // - ") inner " + // - "ON inner.i_user_id = merchants_customers.mc_user_id"); - } - - @Test // GH-1003 - void shouldRenderJoinWithTwoInlineQueries() { - - Table employee = SQL.table("employee"); - Table department = SQL.table("department"); - - Select innerSelectOne = Select.builder() - .select(employee.column("id").as("empId"), employee.column("department_Id"), employee.column("name")) - .from(employee).build(); - Select innerSelectTwo = Select.builder().select(department.column("id"), department.column("name")).from(department) - .build(); - - InlineQuery one = InlineQuery.create(innerSelectOne, "one"); - InlineQuery two = InlineQuery.create(innerSelectTwo, "two"); - - Select select = Select.builder().select(one.column("empId"), two.column("name")).from(one) // - .join(two).on(two.column("department_id")).equals(one.column("empId")) // - .build(); - - String sql = SqlRenderer.toString(select); - assertThat(sql).isEqualTo("SELECT one.empId, two.name FROM (" // - + "SELECT employee.id AS empId, employee.department_Id, employee.name FROM employee) one " // - + "JOIN (SELECT department.id, department.name FROM department) two " // - + "ON two.department_id = one.empId"); - } - @Test // DATAJDBC-309 void shouldRenderOrderByName() { @@ -424,7 +234,6 @@ void shouldRenderSimpleFunctionWithSubselect() { Table floo = SQL.table("floo"); Column bah = floo.column("bah"); - Select subselect = Select.builder().select(bah).from(floo).build(); SimpleFunction func = SimpleFunction.create("func", List.of(SubselectExpression.of(subselect))); @@ -435,8 +244,8 @@ void shouldRenderSimpleFunctionWithSubselect() { .where(Conditions.isEqual(func, SQL.literalOf(23))) // .build(); - assertThat(SqlRenderer.toString(select)) - .isEqualTo("SELECT func(SELECT floo.bah FROM floo) AS alias FROM foo WHERE func(SELECT floo.bah FROM floo) = 23"); + assertThat(SqlRenderer.toString(select)).isEqualTo( + "SELECT func(SELECT floo.bah FROM floo) AS alias FROM foo WHERE func(SELECT floo.bah FROM floo) = 23"); } @Test // DATAJDBC-309 @@ -709,7 +518,7 @@ void asteriskOfAliasedTableUsesAlias() { assertThat(rendered).isEqualTo("SELECT e.*, e.id FROM employee e"); } - @Test + @Test // GH-1844 void rendersCaseExpression() { Table table = SQL.table("table"); @@ -724,7 +533,225 @@ void rendersCaseExpression() { .build(); String rendered = SqlRenderer.toString(select); - assertThat(rendered).isEqualTo("SELECT CASE WHEN table.name IS NULL THEN 1 WHEN table.name IS NOT NULL THEN table.name ELSE 3 END FROM table"); + assertThat(rendered).isEqualTo( + "SELECT CASE WHEN table.name IS NULL THEN 1 WHEN table.name IS NOT NULL THEN table.name ELSE 3 END FROM table"); + } + + @Test // GH-574 + void rendersTupleExpression() { + + Table table = SQL.table("table"); + Column first = table.column("first"); + Column middle = table.column("middle"); + Column last = table.column("last").as("anAlias"); + + TupleExpression tupleExpression = TupleExpression.create(first, SQL.literalOf(1), middle, last); // + + Select select = StatementBuilder.select(first) // + .from(table) // + .where(Conditions.in(tupleExpression, Expressions.just("some expression"))).build(); + + String rendered = SqlRenderer.toString(select); + assertThat(rendered).isEqualTo( + "SELECT table.first FROM table WHERE (table.first, 1, table.middle, table.last) IN (some expression)"); + } + + /** + * Tests for rendering joins. + */ + @Nested + class JoinsTests { + + @Test // DATAJDBC-309 + void shouldRenderSimpleJoin() { + + Table employee = SQL.table("employee"); + Table department = SQL.table("department"); + + Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) // + .join(department).on(employee.column("department_id")).equals(department.column("id")) // + .build(); + + assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " + + "JOIN department ON employee.department_id = department.id"); + } + + @Test // DATAJDBC-340 + void shouldRenderOuterJoin() { + + Table employee = SQL.table("employee"); + Table department = SQL.table("department"); + + Select select = Select.builder().select(employee.column("id"), department.column("name")) // + .from(employee) // + .leftOuterJoin(department).on(employee.column("department_id")).equals(department.column("id")) // + .build(); + + assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " + + "LEFT OUTER JOIN department ON employee.department_id = department.id"); + } + + @Test // GH-1421 + void shouldRenderFullOuterJoin() { + + Table employee = SQL.table("employee"); + Table department = SQL.table("department"); + + Select select = Select.builder().select(employee.column("id"), department.column("name")) // + .from(employee) // + .join(department, Join.JoinType.FULL_OUTER_JOIN).on(employee.column("department_id")) + .equals(department.column("id")) // + .build(); + + assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " + + "FULL OUTER JOIN department ON employee.department_id = department.id"); + } + + @Test // DATAJDBC-309 + void shouldRenderSimpleJoinWithAnd() { + + Table employee = SQL.table("employee"); + Table department = SQL.table("department"); + + Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) // + .join(department).on(employee.column("department_id")).equals(department.column("id")) // + .and(employee.column("tenant")).equals(department.column("tenant")) // + .build(); + + assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " // + + "JOIN department ON employee.department_id = department.id " // + + "AND employee.tenant = department.tenant"); + } + + @Test // #995 + void shouldRenderArbitraryJoinCondition() { + + Table employee = SQL.table("employee"); + Table department = SQL.table("department"); + + Select select = Select.builder() // + .select(employee.column("id"), department.column("name")) // + .from(employee) // + .join(department) // + .on(Conditions.isEqual(employee.column("department_id"), department.column("id")) // + .or(Conditions.isNotEqual(employee.column("tenant"), department.column("tenant")) // + )).build(); + + assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " // + + "JOIN department ON employee.department_id = department.id " // + + "OR employee.tenant != department.tenant"); + } + + @Test // #1009 + void shouldRenderJoinWithJustExpression() { + + Table employee = SQL.table("employee"); + Table department = SQL.table("department"); + + Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) // + .join(department).on(Expressions.just("alpha")).equals(Expressions.just("beta")) // + .build(); + + assertThat(SqlRenderer.toString(select)) + .isEqualTo("SELECT employee.id, department.name FROM employee " + "JOIN department ON alpha = beta"); + } + + @Test // DATAJDBC-309 + void shouldRenderMultipleJoinWithAnd() { + + Table employee = SQL.table("employee"); + Table department = SQL.table("department"); + Table tenant = SQL.table("tenant").as("tenant_base"); + + Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) // + .join(department).on(employee.column("department_id")).equals(department.column("id")) // + .and(employee.column("tenant")).equals(department.column("tenant")) // + .join(tenant).on(tenant.column("tenant_id")).equals(department.column("tenant")) // + .build(); + + assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT employee.id, department.name FROM employee " // + + "JOIN department ON employee.department_id = department.id " // + + "AND employee.tenant = department.tenant " // + + "JOIN tenant tenant_base ON tenant_base.tenant_id = department.tenant"); + } + + @Test // GH-1003 + void shouldRenderJoinWithInlineQuery() { + + Table employee = SQL.table("employee"); + Table department = SQL.table("department"); + + Select innerSelect = Select.builder() + .select(employee.column("id"), employee.column("department_Id"), employee.column("name")).from(employee) + .build(); + + InlineQuery one = InlineQuery.create(innerSelect, "one"); + + Select select = Select.builder().select(one.column("id"), department.column("name")).from(department) // + .join(one).on(one.column("department_id")).equals(department.column("id")) // + .build(); + + String sql = SqlRenderer.toString(select); + + assertThat(sql).isEqualTo("SELECT one.id, department.name FROM department " // + + "JOIN (SELECT employee.id, employee.department_Id, employee.name FROM employee) one " // + + "ON one.department_id = department.id"); + } + + @Test // GH-1362 + void shouldRenderNestedJoins() { + + Table merchantCustomers = Table.create("merchants_customers"); + Table customerDetails = Table.create("customer_details"); + + Select innerSelect = Select.builder().select(customerDetails.column("cd_user_id")).from(customerDetails) + .join(merchantCustomers) + .on(merchantCustomers.column("mc_user_id").isEqualTo(customerDetails.column("cd_user_id"))).build(); + + InlineQuery innerTable = InlineQuery.create(innerSelect, "inner"); + + Select select = Select.builder().select(merchantCustomers.asterisk()) // + .from(merchantCustomers) // + .join(innerTable).on(innerTable.column("i_user_id").isEqualTo(merchantCustomers.column("mc_user_id"))) // + .build(); + + String sql = SqlRenderer.toString(select); + + assertThat(sql).isEqualTo("SELECT merchants_customers.* FROM merchants_customers " + // + "JOIN (" + // + "SELECT customer_details.cd_user_id " + // + "FROM customer_details " + // + "JOIN merchants_customers ON merchants_customers.mc_user_id = customer_details.cd_user_id" + // + ") inner " + // + "ON inner.i_user_id = merchants_customers.mc_user_id"); + } + + @Test // GH-1003 + void shouldRenderJoinWithTwoInlineQueries() { + + Table employee = SQL.table("employee"); + Table department = SQL.table("department"); + + Select innerSelectOne = Select.builder() + .select(employee.column("id").as("empId"), employee.column("department_Id"), employee.column("name")) + .from(employee).build(); + Select innerSelectTwo = Select.builder().select(department.column("id"), department.column("name")) + .from(department).build(); + + InlineQuery one = InlineQuery.create(innerSelectOne, "one"); + InlineQuery two = InlineQuery.create(innerSelectTwo, "two"); + + Select select = Select.builder().select(one.column("empId"), two.column("name")).from(one) // + .join(two).on(two.column("department_id")).equals(one.column("empId")) // + .build(); + + String sql = SqlRenderer.toString(select); + assertThat(sql).isEqualTo("SELECT one.empId, two.name FROM (" // + + "SELECT employee.id AS empId, employee.department_Id, employee.name FROM employee) one " // + + "JOIN (SELECT department.id, department.name FROM department) two " // + + "ON two.department_id = one.empId"); + } + } /** @@ -742,8 +769,8 @@ class AnalyticFunctionsTests { void renderEmptyOver() { Select select = StatementBuilder.select( // - AnalyticFunction.create("MAX", salary) // - ) // + AnalyticFunction.create("MAX", salary) // + ) // .from(employee) // .build(); diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AliasFactoryUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AliasFactoryUnitTests.java index 7ec6678f8b..2ed989e332 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AliasFactoryUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AliasFactoryUnitTests.java @@ -20,11 +20,13 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.data.annotation.Id; import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.mapping.RelationalMappingContext; /** * Unit tests for the {@link AliasFactory}. + * * @author Jens Schauder */ class AliasFactoryUnitTests { @@ -55,8 +57,8 @@ void aliasSimpleProperty() { @Test // GH-1446 void nameGetsSanitized() { - String alias = aliasFactory.getColumnAlias( - context.getAggregatePath( context.getPersistentPropertyPath("evil", DummyEntity.class))); + String alias = aliasFactory + .getColumnAlias(context.getAggregatePath(context.getPersistentPropertyPath("evil", DummyEntity.class))); assertThat(alias).isEqualTo("c_ameannamecontains3illegal_characters_1"); } @@ -64,10 +66,10 @@ void nameGetsSanitized() { @Test // GH-1446 void aliasIsStable() { - String alias1 = aliasFactory.getColumnAlias( - context.getAggregatePath( context.getRequiredPersistentEntity(DummyEntity.class))); - String alias2 = aliasFactory.getColumnAlias( - context.getAggregatePath( context.getRequiredPersistentEntity(DummyEntity.class))); + String alias1 = aliasFactory + .getColumnAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class))); + String alias2 = aliasFactory + .getColumnAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class))); assertThat(alias1).isEqualTo(alias2); } @@ -79,10 +81,10 @@ class RnAlias { @Test // GH-1446 void aliasIsStable() { - String alias1 = aliasFactory.getRowNumberAlias( - context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class))); - String alias2 = aliasFactory.getRowNumberAlias( - context.getAggregatePath( context.getRequiredPersistentEntity(DummyEntity.class))); + String alias1 = aliasFactory + .getRowNumberAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class))); + String alias2 = aliasFactory + .getRowNumberAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class))); assertThat(alias1).isEqualTo(alias2); } @@ -90,11 +92,11 @@ void aliasIsStable() { @Test // GH-1446 void aliasProjectsOnTableReferencingPath() { - String alias1 = aliasFactory.getRowNumberAlias( - context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class))); + String alias1 = aliasFactory + .getRowNumberAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class))); - String alias2 = aliasFactory.getRowNumberAlias( - context.getAggregatePath(context.getPersistentPropertyPath("evil", DummyEntity.class))); + String alias2 = aliasFactory + .getRowNumberAlias(context.getAggregatePath(context.getPersistentPropertyPath("evil", DummyEntity.class))); assertThat(alias1).isEqualTo(alias2); } @@ -102,10 +104,10 @@ void aliasProjectsOnTableReferencingPath() { @Test // GH-1446 void rnAliasIsIndependentOfTableAlias() { - String alias1 = aliasFactory.getRowNumberAlias( - context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class))); - String alias2 = aliasFactory.getColumnAlias( - context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class))); + String alias1 = aliasFactory + .getRowNumberAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class))); + String alias2 = aliasFactory + .getColumnAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class))); assertThat(alias1).isNotEqualTo(alias2); } @@ -117,8 +119,8 @@ class BackReferenceAlias { @Test // GH-1446 void testBackReferenceAlias() { - String alias = aliasFactory.getBackReferenceAlias( - context.getAggregatePath(context.getPersistentPropertyPath("dummy", Reference.class))); + String alias = aliasFactory + .getBackReferenceAlias(context.getAggregatePath(context.getPersistentPropertyPath("dummy", Reference.class))); assertThat(alias).isEqualTo("br_dummy_entity_1"); } @@ -129,8 +131,8 @@ class KeyAlias { @Test // GH-1446 void testKeyAlias() { - String alias = aliasFactory.getKeyAlias( - context.getAggregatePath(context.getPersistentPropertyPath("dummy", Reference.class))); + String alias = aliasFactory + .getKeyAlias(context.getAggregatePath(context.getPersistentPropertyPath("dummy", Reference.class))); assertThat(alias).isEqualTo("key_dummy_entity_1"); } @@ -141,11 +143,11 @@ class TableAlias { @Test // GH-1448 void tableAliasIsDifferentForDifferentPathsToSameEntity() { - String alias = aliasFactory.getTableAlias( - context.getAggregatePath(context.getPersistentPropertyPath("dummy", Reference.class))); + String alias = aliasFactory + .getTableAlias(context.getAggregatePath(context.getPersistentPropertyPath("dummy", Reference.class))); - String alias2 = aliasFactory.getTableAlias( - context.getAggregatePath(context.getPersistentPropertyPath("dummy2", Reference.class))); + String alias2 = aliasFactory + .getTableAlias(context.getAggregatePath(context.getPersistentPropertyPath("dummy2", Reference.class))); assertThat(alias).isNotEqualTo(alias2); } @@ -158,6 +160,7 @@ static class DummyEntity { } static class Reference { + @Id Long id; DummyEntity dummy; DummyEntity dummy2; } diff --git a/src/main/antora/modules/ROOT/pages/jdbc/mapping.adoc b/src/main/antora/modules/ROOT/pages/jdbc/mapping.adoc index c3bba01ca0..c41b6cd42b 100644 --- a/src/main/antora/modules/ROOT/pages/jdbc/mapping.adoc +++ b/src/main/antora/modules/ROOT/pages/jdbc/mapping.adoc @@ -106,6 +106,9 @@ Also, the type of that aggregate is encoded in a type parameter. All references in an aggregate result in a foreign key relationship in the opposite direction in the database. By default, the name of the foreign key column is the table name of the referencing entity. +If the referenced id is an `@Embedded` id, the back reference consists of multiple columns, each named by a concatenation of + `_` + . +E.g. the back reference to a `Person` entity, with a composite id with the properties `firstName` and `lastName` will consist of the two columns `PERSON_FIRST_NAME` and `PERSON_LAST_NAME`. + Alternatively you may choose to have them named by the entity name of the referencing entity ignoring `@Table` annotations. You activate this behaviour by calling `setForeignKeyNaming(ForeignKeyNaming.IGNORE_RENAMING)` on the `RelationalMappingContext`. diff --git a/src/main/antora/modules/ROOT/partials/mapping.adoc b/src/main/antora/modules/ROOT/partials/mapping.adoc index 7e864516e2..ed80c37fab 100644 --- a/src/main/antora/modules/ROOT/partials/mapping.adoc +++ b/src/main/antora/modules/ROOT/partials/mapping.adoc @@ -149,6 +149,13 @@ Embedded entities containing a `Collection` or a `Map` will always be considered Such an entity will therefore never be `null` even when using @Embedded(onEmpty = USE_NULL). endif::[] +[[entity-persistence.embedded-ids]] +=== Embedded Ids + +Entities may be annotated with `@Id` and `@Embedded`, resulting in a composite id on the database side. +The full embedded entity is considered the id, and therefore the check for determining if an aggregate is considered a new aggregate requiring an insert or an existing one, asking for an update is based on that entity, not its elements. +Most use cases will require a custom `BeforeConvertCallback` to set the id for new aggregate. + [[entity-persistence.read-only-properties]] == Read Only Properties From 51baffd974a88236046be4a4c90f81b75c9e4c03 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 19 Mar 2025 15:47:33 +0100 Subject: [PATCH 3/8] Polishing. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce ColumnInfos.reduce(…) operation. --- .../data/jdbc/core/convert/Identifier.java | 20 +++++++++++ .../core/convert/JdbcIdentifierBuilder.java | 12 +++---- .../core/mapping/AggregatePath.java | 35 +++++++++++++++++-- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Identifier.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Identifier.java index 5f9284a54b..56f0c1a90d 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Identifier.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Identifier.java @@ -99,6 +99,25 @@ public static Identifier from(Map map) { return new Identifier(Collections.unmodifiableList(values)); } + /** + * Creates a new {@link Identifier} from the current instance and sets the value from {@link Identifier}. Existing key + * definitions for {@code name} are overwritten if they already exist. + * + * @param identifier the identifier to append. + * @return the {@link Identifier} containing all existing keys and the key part for {@code name}, {@code value}, and a + * {@link Class target type}. + * @since 3.5 + */ + public Identifier withPart(Identifier identifier) { + + Identifier result = this; + for (SingleIdentifierValue part : identifier.getParts()) { + result = result.withPart(part.getName(), part.getValue(), part.getTargetType()); + } + + return result; + } + /** * Creates a new {@link Identifier} from the current instance and sets the value for {@code key}. Existing key * definitions for {@code name} are overwritten if they already exist. @@ -188,6 +207,7 @@ public Object get(SqlIdentifier columnName) { return null; } + /** * A single value of an Identifier consisting of the column name, the value and the target type which is to be used to * store the element in the database. diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java index 41e6b2c488..ef0ff6f467 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java @@ -48,7 +48,7 @@ public static JdbcIdentifierBuilder empty() { public static JdbcIdentifierBuilder forBackReferences(JdbcConverter converter, AggregatePath path, Object value) { RelationalPersistentProperty idProperty = path.getIdDefiningParentPath().getRequiredIdProperty(); - AggregatePath.ColumnInfos reverseColumnInfos = path.getTableInfo().reverseColumnInfos(); + AggregatePath.ColumnInfos infos = path.getTableInfo().reverseColumnInfos(); // create property accessor RelationalMappingContext mappingContext = converter.getMappingContext(); @@ -62,16 +62,14 @@ public static JdbcIdentifierBuilder forBackReferences(JdbcConverter converter, A valueProvider = ap -> propertyPathAccessor.getProperty(ap.getRequiredPersistentPropertyPath()); } - final Identifier[] identifierHolder = new Identifier[] { Identifier.empty() }; - - reverseColumnInfos.forEach((ap, ci) -> { + Identifier identifierHolder = infos.reduce(Identifier.empty(), (ap, ci) -> { RelationalPersistentProperty property = ap.getRequiredLeafProperty(); - identifierHolder[0] = identifierHolder[0].withPart(ci.name(), valueProvider.apply(ap), + return Identifier.of(ci.name(), valueProvider.apply(ap), converter.getColumnType(property)); - }); + }, Identifier::withPart); - return new JdbcIdentifierBuilder(identifierHolder[0]); + return new JdbcIdentifierBuilder(identifierHolder); } /** 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 f721e34eea..4ea0e38b90 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 @@ -23,6 +23,7 @@ import java.util.TreeMap; import java.util.function.BiConsumer; import java.util.function.BiFunction; +import java.util.function.BinaryOperator; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Stream; @@ -475,14 +476,44 @@ public List toList(Function mapper) { return columnInfos.values().stream().map(mapper).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. + * @return result of the function. + * @param type of the result. + * @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; + } + public void forEach(BiConsumer consumer) { columnInfos.forEach(consumer); } - public T any(BiFunction consumer) { + public T any(BiFunction mapper) { Map.Entry any = columnInfos.entrySet().iterator().next(); - return consumer.apply(any.getKey(), any.getValue()); + return mapper.apply(any.getKey(), any.getValue()); } public ColumnInfo get(AggregatePath path) { From 60f4e57b333c2e3274f165a1c6e0ddc15b512749 Mon Sep 17 00:00:00 2001 From: Jens Schauder Date: Thu, 20 Mar 2025 15:26:55 +0100 Subject: [PATCH 4/8] Applied changes requested in review. The major ones are: * directly construct joins * remove multiple places of duplication * naming * documentation Original pull request #1957 See #574 --- .../JdbcAggregateChangeExecutionContext.java | 30 +- .../data/jdbc/core/convert/Identifier.java | 3 +- .../core/convert/JdbcIdentifierBuilder.java | 55 ++-- .../core/convert/MappingJdbcConverter.java | 46 ++- .../data/jdbc/core/convert/SqlContext.java | 3 +- .../data/jdbc/core/convert/SqlGenerator.java | 130 ++++---- .../jdbc/core/convert/SqlGeneratorSource.java | 2 +- .../core/convert/SqlParametersFactory.java | 62 ++-- .../query/JdbcDeleteQueryCreator.java | 29 +- .../repository/query/JdbcQueryCreator.java | 159 +++------ .../jdbc/repository/query/SqlContext.java | 2 +- ...AggregateTemplateHsqlIntegrationTests.java | 7 +- ...angeExecutorContextImmutableUnitTests.java | 27 +- ...gregateChangeExecutorContextUnitTests.java | 4 +- .../JdbcIdentifierBuilderUnitTests.java | 38 ++- .../SqlGeneratorEmbeddedUnitTests.java | 97 +++--- .../core/convert/SqlGeneratorUnitTests.java | 60 ++-- .../support/SimpleR2dbcRepository.java | 3 +- ...CompositeIdRepositoryIntegrationTests.java | 10 +- .../core/mapping/AggregatePath.java | 303 +++++++++++------- .../core/mapping/ColumInfosBuilder.java | 84 +++++ .../core/mapping/DefaultAggregatePath.java | 10 +- .../mapping/RelationalPersistentEntity.java | 3 +- .../relational/core/sql/AnalyticFunction.java | 8 +- .../data/relational/core/sql/Expressions.java | 24 +- .../relational/core/sql/TupleExpression.java | 32 +- .../core/sql/render/TupleVisitor.java | 4 +- .../SingleQuerySqlGenerator.java | 14 +- .../core/mapping/AggregatePathAssertions.java | 80 +++++ .../mapping/AggregatePathSoftAssertions.java | 41 +++ .../core/mapping/ColumnInfosUnitTests.java | 22 +- .../DefaultAggregatePathUnitTests.java | 89 +++-- .../core/sql/TupleExpressionUnitTests.java | 11 +- .../modules/ROOT/pages/jdbc/mapping.adoc | 3 +- .../ROOT/partials/mapping-annotations.adoc | 12 + .../antora/modules/ROOT/partials/mapping.adoc | 38 ++- 36 files changed, 926 insertions(+), 619 deletions(-) create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/ColumInfosBuilder.java create mode 100644 spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/AggregatePathAssertions.java create mode 100644 spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/AggregatePathSoftAssertions.java diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java index 2ec070ab76..57dc1ff487 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java @@ -17,6 +17,7 @@ import java.util.*; import java.util.function.BiConsumer; +import java.util.function.Function; import java.util.stream.Collectors; import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException; @@ -176,7 +177,8 @@ private Identifier getParentKeys(DbAction.WithDependingOn action, JdbcConvert Object id = getParentId(action); JdbcIdentifierBuilder identifier = JdbcIdentifierBuilder // - .forBackReferences(converter, context.getAggregatePath(action.getPropertyPath()), id); + .forBackReferences(converter, context.getAggregatePath(action.getPropertyPath()), + getValueProvider(id, context.getAggregatePath(action.getPropertyPath()), converter)); for (Map.Entry, Object> qualifier : action.getQualifiers() .entrySet()) { @@ -186,6 +188,22 @@ private Identifier getParentKeys(DbAction.WithDependingOn action, JdbcConvert return identifier.build(); } + static Function getValueProvider(Object idValue, AggregatePath path, JdbcConverter converter) { + + RelationalPersistentEntity entity = converter.getMappingContext() + .getPersistentEntity(path.getIdDefiningParentPath().getRequiredIdProperty().getType()); + + Function valueProvider = ap -> { + if (entity == null) { + return idValue; + } else { + PersistentPropertyPathAccessor propertyPathAccessor = entity.getPropertyPathAccessor(idValue); + return propertyPathAccessor.getProperty(ap.getRequiredPersistentPropertyPath()); + } + }; + return valueProvider; + } + private Object getParentId(DbAction.WithDependingOn action) { DbAction.WithEntity idOwningAction = getIdOwningAction(action, @@ -267,12 +285,10 @@ List populateIdsIfNecessary() { if (newEntity != action.getEntity()) { - cascadingValues.stage(insert.getDependingOn(), insert.getPropertyPath(), - qualifierValue, newEntity); + cascadingValues.stage(insert.getDependingOn(), insert.getPropertyPath(), qualifierValue, newEntity); } else if (insert.getPropertyPath().getLeafProperty().isCollectionLike()) { - cascadingValues.gather(insert.getDependingOn(), insert.getPropertyPath(), - qualifierValue, newEntity); + cascadingValues.gather(insert.getDependingOn(), insert.getPropertyPath(), qualifierValue, newEntity); } } } @@ -359,8 +375,8 @@ private void updateWithVersion(DbAction.UpdateRoot update) { */ private static class StagedValues { - static final List> aggregators = Arrays.asList(SetAggregator.INSTANCE, MapAggregator.INSTANCE, - ListAggregator.INSTANCE, SingleElementAggregator.INSTANCE); + static final List> aggregators = Arrays.asList(SetAggregator.INSTANCE, + MapAggregator.INSTANCE, ListAggregator.INSTANCE, SingleElementAggregator.INSTANCE); Map> values = new HashMap<>(); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Identifier.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Identifier.java index 56f0c1a90d..711ba330c8 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Identifier.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Identifier.java @@ -106,7 +106,7 @@ public static Identifier from(Map map) { * @param identifier the identifier to append. * @return the {@link Identifier} containing all existing keys and the key part for {@code name}, {@code value}, and a * {@link Class target type}. - * @since 3.5 + * @since 4.0 */ public Identifier withPart(Identifier identifier) { @@ -207,7 +207,6 @@ public Object get(SqlIdentifier columnName) { return null; } - /** * A single value of an Identifier consisting of the column name, the value and the target type which is to be used to * store the element in the database. diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java index ef0ff6f467..24213662ff 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilder.java @@ -17,10 +17,7 @@ import java.util.function.Function; -import org.springframework.data.mapping.PersistentPropertyPathAccessor; import org.springframework.data.relational.core.mapping.AggregatePath; -import org.springframework.data.relational.core.mapping.RelationalMappingContext; -import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.util.Assert; @@ -45,31 +42,42 @@ public static JdbcIdentifierBuilder empty() { /** * Creates ParentKeys with backreference for the given path and value of the parents id. */ - public static JdbcIdentifierBuilder forBackReferences(JdbcConverter converter, AggregatePath path, Object value) { + public static JdbcIdentifierBuilder forBackReferences(JdbcConverter converter, AggregatePath path, + Function valueProvider) { - RelationalPersistentProperty idProperty = path.getIdDefiningParentPath().getRequiredIdProperty(); - AggregatePath.ColumnInfos infos = path.getTableInfo().reverseColumnInfos(); + return new JdbcIdentifierBuilder(forBackReference(converter, path, Identifier.empty(), valueProvider)); + } - // create property accessor - RelationalMappingContext mappingContext = converter.getMappingContext(); - RelationalPersistentEntity persistentEntity = mappingContext.getPersistentEntity(idProperty.getType()); + /** + * @param converter used for determining the column types to be used for different properties. Must not be + * {@literal null}. + * @param path the path for which needs to back reference an id. Must not be {@literal null}. + * @param defaultIdentifier Identifier to be used as a default when no backreference can be constructed. Must not be + * {@literal null}. + * @param valueProvider provides values for the {@link Identifier} based on an {@link AggregatePath}. Must not be + * {@literal null}. + * @return Guaranteed not to be {@literal null}. + */ + public static Identifier forBackReference(JdbcConverter converter, AggregatePath path, Identifier defaultIdentifier, + Function valueProvider) { - Function valueProvider; - if (persistentEntity == null) { - valueProvider = ap -> value; - } else { - PersistentPropertyPathAccessor propertyPathAccessor = persistentEntity.getPropertyPathAccessor(value); - valueProvider = ap -> propertyPathAccessor.getProperty(ap.getRequiredPersistentPropertyPath()); - } + Identifier identifierToUse = defaultIdentifier; - Identifier identifierHolder = infos.reduce(Identifier.empty(), (ap, ci) -> { + AggregatePath idDefiningParentPath = path.getIdDefiningParentPath(); - RelationalPersistentProperty property = ap.getRequiredLeafProperty(); - return Identifier.of(ci.name(), valueProvider.apply(ap), - converter.getColumnType(property)); - }, Identifier::withPart); + // note that the idDefiningParentPath might not itself have an id property, but have a combination of back + // references and possibly keys, that form an id + if (idDefiningParentPath.hasIdProperty()) { + + AggregatePath.ColumnInfos infos = path.getTableInfo().backReferenceColumnInfos(); + identifierToUse = infos.reduce(Identifier.empty(), (ap, ci) -> { + + RelationalPersistentProperty property = ap.getRequiredLeafProperty(); + return Identifier.of(ci.name(), valueProvider.apply(ap), converter.getColumnType(property)); + }, Identifier::withPart); + } - return new JdbcIdentifierBuilder(identifierHolder); + return identifierToUse; } /** @@ -85,8 +93,7 @@ public JdbcIdentifierBuilder withQualifier(AggregatePath path, Object value) { Assert.notNull(value, "Value must not be null"); AggregatePath.TableInfo tableInfo = path.getTableInfo(); - identifier = identifier.withPart(tableInfo.qualifierColumnInfo().name(), value, - tableInfo.qualifierColumnType()); + identifier = identifier.withPart(tableInfo.qualifierColumnInfo().name(), value, tableInfo.qualifierColumnType()); return this; } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java index 80e1975de2..b1d74f1876 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java @@ -361,7 +361,8 @@ public T getPropertyValue(RelationalPersistentProperty property) { if (property.isCollectionLike() || property.isMap()) { - Identifier identifier = constructIdentifier(aggregatePath); + Identifier identifier = JdbcIdentifierBuilder.forBackReference(MappingJdbcConverter.this, aggregatePath, + this.identifier, getWrappedValueProvider(delegate::getValue, aggregatePath)); Iterable allByPath = relationResolver.findAllByPath(identifier, aggregatePath.getRequiredPersistentPropertyPath()); @@ -388,29 +389,6 @@ public T getPropertyValue(RelationalPersistentProperty property) { return (T) delegate.getValue(aggregatePath); } - private Identifier constructIdentifier(AggregatePath aggregatePath) { - - Identifier identifierToUse = this.identifier; - AggregatePath idDefiningParentPath = aggregatePath.getIdDefiningParentPath(); - - // note that the idDefiningParentPath might not itself have an id property, but have a combination of back - // references and possibly keys, that form an id - if (idDefiningParentPath.hasIdProperty()) { - - RelationalPersistentProperty idProperty = idDefiningParentPath.getRequiredIdProperty(); - AggregatePath idPath = idProperty.isEntity() ? idDefiningParentPath.append(idProperty) : idDefiningParentPath; - Identifier[] buildingIdentifier = new Identifier[] { Identifier.empty() }; - aggregatePath.getTableInfo().reverseColumnInfos().forEach((ap, ci) -> { - - Object value = delegate.getValue(idPath.append(ap)); - buildingIdentifier[0] = buildingIdentifier[0].withPart(ci.name(), value, - ap.getRequiredLeafProperty().getActualType()); - }); - identifierToUse = buildingIdentifier[0]; - } - return identifierToUse; - } - @Override public boolean hasValue(RelationalPersistentProperty property) { @@ -431,7 +409,7 @@ public boolean hasValue(RelationalPersistentProperty property) { return delegate.hasValue(toUse); } - return delegate.hasValue(aggregatePath.getTableInfo().reverseColumnInfos().any().alias()); + return delegate.hasValue(aggregatePath.getTableInfo().backReferenceColumnInfos().any().alias()); } return delegate.hasValue(aggregatePath); @@ -457,7 +435,7 @@ public boolean hasNonEmptyValue(RelationalPersistentProperty property) { return delegate.hasValue(toUse); } - return delegate.hasValue(aggregatePath.getTableInfo().reverseColumnInfos().any().alias()); + return delegate.hasValue(aggregatePath.getTableInfo().backReferenceColumnInfos().any().alias()); } return delegate.hasNonEmptyValue(aggregatePath); @@ -472,6 +450,22 @@ public RelationalPropertyValueProvider withContext(ConversionContext context) { } } + private static Function getWrappedValueProvider(Function valueProvider, + AggregatePath aggregatePath) { + + AggregatePath idDefiningParentPath = aggregatePath.getIdDefiningParentPath(); + + if (!idDefiningParentPath.hasIdProperty()) { + return ap -> { + throw new IllegalStateException("This should never happen"); + }; + } + + RelationalPersistentProperty idProperty = idDefiningParentPath.getRequiredIdProperty(); + AggregatePath idPath = idProperty.isEntity() ? idDefiningParentPath.append(idProperty) : idDefiningParentPath; + return ap -> valueProvider.apply(idPath.append(ap)); + } + /** * Marker object to indicate that the property value provider should resolve relations. * diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java index b1b4dfdc2d..586da2c22f 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java @@ -66,10 +66,11 @@ Column getColumn(AggregatePath path) { * * @param path must not be null. * @return a {@literal Column} that is part of the effective primary key for the given path. + * @since 4.0 */ Column getAnyReverseColumn(AggregatePath path) { - AggregatePath.ColumnInfo columnInfo = path.getTableInfo().reverseColumnInfos().any(); + AggregatePath.ColumnInfo columnInfo = path.getTableInfo().backReferenceColumnInfos().any(); return getTable(path).column(columnInfo.name()).as(columnInfo.alias()); } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java index ad5af788a7..8ca457deb1 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java @@ -17,6 +17,7 @@ import java.util.*; import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; import org.springframework.data.domain.Pageable; @@ -34,7 +35,6 @@ import org.springframework.data.relational.core.query.CriteriaDefinition; import org.springframework.data.relational.core.query.Query; import org.springframework.data.relational.core.sql.*; -import org.springframework.data.relational.core.sql.render.RenderContext; import org.springframework.data.relational.core.sql.render.SqlRenderer; import org.springframework.data.util.Lazy; import org.springframework.data.util.Pair; @@ -62,7 +62,7 @@ * @author Viktor Ardelean * @author Kurt Niemi */ -class SqlGenerator { +public class SqlGenerator { static final SqlIdentifier VERSION_SQL_PARAMETER = SqlIdentifier.unquoted("___oldOptimisticLockingVersion"); static final SqlIdentifier IDS_SQL_PARAMETER = SqlIdentifier.unquoted("ids"); @@ -96,10 +96,6 @@ class SqlGenerator { private final QueryMapper queryMapper; private final Dialect dialect; - private final Function, Condition> inCondition; - private final Function, Condition> equalityCondition; - private final Function, Condition> notNullCondition; - /** * Create a new {@link SqlGenerator} given {@link RelationalMappingContext} and {@link RelationalPersistentEntity}. * @@ -118,11 +114,19 @@ class SqlGenerator { this.columns = new Columns(entity, mappingContext, converter); this.queryMapper = new QueryMapper(converter); this.dialect = dialect; + } - inCondition = inCondition(); - equalityCondition = equalityCondition(); - notNullCondition = isNotNullCondition(); - + /** + * Create a basic select structure with all the necessary joins + * + * @param table the table to base the select on + * @param pathFilter a filter for excluding paths from the select. All paths for which the filter returns + * {@literal true} will be skipped when determining columns to select. + * @return A select structure suitable for constructing more specialized selects by adding conditions. + * @since 4.0 + */ + public SelectBuilder.SelectWhere createSelectBuilder(Table table, Predicate pathFilter) { + return createSelectBuilder(table, pathFilter, Collections.emptyList()); } /** @@ -199,7 +203,7 @@ private Condition getSubselectCondition(AggregatePath path, innerCondition = getSubselectCondition(parentPath, conditionFunction, selectFilterColumns); } - List idColumns = parentPathTableInfo.idColumnInfos().toList(ci -> subSelectTable.column(ci.name())); + List idColumns = parentPathTableInfo.idColumnInfos().toColumnList(subSelectTable); Select select = Select.builder() // .select(idColumns) // @@ -210,7 +214,7 @@ private Condition getSubselectCondition(AggregatePath path, } private Expression toExpression(Map columnsMap) { - return TupleExpression.maybeWrap(new ArrayList<>(columnsMap.values())); + return Expressions.of(new ArrayList<>(columnsMap.values())); } private BindMarker getBindMarker(SqlIdentifier columnName) { @@ -456,7 +460,7 @@ String createDeleteAllSql(@Nullable PersistentPropertyPath path) { - return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path), equalityCondition); + return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path), this::equalityCondition); } /** @@ -478,63 +482,55 @@ String createDeleteByPath(PersistentPropertyPath p * @return the statement as a {@link String}. Guaranteed to be not {@literal null}. */ String createDeleteInByPath(PersistentPropertyPath path) { - return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path), inCondition); + return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path), this::inCondition); } /** - * Constructs a function for constructing a where condition. The where condition will be of the form - * {@literal IN :bind-marker} + * Constructs a where condition. The where condition will be of the form {@literal IN :bind-marker} */ - private Function, Condition> inCondition() { - - return columnMap -> { + private Condition inCondition(Map columnMap) { - List columns = List.copyOf(columnMap.values()); + List columns = List.copyOf(columnMap.values()); - if (columns.size() == 1) { - return Conditions.in(columns.get(0), getBindMarker(IDS_SQL_PARAMETER)); - } - return Conditions.in(TupleExpression.create(columns), getBindMarker(IDS_SQL_PARAMETER)); - }; + if (columns.size() == 1) { + return Conditions.in(columns.get(0), getBindMarker(IDS_SQL_PARAMETER)); + } + return Conditions.in(TupleExpression.create(columns), getBindMarker(IDS_SQL_PARAMETER)); } /** - * Constructs a function for constructing a where. The where condition will be of the form + * Constructs a where-condition. The where condition will be of the form * {@literal = :bind-marker-a AND = :bind-marker-b ...} */ - private Function, Condition> equalityCondition() { + private Condition equalityCondition(Map columnMap) { AggregatePath.ColumnInfos idColumnInfos = mappingContext.getAggregatePath(entity).getTableInfo().idColumnInfos(); - return columnMap -> { - - Condition result = null; - for (Map.Entry entry : columnMap.entrySet()) { - BindMarker bindMarker = getBindMarker(idColumnInfos.get(entry.getKey()).name()); - Comparison singleCondition = entry.getValue().isEqualTo(bindMarker); + Condition result = null; + for (Map.Entry entry : columnMap.entrySet()) { + BindMarker bindMarker = getBindMarker(idColumnInfos.get(entry.getKey()).name()); + Comparison singleCondition = entry.getValue().isEqualTo(bindMarker); - result = result == null ? singleCondition : result.and(singleCondition); - } - return result; - }; + result = result == null ? singleCondition : result.and(singleCondition); + } + Assert.state(result != null, "We need at least one condition"); + return result; } /** * Constructs a function for constructing where a condition. The where condition will be of the form * {@literal IS NOT NULL AND IS NOT NULL ... } */ - private Function, Condition> isNotNullCondition() { - - return columnMap -> { + private Condition isNotNullCondition(Map columnMap) { - Condition result = null; - for (Column column : columnMap.values()) { - Condition singleCondition = column.isNotNull(); + Condition result = null; + for (Column column : columnMap.values()) { + Condition singleCondition = column.isNotNull(); - result = result == null ? singleCondition : result.and(singleCondition); - } - return result; - }; + result = result == null ? singleCondition : result.and(singleCondition); + } + Assert.state(result != null, "We need at least one condition"); + return result; } private String createFindOneSql() { @@ -601,9 +597,13 @@ private SelectBuilder.SelectWhere selectBuilder(Collection keyCol private SelectBuilder.SelectWhere selectBuilder(Collection keyColumns, Query query) { - Table table = getTable(); + return createSelectBuilder(getTable(), ap -> false, keyColumns); + } + + private SelectBuilder.SelectWhere createSelectBuilder(Table table, Predicate pathFilter, + Collection keyColumns) { - Projection projection = getProjection(keyColumns, query, table); + Projection projection = getProjection(pathFilter, keyColumns, query, table); SelectBuilder.SelectJoin baseSelect = StatementBuilder.select(projection.columns()).from(table); return (SelectBuilder.SelectWhere) addJoins(baseSelect, joinTables); @@ -613,18 +613,12 @@ private static SelectBuilder.SelectJoin addJoins(SelectBuilder.SelectJoin baseSe for (Join join : projection.joins()) { - Condition condition = null; - for (Pair columnPair : join.columns) { - Comparison elementalCondition = columnPair.getFirst().isEqualTo(columnPair.getSecond()); - condition = condition == null ? elementalCondition : condition.and(elementalCondition); - } - - baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(Objects.requireNonNull(condition)); + baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(join.condition); } return baseSelect; } - private Projection getProjection(Collection keyColumns, Query query, Table table) { + private Projection getProjection(Predicate pathFilter, Collection keyColumns, Query query, Table table) { Set columns = new LinkedHashSet<>(); Set joins = new LinkedHashSet<>(); @@ -648,6 +642,10 @@ private Projection getProjection(Collection keyColumns, Query que AggregatePath aggregatePath = mappingContext.getAggregatePath(path); + if (pathFilter.test(aggregatePath)) { + continue; + } + includeColumnAndJoin(aggregatePath, joins, columns); } } @@ -762,21 +760,25 @@ Join getJoin(AggregatePath path) { } Table currentTable = sqlContext.getTable(path); - AggregatePath.ColumnInfos backRefColumnInfos = path.getTableInfo().reverseColumnInfos(); + AggregatePath.ColumnInfos backRefColumnInfos = path.getTableInfo().backReferenceColumnInfos(); AggregatePath idDefiningParentPath = path.getIdDefiningParentPath(); Table parentTable = sqlContext.getTable(idDefiningParentPath); AggregatePath.ColumnInfos idColumnInfos = idDefiningParentPath.getTableInfo().idColumnInfos(); - List> joinConditions = new ArrayList<>(); + final Condition[] joinCondition = { null }; backRefColumnInfos.forEach((ap, ci) -> { - joinConditions.add(Pair.of(currentTable.column(ci.name()), parentTable.column(idColumnInfos.get(ap).name()))); + + Condition elementalCondition = currentTable.column(ci.name()) + .isEqualTo(parentTable.column(idColumnInfos.get(ap).name())); + joinCondition[0] = joinCondition[0] == null ? elementalCondition : joinCondition[0].and(elementalCondition); }); return new Join( // currentTable, // - joinConditions // + joinCondition[0] // ); + } private String createFindAllInListSql() { @@ -914,7 +916,7 @@ private String createDeleteByPathAndCriteria(AggregatePath path, Delete delete; Map columns = new TreeMap<>(); - AggregatePath.ColumnInfos columnInfos = path.getTableInfo().reverseColumnInfos(); + AggregatePath.ColumnInfos columnInfos = path.getTableInfo().backReferenceColumnInfos(); columnInfos.forEach((ag, ci) -> columns.put(ag, table.column(ci.name()))); if (isFirstNonRoot(path)) { @@ -1225,7 +1227,7 @@ SelectBuilder.SelectOrdered applyCriteria(@Nullable CriteriaDefinition criteria, /** * Value object representing a {@code JOIN} association. */ - record Join(Table joinTable, List> columns) { + record Join(Table joinTable, Condition condition) { } /** diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGeneratorSource.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGeneratorSource.java index 0a217dce63..5f5d9de361 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGeneratorSource.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGeneratorSource.java @@ -56,7 +56,7 @@ public Dialect getDialect() { return dialect; } - SqlGenerator getSqlGenerator(Class domainType) { + public SqlGenerator getSqlGenerator(Class domainType) { return CACHE.computeIfAbsent(domainType, t -> new SqlGenerator(context, converter, context.getRequiredPersistentEntity(t), dialect)); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java index 4e9ee941ed..0fdf3d5be0 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java @@ -19,6 +19,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; import java.util.function.Predicate; import org.springframework.data.jdbc.core.mapping.JdbcValue; @@ -122,30 +124,18 @@ SqlIdentifierParameterSource forQueryById(Object id, Class domainType) { RelationalPersistentEntity entity = getRequiredPersistentEntity(domainType); RelationalPersistentProperty singleIdProperty = entity.getRequiredIdProperty(); - if (singleIdProperty.isEntity()) { + RelationalPersistentEntity complexId = context.getPersistentEntity(singleIdProperty); - RelationalPersistentEntity complexId = context.getPersistentEntity(singleIdProperty); - PersistentPropertyPathAccessor accessor = complexId.getPropertyPathAccessor(id); + Function valueExtractor = complexId == null ? ap -> id + : ap -> complexId.getPropertyPathAccessor(id).getProperty(ap.getRequiredPersistentPropertyPath()); - context.getAggregatePath(entity).getTableInfo().idColumnInfos().forEach((ap, ci) -> { - Object idValue = accessor.getProperty(ap.getRequiredPersistentPropertyPath()); - - addConvertedPropertyValue( // + context.getAggregatePath(entity).getTableInfo().idColumnInfos() // + .forEach((ap, ci) -> addConvertedPropertyValue( // parameterSource, // ap.getRequiredLeafProperty(), // - idValue, // + valueExtractor.apply(ap), // ci.name() // - ); - }); - } else { - - addConvertedPropertyValue( // - parameterSource, // - singleIdProperty, // - id, // - singleIdProperty.getColumnName() // - ); - } + )); return parameterSource; } @@ -161,32 +151,26 @@ SqlIdentifierParameterSource forQueryByIds(Iterable ids, Class domainT SqlIdentifierParameterSource parameterSource = new SqlIdentifierParameterSource(); - RelationalPersistentEntity entity = context.getPersistentEntity(domainType); + RelationalPersistentEntity entity = context.getRequiredPersistentEntity(domainType); RelationalPersistentProperty singleIdProperty = entity.getRequiredIdProperty(); + RelationalPersistentEntity complexId = context.getPersistentEntity(singleIdProperty); + AggregatePath.ColumnInfos idColumnInfos = context.getAggregatePath(entity).getTableInfo().idColumnInfos(); - if (singleIdProperty.isEntity()) { - - RelationalPersistentEntity complexId = context.getPersistentEntity(singleIdProperty); - - AggregatePath.ColumnInfos idColumnInfos = context.getAggregatePath(entity).getTableInfo().idColumnInfos(); + BiFunction valueExtractor = complexId == null ? (id, ap) -> id + : (id, ap) -> complexId.getPropertyPathAccessor(id).getProperty(ap.getRequiredPersistentPropertyPath()); - List parameterValues = new ArrayList<>(); - for (Object id : ids) { + List parameterValues = new ArrayList<>(); + for (Object id : ids) { - PersistentPropertyPathAccessor accessor = complexId.getPropertyPathAccessor(id); + List tupleList = new ArrayList<>(); + idColumnInfos.forEach((ap, ci) -> { + tupleList.add(valueExtractor.apply(id, ap)); + }); + parameterValues.add(tupleList.toArray(new Object[0])); + } - List tupleList = new ArrayList<>(); - idColumnInfos.forEach((ap, ci) -> { - tupleList.add(accessor.getProperty(ap.getRequiredPersistentPropertyPath())); - }); - parameterValues.add(tupleList.toArray(new Object[0])); - } + parameterSource.addValue(SqlGenerator.IDS_SQL_PARAMETER, parameterValues); - parameterSource.addValue(SqlGenerator.IDS_SQL_PARAMETER, parameterValues); - } else { - addConvertedPropertyValuesAsList(parameterSource, getRequiredPersistentEntity(domainType).getRequiredIdProperty(), - ids); - } return parameterSource; } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java index a02c681b66..f81cc62260 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java @@ -29,9 +29,17 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.query.Criteria; -import org.springframework.data.relational.core.sql.*; +import org.springframework.data.relational.core.sql.Column; +import org.springframework.data.relational.core.sql.Condition; +import org.springframework.data.relational.core.sql.Conditions; +import org.springframework.data.relational.core.sql.Delete; import org.springframework.data.relational.core.sql.DeleteBuilder.DeleteWhere; +import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.Expressions; +import org.springframework.data.relational.core.sql.Select; import org.springframework.data.relational.core.sql.SelectBuilder.SelectWhere; +import org.springframework.data.relational.core.sql.StatementBuilder; +import org.springframework.data.relational.core.sql.Table; import org.springframework.data.relational.core.sql.render.SqlRenderer; import org.springframework.data.relational.repository.query.RelationalEntityMetadata; import org.springframework.data.relational.repository.query.RelationalParameterAccessor; @@ -89,13 +97,10 @@ protected List complete(@Nullable Criteria criteria, Sort sor Table table = Table.create(entityMetadata.getTableName()); MapSqlParameterSource parameterSource = new MapSqlParameterSource(); - SqlContext sqlContext = new SqlContext(); - Condition condition = criteria == null ? null : queryMapper.getMappedObject(parameterSource, criteria, table, entity); - List idColumns = context.getAggregatePath(entity).getTableInfo().idColumnInfos() - .toList(ci -> table.column(ci.name())); + List idColumns = context.getAggregatePath(entity).getTableInfo().idColumnInfos().toColumnList(table); // create select criteria query for subselect SelectWhere selectBuilder = StatementBuilder.select(idColumns).from(table); @@ -128,26 +133,24 @@ private void deleteRelations(List deleteChain, RelationalPersistentEntit AggregatePath aggregatePath = context.getAggregatePath(path); - // prevent duplication on recursive call - if (path.getLength() > 1 && !aggregatePath.getParentPath().isEmbedded()) { + if (aggregatePath.isEmbedded()) { continue; } - if (aggregatePath.isEntity() && !aggregatePath.isEmbedded()) { + if (aggregatePath.isEntity()) { SqlContext sqlContext = new SqlContext(); - // MariaDB prior to 11.6 does not support aliases for delete statements + // MariaDB prior to 11.6 does not support aliases for delete statements Table table = sqlContext.getUnaliasedTable(aggregatePath); - List reverseColumns = aggregatePath.getTableInfo().reverseColumnInfos() - .toList(ci -> table.column(ci.name())); - Expression expression = TupleExpression.maybeWrap(reverseColumns); + List reverseColumns = aggregatePath.getTableInfo().backReferenceColumnInfos().toColumnList(table); + Expression expression = Expressions.of(reverseColumns); Condition inCondition = Conditions.in(expression, parentSelect); List parentIdColumns = aggregatePath.getIdDefiningParentPath().getTableInfo().idColumnInfos() - .toList(ci -> table.column(ci.name())); + .toColumnList(table); Select select = StatementBuilder.select( // parentIdColumns // diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java index 7d22cd2fdc..fa7202a4a8 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java @@ -15,14 +15,14 @@ */ package org.springframework.data.jdbc.repository.query; -import java.util.ArrayList; -import java.util.List; import java.util.Optional; +import java.util.function.Predicate; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.jdbc.core.convert.QueryMapper; +import org.springframework.data.jdbc.core.convert.SqlGeneratorSource; import org.springframework.data.mapping.PersistentPropertyPath; import org.springframework.data.relational.core.dialect.Dialect; import org.springframework.data.relational.core.dialect.RenderContextFactory; @@ -31,7 +31,12 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.query.Criteria; -import org.springframework.data.relational.core.sql.*; +import org.springframework.data.relational.core.sql.Column; +import org.springframework.data.relational.core.sql.Expressions; +import org.springframework.data.relational.core.sql.Functions; +import org.springframework.data.relational.core.sql.Select; +import org.springframework.data.relational.core.sql.SelectBuilder; +import org.springframework.data.relational.core.sql.Table; import org.springframework.data.relational.core.sql.render.SqlRenderer; import org.springframework.data.relational.repository.Lock; import org.springframework.data.relational.repository.query.RelationalEntityMetadata; @@ -65,6 +70,7 @@ class JdbcQueryCreator extends RelationalQueryCreator { private final boolean isSliceQuery; private final ReturnedType returnedType; private final Optional lockMode; + private final SqlGeneratorSource sqlGeneratorSource; /** * Creates new instance of this class with the given {@link PartTree}, {@link JdbcConverter}, {@link Dialect}, @@ -78,16 +84,45 @@ class JdbcQueryCreator extends RelationalQueryCreator { * @param accessor parameter metadata provider, must not be {@literal null}. * @param isSliceQuery flag denoting if the query returns a {@link org.springframework.data.domain.Slice}. * @param returnedType the {@link ReturnedType} to be returned by the query. Must not be {@literal null}. + * @deprecated use + * {@link JdbcQueryCreator#JdbcQueryCreator(RelationalMappingContext, PartTree, JdbcConverter, Dialect, RelationalEntityMetadata, RelationalParameterAccessor, boolean, ReturnedType, Optional, SqlGeneratorSource)} + * instead. */ + @Deprecated(since = "4.0", forRemoval = true) JdbcQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect, RelationalEntityMetadata entityMetadata, RelationalParameterAccessor accessor, boolean isSliceQuery, ReturnedType returnedType, Optional lockMode) { + this(context, tree, converter, dialect, entityMetadata, accessor, isSliceQuery, returnedType, lockMode, + new SqlGeneratorSource(context, converter, dialect)); + } + + /** + * Creates new instance of this class with the given {@link PartTree}, {@link JdbcConverter}, {@link Dialect}, + * {@link RelationalEntityMetadata} and {@link RelationalParameterAccessor}. + * + * @param context the mapping context. Must not be {@literal null}. + * @param tree part tree, must not be {@literal null}. + * @param converter must not be {@literal null}. + * @param dialect must not be {@literal null}. + * @param entityMetadata relational entity metadata, must not be {@literal null}. + * @param accessor parameter metadata provider, must not be {@literal null}. + * @param isSliceQuery flag denoting if the query returns a {@link org.springframework.data.domain.Slice}. + * @param returnedType the {@link ReturnedType} to be returned by the query. Must not be {@literal null}. + * @param lockMode lock mode to be used for the query. + * @param sqlGeneratorSource the source providing SqlGenerator instances for generating SQL. Must not be + * {@literal null} + * @since 4.0 + */ + JdbcQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect, + RelationalEntityMetadata entityMetadata, RelationalParameterAccessor accessor, boolean isSliceQuery, + ReturnedType returnedType, Optional lockMode, SqlGeneratorSource sqlGeneratorSource) { super(tree, accessor); Assert.notNull(converter, "JdbcConverter must not be null"); Assert.notNull(dialect, "Dialect must not be null"); Assert.notNull(entityMetadata, "Relational entity metadata must not be null"); Assert.notNull(returnedType, "ReturnedType must not be null"); + Assert.notNull(sqlGeneratorSource, "SqlGeneratorSource must not be null"); this.context = context; this.tree = tree; @@ -99,6 +134,7 @@ class JdbcQueryCreator extends RelationalQueryCreator { this.isSliceQuery = isSliceQuery; this.returnedType = returnedType; this.lockMode = lockMode; + this.sqlGeneratorSource = sqlGeneratorSource; } /** @@ -228,122 +264,13 @@ SelectBuilder.SelectLimitOffset createSelectClause(RelationalPersistentEntity private SelectBuilder.SelectJoin selectBuilder(Table table) { - List columnExpressions = new ArrayList<>(); RelationalPersistentEntity entity = entityMetadata.getTableEntity(); - SqlContext sqlContext = new SqlContext(); - - List joinTables = new ArrayList<>(); - for (PersistentPropertyPath path : context - .findPersistentPropertyPaths(entity.getType(), p -> true)) { - - AggregatePath aggregatePath = context.getAggregatePath(path); - - if (returnedType.needsCustomConstruction()) { - if (!returnedType.getInputProperties().contains(aggregatePath.getRequiredBaseProperty().getName())) { - continue; - } - } - - // add a join if necessary - Join join = getJoin(sqlContext, aggregatePath); - if (join != null) { - joinTables.add(join); - } - - Column column = getColumn(sqlContext, aggregatePath); - if (column != null) { - columnExpressions.add(column); - } - } - - SelectBuilder.SelectAndFrom selectBuilder = StatementBuilder.select(columnExpressions); - SelectBuilder.SelectJoin baseSelect = selectBuilder.from(table); - - for (Join join : joinTables) { - - Condition condition = null; - for (int i = 0; i < join.joinColumns.size(); i++) { - Column parentColumn = join.parentId.get(i); - Column joinColumn = join.joinColumns.get(i); - Comparison singleCondition = joinColumn.isEqualTo(parentColumn); - condition = condition == null ? singleCondition : condition.and(singleCondition); - } - - Assert.state(condition != null, "No condition found"); - - baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(condition); - } + Predicate filter = ap -> returnedType.needsCustomConstruction() + && !returnedType.getInputProperties().contains(ap.getRequiredBaseProperty().getName()); - return baseSelect; + return (SelectBuilder.SelectJoin) sqlGeneratorSource.getSqlGenerator(entity.getType()).createSelectBuilder(table, + filter); } - /** - * Create a {@link Column} for {@link AggregatePath}. - * - * @param sqlContext for generating SQL constructs. - * @param path the path to the column in question. - * @return the statement as a {@link String}. Guaranteed to be not {@literal null}. - */ - @Nullable - private Column getColumn(SqlContext sqlContext, AggregatePath path) { - - // an embedded itself doesn't give an column, its members will though. - // if there is a collection or map on the path it won't get selected at all, but it will get loaded with a separate - // select - // only the parent path is considered in order to handle arrays that get stored as BINARY properly - if (path.isEmbedded() || path.getParentPath().isMultiValued()) { - return null; - } - - if (path.isEntity()) { - - if (path.isQualified() // - || path.isCollectionLike() // - || path.hasIdProperty() // - ) { - return null; - } - - // Simple entities without id include there backreference as an synthetic id in order to distinguish null entities - // from entities with only null values. - return sqlContext.getAnyReverseColumn(path); - } - - return sqlContext.getColumn(path); - } - - @Nullable - Join getJoin(SqlContext sqlContext, AggregatePath path) { - - if (!path.isEntity() || path.isEmbedded() || path.isMultiValued()) { - return null; - } - - Table currentTable = sqlContext.getTable(path); - - AggregatePath idDefiningParentPath = path.getIdDefiningParentPath(); - Table parentTable = sqlContext.getTable(idDefiningParentPath); - - List reverseColumns = path.getTableInfo().reverseColumnInfos().toList(ci -> currentTable.column(ci.name())); - List idColumns = idDefiningParentPath.getTableInfo().idColumnInfos() - .toList(ci -> parentTable.column(ci.name())); - return new Join( // - currentTable, // - reverseColumns, // - idColumns // - ); - } - - /** - * Value object representing a {@code JOIN} association. - */ - private record Join(Table joinTable, List joinColumns, List parentId) { - - Join { - Assert.isTrue(joinColumns.size() == parentId.size(), - "Both sides of a join condition must have the same number of columns"); - } - - } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/SqlContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/SqlContext.java index ab6db1ab51..0b83d2d575 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/SqlContext.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/SqlContext.java @@ -47,7 +47,7 @@ Column getColumn(AggregatePath path) { Column getAnyReverseColumn(AggregatePath path) { - AggregatePath.ColumnInfo anyReverseColumnInfo = path.getTableInfo().reverseColumnInfos().any(); + AggregatePath.ColumnInfo anyReverseColumnInfo = path.getTableInfo().backReferenceColumnInfos().any(); return getTable(path).column(anyReverseColumnInfo.name()).as(anyReverseColumnInfo.alias()); } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java index e053bc091f..9354f9423a 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java @@ -18,9 +18,7 @@ import static org.assertj.core.api.Assertions.*; import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; @@ -244,8 +242,9 @@ void projectByCompositeIdParts() { new EmbeddedPk(23L, "x"), "alpha" // )); - Query projectingQuery = Query.empty().columns( "embeddedPk.two", "name"); - SimpleEntityWithEmbeddedPk projected = template.findOne(projectingQuery, SimpleEntityWithEmbeddedPk.class).orElseThrow(); + Query projectingQuery = Query.empty().columns("embeddedPk.two", "name"); + SimpleEntityWithEmbeddedPk projected = template.findOne(projectingQuery, SimpleEntityWithEmbeddedPk.class) + .orElseThrow(); // Projection still does a full select, otherwise one would be null. // See https://github.com/spring-projects/spring-data-relational/issues/1821 diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java index 78d05c03dc..ee7a75eddc 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java @@ -120,7 +120,8 @@ public void idGenerationOfChildInList() { assertThat(newRoot.list.get(0).id).isEqualTo(24L); } - @Test // GH-537 + @Test + // GH-537 void populatesIdsIfNecessaryForAllRootsThatWereProcessed() { DummyEntity root1 = new DummyEntity().withId(123L); @@ -166,7 +167,8 @@ PersistentPropertyPath getPersistentPropertyPath(S } Identifier createBackRef(long value) { - return JdbcIdentifierBuilder.forBackReferences(converter, toAggregatePath("content"), value).build(); + return JdbcIdentifierBuilder.forBackReferences(converter, toAggregatePath("content"), + JdbcAggregateChangeExecutionContext.getValueProvider(value, toAggregatePath("content"), converter)).build(); } PersistentPropertyPath toPath(String path) { @@ -180,10 +182,8 @@ PersistentPropertyPath toPath(String path) { private static final class DummyEntity { - @Id - private final Long id; - @Version - private final long version; + @Id private final Long id; + @Version private final long version; private final Content content; @@ -221,14 +221,16 @@ public List getList() { } public boolean equals(final Object o) { - if (o == this) return true; + if (o == this) + return true; if (!(o instanceof final DummyEntity other)) return false; final Object this$id = this.getId(); final Object other$id = other.getId(); if (!Objects.equals(this$id, other$id)) return false; - if (this.getVersion() != other.getVersion()) return false; + if (this.getVersion() != other.getVersion()) + return false; final Object this$content = this.getContent(); final Object other$content = other.getContent(); if (!Objects.equals(this$content, other$content)) @@ -253,7 +255,8 @@ public int hashCode() { } public String toString() { - return "JdbcAggregateChangeExecutorContextImmutableUnitTests.DummyEntity(id=" + this.getId() + ", version=" + this.getVersion() + ", content=" + this.getContent() + ", list=" + this.getList() + ")"; + return "JdbcAggregateChangeExecutorContextImmutableUnitTests.DummyEntity(id=" + this.getId() + ", version=" + + this.getVersion() + ", content=" + this.getContent() + ", list=" + this.getList() + ")"; } public DummyEntity withId(Long id) { @@ -274,8 +277,7 @@ public DummyEntity withList(List list) { } private static final class Content { - @Id - private final Long id; + @Id private final Long id; Content() { id = null; @@ -290,7 +292,8 @@ public Long getId() { } public boolean equals(final Object o) { - if (o == this) return true; + if (o == this) + return true; if (!(o instanceof final Content other)) return false; final Object this$id = this.getId(); diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java index eef22d5c94..e6bf1cb5c5 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java @@ -18,6 +18,7 @@ import static java.util.Collections.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +import static org.springframework.data.jdbc.core.JdbcAggregateChangeExecutionContext.*; import static org.springframework.data.jdbc.core.convert.JdbcIdentifierBuilder.*; import java.util.ArrayList; @@ -257,7 +258,8 @@ PersistentPropertyPath getPersistentPropertyPath(S } Identifier createBackRef(long value) { - return forBackReferences(converter, toAggregatePath("content"), value).build(); + return forBackReferences(converter, toAggregatePath("content"), + getValueProvider(value, toAggregatePath("content"), converter)).build(); } PersistentPropertyPath toPath(String path) { diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java index b5ec5ece9f..f6a619af12 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java @@ -21,14 +21,17 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.function.Function; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.data.annotation.Id; import org.springframework.data.jdbc.core.PersistentPropertyPathTestUtils; import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; +import org.springframework.data.mapping.PersistentPropertyPathAccessor; import org.springframework.data.relational.core.mapping.AggregatePath; import org.springframework.data.relational.core.mapping.Embedded; +import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; /** * Unit tests for the {@link JdbcIdentifierBuilder}. @@ -47,7 +50,9 @@ class WithSimpleId { @Test // DATAJDBC-326 void parametersWithPropertyKeysUseTheParentPropertyJdbcType() { - Identifier identifier = JdbcIdentifierBuilder.forBackReferences(converter, getPath("child"), "eins").build(); + Identifier identifier = JdbcIdentifierBuilder + .forBackReferences(converter, getPath("child"), getValueProvider("eins", getPath("child"), converter)) + .build(); assertThat(identifier.getParts()) // .extracting("name", "value", "targetType") // @@ -62,7 +67,7 @@ void qualifiersForMaps() { AggregatePath path = getPath("children"); Identifier identifier = JdbcIdentifierBuilder // - .forBackReferences(converter, path, "parent-eins") // + .forBackReferences(converter, path, getValueProvider("parent-eins", path, converter)) // .withQualifier(path, "map-key-eins") // .build(); @@ -80,7 +85,7 @@ void qualifiersForLists() { AggregatePath path = getPath("moreChildren"); Identifier identifier = JdbcIdentifierBuilder // - .forBackReferences(converter, path, "parent-eins") // + .forBackReferences(converter, path, getValueProvider("parent-eins", path, converter)) // .withQualifier(path, "list-index-eins") // .build(); @@ -96,7 +101,8 @@ void qualifiersForLists() { void backreferenceAcrossEmbeddable() { Identifier identifier = JdbcIdentifierBuilder // - .forBackReferences(converter, getPath("embeddable.child"), "parent-eins") // + .forBackReferences(converter, getPath("embeddable.child"), + getValueProvider("parent-eins", getPath("embeddable.child"), converter)) // .build(); assertThat(identifier.getParts()) // @@ -110,7 +116,8 @@ void backreferenceAcrossEmbeddable() { void backreferenceAcrossNoId() { Identifier identifier = JdbcIdentifierBuilder // - .forBackReferences(converter, getPath("noId.child"), "parent-eins") // + .forBackReferences(converter, getPath("noId.child"), + getValueProvider("parent-eins", getPath("noId.child"), converter)) // .build(); assertThat(identifier.getParts()) // @@ -125,6 +132,25 @@ private AggregatePath getPath(String dotPath) { } } + /** + * copied from JdbcAggregateChangeExecutionContext + */ + static Function getValueProvider(Object idValue, AggregatePath path, JdbcConverter converter) { + + RelationalPersistentEntity entity = converter.getMappingContext() + .getPersistentEntity(path.getIdDefiningParentPath().getRequiredIdProperty().getType()); + + Function valueProvider = ap -> { + if (entity == null) { + return idValue; + } else { + PersistentPropertyPathAccessor propertyPathAccessor = entity.getPropertyPathAccessor(idValue); + return propertyPathAccessor.getProperty(ap.getRequiredPersistentPropertyPath()); + } + }; + return valueProvider; + } + @Nested class WithCompositeId { @@ -136,7 +162,7 @@ void forBackReferences() { AggregatePath path = getPath("children"); Identifier identifier = JdbcIdentifierBuilder // - .forBackReferences(converter, path, exampleId) // + .forBackReferences(converter, path, getValueProvider(exampleId, path, converter)) // .build(); assertThat(identifier.getParts()) // diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java index a6a7c63bae..4a5973c86e 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java @@ -89,42 +89,42 @@ void findOne() { @Test // GH-574 void findOneWrappedId() { - SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithWrappedId.class); + SqlGenerator sqlGenerator = createSqlGenerator(WithWrappedId.class); String sql = sqlGenerator.getFindOne(); assertSoftly(softly -> { softly.assertThat(sql).startsWith("SELECT") // - .contains("dummy_entity_with_wrapped_id.name AS name") // - .contains("dummy_entity_with_wrapped_id.id") // - .contains("WHERE dummy_entity_with_wrapped_id.id = :id"); + .contains("with_wrapped_id.name AS name") // + .contains("with_wrapped_id.id") // + .contains("WHERE with_wrapped_id.id = :id"); }); } @Test // GH-574 void findOneEmbeddedId() { - SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class); String sql = sqlGenerator.getFindOne(); assertSoftly(softly -> { softly.assertThat(sql).startsWith("SELECT") // - .contains("dummy_entity_with_embedded_id.name AS name") // - .contains("dummy_entity_with_embedded_id.one") // - .contains("dummy_entity_with_embedded_id.two") // + .contains("with_embedded_id.name AS name") // + .contains("with_embedded_id.one") // + .contains("with_embedded_id.two") // .contains(" WHERE ") // - .contains("dummy_entity_with_embedded_id.one = :one") // - .contains("dummy_entity_with_embedded_id.two = :two"); + .contains("with_embedded_id.one = :one") // + .contains("with_embedded_id.two = :two"); }); } @Test // GH-574 void deleteByIdEmbeddedId() { - SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class); String sql = sqlGenerator.getDeleteById(); @@ -132,15 +132,15 @@ void deleteByIdEmbeddedId() { softly.assertThat(sql).startsWith("DELETE") // .contains(" WHERE ") // - .contains("dummy_entity_with_embedded_id.one = :one") // - .contains("dummy_entity_with_embedded_id.two = :two"); + .contains("with_embedded_id.one = :one") // + .contains("with_embedded_id.two = :two"); }); } @Test // GH-574 void deleteByIdInEmbeddedId() { - SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class); String sql = sqlGenerator.getDeleteByIdIn(); @@ -148,33 +148,33 @@ void deleteByIdInEmbeddedId() { softly.assertThat(sql).startsWith("DELETE") // .contains(" WHERE ") // - .contains("(dummy_entity_with_embedded_id.one, dummy_entity_with_embedded_id.two) IN (:ids)"); + .contains("(with_embedded_id.one, with_embedded_id.two) IN (:ids)"); }); } @Test // GH-574 void deleteByPathEmbeddedId() { - SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class); PersistentPropertyPath path = PersistentPropertyPathTestUtils.getPath("other", - DummyEntityWithEmbeddedIdAndReference.class, context); + WithEmbeddedIdAndReference.class, context); String sql = sqlGenerator.createDeleteByPath(path); assertSoftly(softly -> { softly.assertThat(sql).startsWith("DELETE FROM other_entity WHERE") // - .contains("other_entity.dummy_entity_with_embedded_id_and_reference_one = :one") // - .contains("other_entity.dummy_entity_with_embedded_id_and_reference_two = :two"); + .contains("other_entity.with_embedded_id_and_reference_one = :one") // + .contains("other_entity.with_embedded_id_and_reference_two = :two"); }); } @Test // GH-574 void deleteInByPathEmbeddedId() { - SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class); PersistentPropertyPath path = PersistentPropertyPathTestUtils.getPath("other", - DummyEntityWithEmbeddedIdAndReference.class, context); + WithEmbeddedIdAndReference.class, context); String sql = sqlGenerator.createDeleteInByPath(path); @@ -183,14 +183,14 @@ void deleteInByPathEmbeddedId() { softly.assertThat(sql).startsWith("DELETE FROM other_entity WHERE") // .contains(" WHERE ") // .contains( - "(other_entity.dummy_entity_with_embedded_id_and_reference_one, other_entity.dummy_entity_with_embedded_id_and_reference_two) IN (:ids)"); + "(other_entity.with_embedded_id_and_reference_one, other_entity.with_embedded_id_and_reference_two) IN (:ids)"); }); } @Test // GH-574 void updateWithEmbeddedId() { - SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class); String sql = sqlGenerator.getUpdate(); @@ -198,15 +198,15 @@ void updateWithEmbeddedId() { softly.assertThat(sql).startsWith("UPDATE") // .contains(" WHERE ") // - .contains("dummy_entity_with_embedded_id.one = :one") // - .contains("dummy_entity_with_embedded_id.two = :two"); + .contains("with_embedded_id.one = :one") // + .contains("with_embedded_id.two = :two"); }); } @Test // GH-574 void existsByIdEmbeddedId() { - SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class); String sql = sqlGenerator.getExists(); @@ -214,8 +214,8 @@ void existsByIdEmbeddedId() { softly.assertThat(sql).startsWith("SELECT COUNT") // .contains(" WHERE ") // - .contains("dummy_entity_with_embedded_id.one = :one") // - .contains("dummy_entity_with_embedded_id.two = :two"); + .contains("with_embedded_id.one = :one") // + .contains("with_embedded_id.two = :two"); }); } @@ -269,24 +269,24 @@ void findAllInList() { @Test // GH-574 void findAllInListEmbeddedId() { - SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedId.class); + SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedId.class); String sql = sqlGenerator.getFindAllInList(); assertSoftly(softly -> { softly.assertThat(sql).startsWith("SELECT") // - .contains("dummy_entity_with_embedded_id.name AS name") // - .contains("dummy_entity_with_embedded_id.one") // - .contains("dummy_entity_with_embedded_id.two") // - .contains(" WHERE (dummy_entity_with_embedded_id.one, dummy_entity_with_embedded_id.two) IN (:ids)"); + .contains("with_embedded_id.name AS name") // + .contains("with_embedded_id.one") // + .contains("with_embedded_id.two") // + .contains(" WHERE (with_embedded_id.one, with_embedded_id.two) IN (:ids)"); }); } @Test // GH-574 void findOneWithReference() { - SqlGenerator sqlGenerator = createSqlGenerator(DummyEntityWithEmbeddedIdAndReference.class); + SqlGenerator sqlGenerator = createSqlGenerator(WithEmbeddedIdAndReference.class); String sql = sqlGenerator.getFindOne(); @@ -295,13 +295,11 @@ void findOneWithReference() { softly.assertThat(sql).startsWith("SELECT") // .contains(" LEFT OUTER JOIN other_entity other ") // .contains(" ON ") // - .contains( - " other.dummy_entity_with_embedded_id_and_reference_one = dummy_entity_with_embedded_id_and_reference.one ") // - .contains( - " other.dummy_entity_with_embedded_id_and_reference_two = dummy_entity_with_embedded_id_and_reference.two ") // + .contains(" other.with_embedded_id_and_reference_one = with_embedded_id_and_reference.one ") // + .contains(" other.with_embedded_id_and_reference_two = with_embedded_id_and_reference.two ") // .contains(" WHERE ") // - .contains("dummy_entity_with_embedded_id_and_reference.one = :one") // - .contains("dummy_entity_with_embedded_id_and_reference.two = :two"); + .contains("with_embedded_id_and_reference.one = :one") // + .contains("with_embedded_id_and_reference.two = :two"); }); } @@ -445,17 +443,8 @@ void joinForEmbeddedWithReference() { assertSoftly(softly -> { softly.assertThat(join.joinTable().getName()).isEqualTo(SqlIdentifier.unquoted("other_entity")); - softly.assertThat(join.columns()).extracting( // - pair -> pair.getFirst().getTable(), // - pair -> pair.getFirst().getName(), // - pair -> pair.getSecond().getTable().getName(), // - pair -> pair.getSecond().getName() // - ).contains(tuple( // - join.joinTable(), // - SqlIdentifier.unquoted("dummy_entity2"), // - SqlIdentifier.unquoted("dummy_entity2"), // - SqlIdentifier.unquoted("id") // - )); + softly.assertThat(join.condition()) + .isEqualTo(SqlGeneratorUnitTests.equalsCondition("dummy_entity2", "id", join.joinTable(), "dummy_entity2")); }); } @@ -511,7 +500,7 @@ static class DummyEntity { record WrappedId(Long id) { } - static class DummyEntityWithWrappedId { + static class WithWrappedId { @Id @Embedded(onEmpty = OnEmpty.USE_NULL) WrappedId wrappedId; @@ -522,7 +511,7 @@ static class DummyEntityWithWrappedId { record EmbeddedId(Long one, String two) { } - static class DummyEntityWithEmbeddedId { + static class WithEmbeddedId { @Id @Embedded(onEmpty = OnEmpty.USE_NULL) EmbeddedId embeddedId; @@ -531,7 +520,7 @@ static class DummyEntityWithEmbeddedId { } - static class DummyEntityWithEmbeddedIdAndReference { + static class WithEmbeddedIdAndReference { @Id @Embedded(onEmpty = OnEmpty.USE_NULL) EmbeddedId embeddedId; diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java index abced6804b..7e7989ef48 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java @@ -57,6 +57,7 @@ import org.springframework.data.relational.core.query.Criteria; import org.springframework.data.relational.core.query.Query; import org.springframework.data.relational.core.sql.Aliased; +import org.springframework.data.relational.core.sql.Comparison; import org.springframework.data.relational.core.sql.LockMode; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; @@ -92,6 +93,22 @@ class SqlGeneratorUnitTests { }); private SqlGenerator sqlGenerator; + static Comparison equalsCondition(Table parentTable, SqlIdentifier parentId, Table joinedTable, + SqlIdentifier joinedColumn) { + return org.springframework.data.relational.core.sql.Column.create(joinedColumn, joinedTable) + .isEqualTo(org.springframework.data.relational.core.sql.Column.create(parentId, parentTable)); + } + + static Comparison equalsCondition(SqlIdentifier parentTable, SqlIdentifier parentId, Table joinedTable, + SqlIdentifier joinedColumn) { + return equalsCondition(Table.create(parentTable), parentId, joinedTable, joinedColumn); + } + + static Comparison equalsCondition(String parentTable, String parentId, Table joinedTable, String joinedColumn) { + return equalsCondition(SqlIdentifier.unquoted(parentTable), SqlIdentifier.unquoted(parentId), joinedTable, + SqlIdentifier.unquoted(joinedColumn)); + } + @BeforeEach void setUp() { this.sqlGenerator = createSqlGenerator(DummyEntity.class); @@ -763,17 +780,9 @@ void joinForSimpleReference() { assertSoftly(softly -> { softly.assertThat(join.joinTable().getName()).isEqualTo(SqlIdentifier.quoted("REFERENCED_ENTITY")); - softly.assertThat(join.columns()).extracting( // - pair -> pair.getFirst().getTable(), // - pair -> pair.getFirst().getName(), // - pair -> pair.getSecond().getTable().getName(), // - pair -> pair.getSecond().getName() // - ).contains(tuple( // - join.joinTable(), // - SqlIdentifier.quoted("DUMMY_ENTITY"), // - SqlIdentifier.quoted("DUMMY_ENTITY"), // - SqlIdentifier.quoted("id1") // - )); + softly.assertThat(join.condition()).isEqualTo(equalsCondition(SqlIdentifier.quoted("DUMMY_ENTITY"), + SqlIdentifier.quoted("id1"), join.joinTable(), SqlIdentifier.quoted("DUMMY_ENTITY"))); + }); } @@ -801,17 +810,10 @@ void joinForSecondLevelReference() { assertSoftly(softly -> { softly.assertThat(join.joinTable().getName()).isEqualTo(SqlIdentifier.quoted("SECOND_LEVEL_REFERENCED_ENTITY")); - softly.assertThat(join.columns()).extracting( // - pair -> pair.getFirst().getTable(), // - pair -> pair.getFirst().getName(), // - pair -> pair.getSecond().getTable().getName(), // - pair -> pair.getSecond().getName() // - ).contains(tuple( // - join.joinTable(), // - SqlIdentifier.quoted("REFERENCED_ENTITY"), // - SqlIdentifier.quoted("REFERENCED_ENTITY"), // - SqlIdentifier.quoted("X_L1ID") // - )); + softly.assertThat(join.condition()) + .isEqualTo(equalsCondition(Table.create("REFERENCED_ENTITY").as(SqlIdentifier.quoted("ref")), + SqlIdentifier.quoted("X_L1ID"), join.joinTable(), SqlIdentifier.quoted("REFERENCED_ENTITY"))); + }); } @@ -826,18 +828,8 @@ void joinForOneToOneWithoutId() { softly.assertThat(joinTable.getName()).isEqualTo(SqlIdentifier.quoted("NO_ID_CHILD")); softly.assertThat(joinTable).isInstanceOf(Aliased.class); softly.assertThat(((Aliased) joinTable).getAlias()).isEqualTo(SqlIdentifier.quoted("child")); - - softly.assertThat(join.columns()).extracting( // - pair -> pair.getFirst().getTable(), // - pair -> pair.getFirst().getName(), // - pair -> pair.getSecond().getTable().getName(), // - pair -> pair.getSecond().getName() // - ).contains(tuple( // - join.joinTable(), // - SqlIdentifier.quoted("PARENT_OF_NO_ID_CHILD"), // - SqlIdentifier.quoted("PARENT_OF_NO_ID_CHILD"), // - SqlIdentifier.quoted("X_ID") // - )); + softly.assertThat(join.condition()).isEqualTo(equalsCondition(SqlIdentifier.quoted("PARENT_OF_NO_ID_CHILD"), + SqlIdentifier.quoted("X_ID"), join.joinTable(), SqlIdentifier.quoted("PARENT_OF_NO_ID_CHILD"))); }); } diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java index e3b33e4547..1fecd89e8a 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java @@ -377,7 +377,8 @@ private Query getIdQuery(Object id) { idEntity.doWithProperties(new PropertyHandler() { @Override public void doWithPersistentProperty(RelationalPersistentProperty persistentProperty) { - criteriaHolder[0] = criteriaHolder [0].and(persistentProperty.getName()).is(accessor.getProperty(persistentProperty)); + criteriaHolder[0] = criteriaHolder[0].and(persistentProperty.getName()) + .is(accessor.getProperty(persistentProperty)); } }); criteria = criteriaHolder[0]; diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/CompositeIdRepositoryIntegrationTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/CompositeIdRepositoryIntegrationTests.java index eeedcf1355..9e868577fb 100644 --- a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/CompositeIdRepositoryIntegrationTests.java +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/CompositeIdRepositoryIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2019-2025 the original author or authors. + * 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. @@ -15,7 +15,10 @@ */ package org.springframework.data.r2dbc.repository; +import static org.assertj.core.api.Assertions.*; + import io.r2dbc.spi.ConnectionFactory; +import reactor.test.StepVerifier; import javax.sql.DataSource; @@ -36,9 +39,6 @@ import org.springframework.data.repository.reactive.ReactiveCrudRepository; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.junit.jupiter.SpringExtension; -import reactor.test.StepVerifier; - -import static org.assertj.core.api.Assertions.*; /** * Integration tests for repositories of entities with a composite id. @@ -103,7 +103,7 @@ protected ConnectionFactory createConnectionFactory() { void findAllById() { repository.findById(new CompositeId(42, "HBAR")) // .as(StepVerifier::create) // - .consumeNextWith(actual ->{ + .consumeNextWith(actual -> { assertThat(actual.name).isEqualTo("Walter"); }).verifyComplete(); } 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 4ea0e38b90..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 @@ -20,11 +20,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.TreeMap; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.BinaryOperator; -import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Stream; import java.util.stream.StreamSupport; @@ -32,17 +30,22 @@ 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, Comparable { @@ -67,7 +70,7 @@ public interface AggregatePath extends Iterable, Comparable stream() { */ AggregatePath getIdDefiningParentPath(); + /** + * 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(); - 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, - - ColumnInfos reverseColumnInfos, - - /* - * The column used for the list index or map key of the leaf property of this path. - */ - @Nullable ColumnInfo qualifierColumnInfo, + /** + * 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 type of the qualifier column of the leaf property of this path or {@literal null} if this is not - * applicable. - */ - @Nullable Class qualifierColumnType, + /** + * 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()); + } - /* - * The column name of the id column of the ancestor path that represents an actual table. - */ - ColumnInfos idColumnInfos) { + /** + * 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) { @@ -289,7 +314,7 @@ static TableInfo of(AggregatePath path) { SqlIdentifier tableAlias = tableOwner.isRoot() ? null : AggregatePathTableUtils.constructTableAlias(tableOwner); - ColumnInfos reverseColumnInfos = computeReverseColumnInfo(path); + ColumnInfos backReferenceColumnInfos = computeBackReferenceColumnInfos(path); ColumnInfo qualifierColumnInfo = null; if (!path.isRoot()) { @@ -307,8 +332,8 @@ static TableInfo of(AggregatePath path) { ColumnInfos idColumnInfos = computeIdColumnInfos(tableOwner, leafEntity); - return new TableInfo(qualifiedTableName, tableAlias, reverseColumnInfos, qualifierColumnInfo, qualifierColumnType, - idColumnInfos); + return new TableInfo(qualifiedTableName, tableAlias, backReferenceColumnInfos, qualifierColumnInfo, + qualifierColumnType, idColumnInfos); } @@ -337,7 +362,7 @@ private static ColumnInfos computeIdColumnInfos(AggregatePath tableOwner, } } - private static ColumnInfos computeReverseColumnInfo(AggregatePath path) { + private static ColumnInfos computeBackReferenceColumnInfos(AggregatePath path) { AggregatePath tableOwner = AggregatePathTraversal.getTableOwningPath(path); @@ -346,65 +371,79 @@ private static ColumnInfos computeReverseColumnInfo(AggregatePath path) { } AggregatePath idDefiningParentPath = tableOwner.getIdDefiningParentPath(); - RelationalPersistentProperty leafProperty = tableOwner.getRequiredLeafProperty(); - - RelationalPersistentProperty idProperty = idDefiningParentPath.getLeafEntity().getIdProperty(); + RelationalPersistentProperty idProperty = idDefiningParentPath.getRequiredLeafEntity().getIdProperty(); - if (idProperty != null) { - if (idProperty.isEntity()) { + AggregatePath basePath = idProperty != null && idProperty.isEntity() ? idDefiningParentPath.append(idProperty) + : idDefiningParentPath; + ColumInfosBuilder ciBuilder = new ColumInfosBuilder(basePath); - AggregatePath idBasePath = idDefiningParentPath.append(idProperty); - ColumInfosBuilder ciBuilder = new ColumInfosBuilder(idBasePath); + if (idProperty != null && idProperty.isEntity()) { - RelationalPersistentEntity idEntity = idBasePath.getRequiredLeafEntity(); - idEntity.doWithProperties((PropertyHandler) p -> { - AggregatePath idElementPath = idBasePath.append(p); - SqlIdentifier name = idElementPath.getColumnInfo().name(); - name = name.transform(n -> idDefiningParentPath.getTableInfo().qualifiedTableName.getReference() + "_" + n); + 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); - }); - - return ciBuilder.build(); - - } else { - - ColumInfosBuilder ciBuilder = new ColumInfosBuilder(idDefiningParentPath); - SqlIdentifier reverseColumnName = leafProperty - .getReverseColumnName(idDefiningParentPath.getRequiredLeafEntity()); - - ciBuilder.add(idProperty, reverseColumnName, - AggregatePathTableUtils.prefixWithTableAlias(path, reverseColumnName)); + ciBuilder.add(idElementPath, name, name); + }); - return ciBuilder.build(); - } } else { - ColumInfosBuilder ciBuilder = new ColumInfosBuilder(idDefiningParentPath); + RelationalPersistentProperty leafProperty = tableOwner.getRequiredLeafProperty(); SqlIdentifier reverseColumnName = leafProperty .getReverseColumnName(idDefiningParentPath.getRequiredLeafEntity()); + SqlIdentifier alias = AggregatePathTableUtils.prefixWithTableAlias(path, reverseColumnName); - ciBuilder.add(idDefiningParentPath, reverseColumnName, - AggregatePathTableUtils.prefixWithTableAlias(path, reverseColumnName)); - - return ciBuilder.build(); + 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 reverseColumnInfos.unique(); + 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 reverseColumnInfos.columnInfos.isEmpty() ? idColumnInfos : reverseColumnInfos; + 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. + * @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) { @@ -432,29 +471,49 @@ static ColumnInfo of(AggregatePath path) { } /** - * A group of {@link ColumnInfo} values referenced by there respective {@link AggregatePath}. This is relevant for - * composite ids and references to such ids. - **/ + * 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} */ - private ColumnInfos(AggregatePath basePath, Map columnInfos) { + ColumnInfos(AggregatePath basePath, Map columnInfos) { this.basePath = basePath; this.columnInfos = columnInfos; } - public static ColumnInfos empty(AggregatePath base) { - return new ColumnInfos(base, new HashMap<>()); + /** + * 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(); @@ -462,18 +521,41 @@ public ColumnInfo 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(); } - public List toList(Function mapper) { - return columnInfos.values().stream().map(mapper).toList(); + /** + * 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()); } /** @@ -489,8 +571,8 @@ public List toList(Function mapper) { * 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. - * @return result of the function. * @param type of the result. + * @return result of the function. * @since 3.5 */ public T reduce(T identity, BiFunction accumulator, BinaryOperator combiner) { @@ -506,56 +588,57 @@ public T reduce(T identity, BiFunction accumul 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(); } } - class ColumInfosBuilder { - private final AggregatePath basePath; - - private final Map columnInfoMap = new TreeMap<>(); - - public ColumInfosBuilder(AggregatePath basePath) { - this.basePath = basePath; - } - - void add(AggregatePath path, SqlIdentifier name, SqlIdentifier alias) { - add(path, new ColumnInfo(name, alias)); - } - - public void add(RelationalPersistentProperty property, SqlIdentifier name, SqlIdentifier alias) { - add(basePath.append(property), name, alias); - } - - ColumnInfos build() { - return new ColumnInfos(basePath, columnInfoMap); - } - - public void add(AggregatePath path, ColumnInfo columnInfo) { - columnInfoMap.put(path.substract(basePath), columnInfo); - } - } - - @Nullable - AggregatePath substract(@Nullable AggregatePath basePath); - } 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 e0e1073da5..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 @@ -21,7 +21,6 @@ import org.springframework.data.mapping.PersistentPropertyPath; import org.springframework.data.util.Lazy; -import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ConcurrentLruCache; @@ -229,7 +228,7 @@ public AggregatePath getTail() { @Override @Nullable - public AggregatePath substract(@Nullable AggregatePath basePath) { + public AggregatePath subtract(@Nullable AggregatePath basePath) { if (basePath == null || basePath.isRoot()) { return this; @@ -244,7 +243,7 @@ public AggregatePath substract(@Nullable AggregatePath basePath) { if (tail == null) { return null; } - return tail.substract(basePath.getTail()); + return tail.subtract(basePath.getTail()); } throw new IllegalStateException("Can't subtract [%s] from [%s]".formatted(basePath, this)); @@ -303,11 +302,6 @@ public String toString() { + ((isRoot()) ? "/" : path.toDotPath()); } - @Override - public int compareTo(@NonNull AggregatePath other) { - return toDotPath().compareTo(other.toDotPath()); - } - private static class AggregatePathIterator implements Iterator { private @Nullable AggregatePath current; 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 210ab1f7ec..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,8 @@ default SqlIdentifier getQualifiedTableName() { * Returns the column representing the identifier. * * @return will never be {@literal null}. - * @deprecated use {@code AggregatePath.getTableInfo().getIdColumnInfos()} instead. + * @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 ecc46d3ede..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 @@ -62,9 +62,9 @@ public AnalyticFunction partitionBy(Expression... partitionBy) { * @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 3.5 + * @since 4.0 */ - public AnalyticFunction partitionBy(Collection partitionBy) { + public AnalyticFunction partitionBy(Collection partitionBy) { return partitionBy(partitionBy.toArray(new Expression[0])); } @@ -85,9 +85,9 @@ public AnalyticFunction orderBy(OrderByField... orderBy) { * @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 3.5 + * @since 4.0 */ - public AnalyticFunction orderBy(Collection orderBy) { + public AnalyticFunction orderBy(Collection orderBy) { return orderBy(orderBy.toArray(new Expression[0])); } 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 index e1699197ea..82ac2fe7f8 100644 --- 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 @@ -1,6 +1,20 @@ -package org.springframework.data.relational.core.sql; +/* + * 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. + */ -import org.jetbrains.annotations.NotNull; +package org.springframework.data.relational.core.sql; import static java.util.stream.Collectors.*; @@ -8,13 +22,13 @@ /** * A tuple as used in conditions like - * + * *

  *   WHERE (one, two) IN (select x, y from some_table)
  * 
* * @author Jens Schauder - * @since 3.5 + * @since 4.0 */ public class TupleExpression extends AbstractSegment implements Expression { @@ -24,7 +38,7 @@ private static Segment[] children(List expressions) { return expressions.toArray(new Segment[0]); } - private TupleExpression(List expressions) { + TupleExpression(List expressions) { super(children(expressions)); @@ -39,14 +53,6 @@ public static TupleExpression create(List expressions) { return new TupleExpression(expressions); } - public static Expression maybeWrap(List columns) { - - if (columns.size() == 1) { - return columns.get(0); - } - return new TupleExpression(columns); - } - @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/TupleVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/TupleVisitor.java index fef8d8f688..d03fce9d3f 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2019-2024 the original author or authors. + * 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. @@ -22,7 +22,7 @@ * Visitor for rendering tuple expressions. * * @author Jens Schauder - * @since 3.5 + * @since 4.0 */ class TupleVisitor extends TypedSingleConditionRenderSupport implements PartRenderer { 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 183050166f..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 @@ -169,10 +169,7 @@ private QueryMeta createInlineQuery(AggregatePath basePath, @Nullable Condition String rowCountAlias = aliases.getRowCountAlias(basePath); Expression count = basePath.isRoot() ? new AliasedExpression(SQL.literalOf(1), rowCountAlias) // : AnalyticFunction.create("count", Expressions.just("*")) // - .partitionBy( // - basePath.getTableInfo().reverseColumnInfos().toList( // - ci -> table.column(ci.name()) // - ) // + .partitionBy(basePath.getTableInfo().backReferenceColumnInfos().toColumnList(table) // ).as(rowCountAlias); columns.add(count); @@ -182,7 +179,8 @@ private QueryMeta createInlineQuery(AggregatePath basePath, @Nullable Condition if (!basePath.isRoot()) { backReferenceAlias = aliases.getBackReferenceAlias(basePath); - columns.add(table.column(basePath.getTableInfo().reverseColumnInfos().unique().name()).as(backReferenceAlias)); + columns + .add(table.column(basePath.getTableInfo().backReferenceColumnInfos().unique().name()).as(backReferenceAlias)); keyAlias = aliases.getKeyAlias(basePath); Expression keyExpression = basePath.isQualified() @@ -242,10 +240,10 @@ private String getIdentifierProperty(List paths) { private static AnalyticFunction createRowNumberExpression(AggregatePath basePath, Table table, String rowNumberAlias) { - AggregatePath.ColumnInfos reverseColumnInfos = basePath.getTableInfo().reverseColumnInfos(); + AggregatePath.ColumnInfos reverseColumnInfos = basePath.getTableInfo().backReferenceColumnInfos(); return AnalyticFunction.create("row_number") // - .partitionBy(reverseColumnInfos.toList(ci -> table.column(ci.name()))) // - .orderBy(reverseColumnInfos.toList(ci -> table.column(ci.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/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 index 54f0bf0482..fa1db8e202 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 the original author or authors. + * 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. @@ -26,14 +26,16 @@ 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(); @@ -45,9 +47,7 @@ void emptyColumnInfos() { assertThat(columnInfos.isEmpty()).isTrue(); assertThrows(NoSuchElementException.class, columnInfos::any); assertThrows(IllegalStateException.class, columnInfos::unique); - assertThat(columnInfos.toList(ci -> { - throw new IllegalStateException("This should never get called"); - })).isEmpty(); + assertThat(columnInfos.toColumnList(TABLE)).isEmpty(); } @Test // GH-574 @@ -58,21 +58,21 @@ void singleElementColumnInfos() { assertThat(columnInfos.isEmpty()).isFalse(); assertThat(columnInfos.any().name()).isEqualTo(ID); assertThat(columnInfos.unique().name()).isEqualTo(ID); - assertThat(columnInfos.toList(ci -> ci.name())).containsExactly(ID); + assertThat(columnInfos.toColumnList(TABLE)).containsExactly(TABLE.column(ID)); } @Test // GH-574 void multiElementColumnInfos() { - AggregatePath.ColumnInfos columnInfos = basePath(DummyEntityWithCompositeId.class).getTableInfo().idColumnInfos(); + 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.toList(ci -> ci.name())) // + assertThat(columnInfos.toColumnList(TABLE)) // .containsExactly( // - SqlIdentifier.quoted("ONE"), // - SqlIdentifier.quoted("TWO") // + TABLE.column(SqlIdentifier.quoted("ONE")), // + TABLE.column(SqlIdentifier.quoted("TWO")) // ); List collector = new ArrayList<>(); @@ -97,6 +97,6 @@ record DummyEntity(@Id String id, String name) { record CompositeId(String one, String two) { } - record DummyEntityWithCompositeId(@Id @Embedded.Nullable CompositeId id, String name) { + 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 1abdc4ddd1..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 @@ -30,7 +30,9 @@ 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}. @@ -48,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 @@ -56,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((Object) path("second.third2.value").getParentPath()).isEqualTo(path("second.third2")); - softly.assertThat((Object) path("second.third2").getParentPath()).isEqualTo(path("second")); - softly.assertThat((Object) 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); }); @@ -77,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); @@ -94,17 +96,16 @@ void getRequiredLeafEntity() { @Test // GH-1525 void idDefiningPath() { - assertSoftly(softly -> { + AggregatePathSoftAssertions.assertAggregatePathsSoftly(softly -> { - softly.assertThat((Object) path("second.third2.value").getIdDefiningParentPath()).isEqualTo(path()); - softly.assertThat((Object) path("second.third.value").getIdDefiningParentPath()).isEqualTo(path()); - softly.assertThat((Object) path("secondList.third2.value").getIdDefiningParentPath()).isEqualTo(path()); - softly.assertThat((Object) path("secondList.third.value").getIdDefiningParentPath()).isEqualTo(path()); - softly.assertThat((Object) path("second2.third2.value").getIdDefiningParentPath()).isEqualTo(path()); - softly.assertThat((Object) path("second2.third.value").getIdDefiningParentPath()).isEqualTo(path()); - softly.assertThat((Object) path("withId.second.third2.value").getIdDefiningParentPath()) - .isEqualTo(path("withId")); - softly.assertThat((Object) 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"); }); } @@ -147,8 +148,10 @@ void reverseColumnName() { void reverseColumnNames() { assertSoftly(softly -> { - softly.assertThat(path(CompoundIdEntity.class, "second").getTableInfo().reverseColumnInfos().toList(x -> x)) - .extracting(AggregatePath.ColumnInfo::name) + 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")); }); @@ -183,13 +186,11 @@ void getQualifierColumnType() { @Test // GH-1525 void extendBy() { + AggregatePathSoftAssertions.assertAggregatePathsSoftly(softly -> { - assertSoftly(softly -> { - - softly.assertThat((Object) path().append(entity.getRequiredPersistentProperty("withId"))) - .isEqualTo(path("withId")); - softly.assertThat((Object) 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"); }); } @@ -244,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 (so the parent is // multi-valued but not third2). - // TODO: This test fails because MultiValued considers parents. - // softly.assertThat(path("secondList.third.value").isMultiValued()).isFalse(); + softly.assertThat(path("secondList.third.value").isMultiValued()).isTrue(); softly.assertThat(path("secondList").isMultiValued()).isTrue(); }); } @@ -453,8 +454,7 @@ void getRequiredPersistentPropertyPath() { }); } - @Test - // GH-1525 + @Test // GH-1525 void getLength() { assertSoftly(softly -> { @@ -472,25 +472,24 @@ void getLength() { @Test // GH-574 void getTail() { - assertSoftly(softly -> { + AggregatePathSoftAssertions.assertAggregatePathsSoftly(softly -> { - softly.assertThat((Object) path().getTail()).isEqualTo(null); - softly.assertThat((Object) path("second").getTail()).isEqualTo(null); - softly.assertThat(path("second.third").getTail().toDotPath()).isEqualTo("third"); - softly.assertThat(path("second.third.value").getTail().toDotPath()).isEqualTo("third.value"); + softly.assertAggregatePath(path().getTail()).isNull(); + softly.assertAggregatePath(path("second").getTail()).isNull(); + softly.assertAggregatePath(path("second.third").getTail()).hasPath("third"); + softly.assertAggregatePath(path("second.third.value").getTail()).hasPath("third.value"); }); } @Test // GH-74 void append() { - assertSoftly(softly -> { - - softly.assertThat(path("second").append(path()).toDotPath()).isEqualTo("second"); - softly.assertThat(path().append(path("second")).toDotPath()).isEqualTo("second"); - softly.assertThat(path().append(path("second.third")).toDotPath()).isEqualTo("second.third"); + AggregatePathSoftAssertions.assertAggregatePathsSoftly(softly -> { + softly.assertAggregatePath(path("second").append(path())).hasPath("second"); + softly.assertAggregatePath(path().append(path("second"))).hasPath("second"); + softly.assertAggregatePath(path().append(path("second.third"))).hasPath("second.third"); AggregatePath value = path("second.third.value").getTail().getTail(); - softly.assertThat(path("second.third").append(value).toDotPath()).isEqualTo("second.third.value"); + softly.assertAggregatePath(path("second.third").append(value)).hasPath("second.third.value"); }); } diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java index b69a9ee10e..6ee97eacd7 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 the original author or authors. + * 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. @@ -22,6 +22,11 @@ import org.junit.jupiter.api.Test; +/** + * Unit tests for construction of {@link TupleExpression}. + * + * @author Jens Schauder + */ class TupleExpressionUnitTests { @Test // GH-574 @@ -29,7 +34,7 @@ void singleExpressionDoesNotGetWrapped() { Column testColumn = Column.create("name", Table.create("employee")); - Expression wrapped = TupleExpression.maybeWrap(List.of(testColumn)); + Expression wrapped = Expressions.of(List.of(testColumn)); assertThat(wrapped).isSameAs(testColumn); } @@ -40,7 +45,7 @@ void multipleExpressionsDoGetWrapped() { Column testColumn1 = Column.create("first", Table.create("employee")); Column testColumn2 = Column.create("last", Table.create("employee")); - Expression wrapped = TupleExpression.maybeWrap(List.of(testColumn1, testColumn2)); + Expression wrapped = Expressions.of(List.of(testColumn1, testColumn2)); assertThat(wrapped).isInstanceOf(TupleExpression.class); } diff --git a/src/main/antora/modules/ROOT/pages/jdbc/mapping.adoc b/src/main/antora/modules/ROOT/pages/jdbc/mapping.adoc index c41b6cd42b..02d4b12cfd 100644 --- a/src/main/antora/modules/ROOT/pages/jdbc/mapping.adoc +++ b/src/main/antora/modules/ROOT/pages/jdbc/mapping.adoc @@ -65,7 +65,8 @@ The table of the referenced entity is expected to have an additional column with * `Map` is considered a qualified one-to-many relationship. The table of the referenced entity is expected to have two additional columns: One named based on the referencing entity for the foreign key (see <>) and one with the same name and an additional `_key` suffix for the map key. -* `List` is mapped as a `Map`. The same additional columns are expected and the names used can be customized in the same way. +* `List` is mapped as a `Map`. +The same additional columns are expected and the names used can be customized in the same way. + For `List`, `Set`, and `Map` naming of the back reference can be controlled by implementing `NamingStrategy.getReverseColumnName(RelationalPersistentEntity owner)` and `NamingStrategy.getKeyColumn(RelationalPersistentProperty property)`, respectively. Alternatively you may annotate the attribute with `@MappedCollection(idColumn="your_column_name", keyColumn="your_key_column_name")`. diff --git a/src/main/antora/modules/ROOT/partials/mapping-annotations.adoc b/src/main/antora/modules/ROOT/partials/mapping-annotations.adoc index e98d076c5d..0fb12a5706 100644 --- a/src/main/antora/modules/ROOT/partials/mapping-annotations.adoc +++ b/src/main/antora/modules/ROOT/partials/mapping-annotations.adoc @@ -1,7 +1,17 @@ The `RelationalConverter` can use metadata to drive the mapping of objects to rows. The following annotations are available: +* `@Embedded`: an entity with this annotation will be mapped to the table of the parent entity, instead of a separate table. +Allows to specify if the resulting columns should have a common prefix. +If all columns resulting from such an entity are `null` either the annotated entity will be `null` or _empty_, i.e. all of its properties will be `null`, depending on the value of `@Embedded.onEmpty()` +May be combined with `@Id` to form a composite id. * `@Id`: Applied at the field level to mark the primary key. +It may be combined with `@Embedded` to form a composite id. +* `@InsertOnlyProperty`: Marks a property as only to be written during insert. +Such a property on an aggregate root will only be written once and never updated. +Note that on a nested entity, all save operations result in an insert therefore this annotation has no effect on properties of nested entities. +* `@MappedCollection`: Allows for configuration how a collection, or a single nested entity gets mapped. `idColumn` specifies the column used for referencing the parent entities primary key. `keyColumn` specifies the column used to store the index of a `List` or the key of a `Map`. +* `@Sequence`: specify a database sequence for generating values for the annotated property. * `@Table`: Applied at the class level to indicate this class is a candidate for mapping to the database. You can specify the name of the table where the database is stored. * `@Transient`: By default, all fields are mapped to the row. @@ -22,3 +32,5 @@ However, this is not recommended, since it may cause problems with other tools. The value is `null` (`zero` for primitive types) is considered as marker for entities to be new. The initially stored value is `zero` (`one` for primitive types). The version gets incremented automatically on every update. + + diff --git a/src/main/antora/modules/ROOT/partials/mapping.adoc b/src/main/antora/modules/ROOT/partials/mapping.adoc index ed80c37fab..16e0c5b833 100644 --- a/src/main/antora/modules/ROOT/partials/mapping.adoc +++ b/src/main/antora/modules/ROOT/partials/mapping.adoc @@ -88,7 +88,6 @@ endif::[] You may use xref:value-expressions.adoc[Spring Data's SpEL support] to dynamically create column names. Once generated the names will be cached, so it is dynamic per mapping context only. - ifdef::embedded-entities[] [[entity-persistence.embedded-entities]] @@ -156,6 +155,43 @@ Entities may be annotated with `@Id` and `@Embedded`, resulting in a composite i The full embedded entity is considered the id, and therefore the check for determining if an aggregate is considered a new aggregate requiring an insert or an existing one, asking for an update is based on that entity, not its elements. Most use cases will require a custom `BeforeConvertCallback` to set the id for new aggregate. +==== +.Simple entity with composite id +[source,java] +---- +@Table("PERSON_WITH_COMPOSITE_ID") +record Person( <1> + @Id @Embedded.Nullable Name pk, <2> + String nickName, + Integer age +) { +} + +record Name(String first, String last) { +} +---- + +.Matching table for simple entity with composite id +[source,sql] +---- +CREATE TABLE PERSON_WITH_COMPOSITE_ID ( + FIRST VARCHAR(100), + LAST VARCHAR(100), + NICK_NAME VARCHAR(100), + AGE INT, + PRIMARY KEY (FIRST, LAST) <3> +); + + +---- + +<1> Entities may be represented as records without any special consideration +<2> `pk` is marked as id and embedded +<3> the two columns from the embedded `Name` entity make up the primary key in the database. + +Details of the create tables will depend on the database used. +==== + [[entity-persistence.read-only-properties]] == Read Only Properties From dbc33be464988c9228024910f333b85bfb8fc3ac Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 26 May 2025 16:32:01 +0200 Subject: [PATCH 5/8] Polishing. Add benchmarks. Performance refinements. --- pom.xml | 69 +--- .../data/jdbc/BenchmarkSettings.java | 40 +++ .../data/jdbc/CompositeIdBenchmarks.java | 245 ++++++++++++++ .../JdbcAggregateChangeExecutionContext.java | 34 +- .../convert/BindParameterNameSanitizer.java | 10 +- .../jdbc/core/convert/JdbcColumnTypes.java | 7 + .../core/convert/MappingJdbcConverter.java | 8 +- .../data/jdbc/core/convert/SqlGenerator.java | 78 +++-- .../convert/SqlIdentifierParameterSource.java | 6 +- .../core/convert/SqlParametersFactory.java | 116 +++---- .../query/JdbcDeleteQueryCreator.java | 44 +-- .../data/jdbc/support/JdbcUtil.java | 10 +- ...AggregateTemplateHsqlIntegrationTests.java | 2 +- ...angeExecutorContextImmutableUnitTests.java | 2 +- ...gregateChangeExecutorContextUnitTests.java | 2 +- .../JdbcIdentifierBuilderUnitTests.java | 2 +- .../SqlGeneratorEmbeddedUnitTests.java | 303 ++++++++---------- .../core/mapping/AggregatePath.java | 54 +++- .../core/mapping/AggregatePathTraversal.java | 21 +- .../core/mapping/DefaultAggregatePath.java | 11 +- .../mapping/RelationalMappingContext.java | 2 +- .../data/relational/core/sql/Conditions.java | 11 + .../data/relational/core/sql/Disjunct.java | 38 +++ .../data/relational/core/sql/Expressions.java | 2 +- .../relational/core/sql/TupleExpression.java | 26 +- .../relational/core/sql/Unrestricted.java | 42 +++ .../core/sql/render/JoinVisitor.java | 8 +- .../core/mapping/ColumnInfosUnitTests.java | 1 - .../core/sql/TupleExpressionUnitTests.java | 1 - .../sql/render/SelectRendererUnitTests.java | 25 ++ 30 files changed, 798 insertions(+), 422 deletions(-) create mode 100644 spring-data-jdbc/src/jmh/java/org/springframework/data/jdbc/BenchmarkSettings.java create mode 100644 spring-data-jdbc/src/jmh/java/org/springframework/data/jdbc/CompositeIdBenchmarks.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Disjunct.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Unrestricted.java diff --git a/pom.xml b/pom.xml index 3c9e363c1e..7e7021e437 100644 --- a/pom.xml +++ b/pom.xml @@ -1,11 +1,14 @@ - + 4.0.0 org.springframework.data spring-data-relational-parent - 4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT + 4.0.0-1737-nullable-embedded-with-collection-574-composite-id-SNAPSHOT + pom Spring Data Relational Parent @@ -175,68 +178,6 @@ test - - - - org.codehaus.mojo - build-helper-maven-plugin - 3.3.0 - - - add-source - generate-sources - - add-test-source - - - - src/jmh/java - - - - - - - org.apache.maven.plugins - maven-surefire-plugin - - true - - - - - org.apache.maven.plugins - maven-failsafe-plugin - - true - - - - org.codehaus.mojo - exec-maven-plugin - 3.1.0 - - - run-benchmarks - pre-integration-test - - exec - - - test - java - - -classpath - - org.openjdk.jmh.Main - .* - - - - - - - jitpack.io diff --git a/spring-data-jdbc/src/jmh/java/org/springframework/data/jdbc/BenchmarkSettings.java b/spring-data-jdbc/src/jmh/java/org/springframework/data/jdbc/BenchmarkSettings.java new file mode 100644 index 0000000000..2e056565d4 --- /dev/null +++ b/spring-data-jdbc/src/jmh/java/org/springframework/data/jdbc/BenchmarkSettings.java @@ -0,0 +1,40 @@ +/* + * Copyright 2023-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.jdbc; + +import java.util.concurrent.TimeUnit; + +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Global benchmark settings. + * + * @author Mark Paluch + */ +@Warmup(iterations = 5, time = 1000, timeUnit = TimeUnit.MILLISECONDS) +@Measurement(iterations = 5, time = 1000, timeUnit = TimeUnit.MILLISECONDS) +@Fork(value = 1, warmups = 0) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +public abstract class BenchmarkSettings { + +} diff --git a/spring-data-jdbc/src/jmh/java/org/springframework/data/jdbc/CompositeIdBenchmarks.java b/spring-data-jdbc/src/jmh/java/org/springframework/data/jdbc/CompositeIdBenchmarks.java new file mode 100644 index 0000000000..f84792b20f --- /dev/null +++ b/spring-data-jdbc/src/jmh/java/org/springframework/data/jdbc/CompositeIdBenchmarks.java @@ -0,0 +1,245 @@ +/* + * 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.jdbc; + +import static org.assertj.core.api.Assertions.*; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import javax.sql.DataSource; + +import org.junit.platform.commons.annotation.Testable; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.annotation.Id; +import org.springframework.data.jdbc.core.JdbcAggregateTemplate; +import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration; +import org.springframework.data.relational.core.mapping.Embedded; +import org.springframework.data.relational.core.mapping.Table; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.ConnectionHolder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * Benchmarks for Composite Ids in Spring Data JDBC. + * + * @author Mark Paluch + */ +@Testable +public class CompositeIdBenchmarks extends BenchmarkSettings { + + @Configuration + static class BenchmarkConfiguration extends AbstractJdbcConfiguration { + + @Bean + NamedParameterJdbcTemplate namedParameterJdbcTemplate(DataSource dataSource) { + return new NamedParameterJdbcTemplate(dataSource); + } + + @Bean + DataSource dataSource() { + + return new EmbeddedDatabaseBuilder() // + .generateUniqueName(true) // + .setType(EmbeddedDatabaseType.HSQL) // + .setScriptEncoding("UTF-8") // + .ignoreFailedDrops(true) // + .addScript( + "classpath:/org.springframework.data.jdbc.core/CompositeIdAggregateTemplateHsqlIntegrationTests-hsql.sql") // + .build(); + } + } + + @State(Scope.Benchmark) + public static class BenchmarkState { + + AnnotationConfigApplicationContext context; + JdbcAggregateTemplate template; + NamedParameterJdbcTemplate named; + AtomicLong l = new AtomicLong(); + SimpleEntity alpha; + + @Setup + public void setup() throws SQLException { + + context = new AnnotationConfigApplicationContext(); + context.register(BenchmarkConfiguration.class); + context.refresh(); + context.start(); + + template = context.getBean(JdbcAggregateTemplate.class); + named = context.getBean(NamedParameterJdbcTemplate.class); + DataSource dataSource = context.getBean(DataSource.class); + + Connection connection = dataSource.getConnection(); + ConnectionHolder holder = new ConnectionHolder(connection, true); + holder.setSynchronizedWithTransaction(true); + TransactionSynchronizationManager.bindResource(dataSource, holder); + + alpha = template.insert(new SimpleEntity(new WrappedPk(l.incrementAndGet()), "alpha")); + } + + @TearDown + public void cleanup() { + context.close(); + } + } + + @Benchmark + public Object namedTemplate(BenchmarkState state) { + return state.named.query("SELECT * FROM SIMPLE_ENTITY WHERE id = :id", Map.of("id", state.alpha.wrappedPk.id), + (rs, rowNum) -> 1); + } + + @Benchmark + public Object jdbcTemplate(BenchmarkState state) { + return state.named.getJdbcOperations().query("SELECT * FROM SIMPLE_ENTITY WHERE id = " + state.alpha.wrappedPk.id, + (rs, rowNum) -> 1); + } + + @Benchmark + public Object baselineInsert(BenchmarkState state) { + return state.template.insert(new BaselineEntity(state.l.incrementAndGet(), "alpha")); + } + + @Benchmark + public Object loadBaselineEntity(BenchmarkState state) { + return state.template.findById(state.alpha.wrappedPk, BaselineEntity.class); + } + + @Benchmark + public Object insert(BenchmarkState state) { + return state.template.insert(new SimpleEntity(new WrappedPk(state.l.incrementAndGet()), "alpha")); + } + + @Benchmark + public Object loadSimpleEntity(BenchmarkState state) { + return state.template.findById(state.alpha.wrappedPk, SimpleEntity.class); + } + + @Benchmark + public Object saveAndLoadEntityWithList(BenchmarkState state) { + + WithList entity = state.template.insert(new WithList(new WrappedPk(state.l.incrementAndGet()), "alpha", + List.of(new Child("Romulus"), new Child("Remus")))); + + assertThat(entity.wrappedPk).isNotNull() // + .extracting(WrappedPk::id).isNotNull(); + + return state.template.findById(entity.wrappedPk, WithList.class); + } + + @Benchmark + public Object saveAndLoadSimpleEntityWithEmbeddedPk(BenchmarkState state) { + + SimpleEntityWithEmbeddedPk entity = state.template + .insert(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(state.l.incrementAndGet(), "x"), "alpha")); + + return state.template.findById(entity.embeddedPk, SimpleEntityWithEmbeddedPk.class); + } + + @Benchmark + public void deleteSingleSimpleEntityWithEmbeddedPk(BenchmarkState state) { + + List entities = (List) state.template + .insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"))); + + state.template.delete(entities.get(0)); + } + + @Benchmark + public void deleteMultipleSimpleEntityWithEmbeddedPk(BenchmarkState state) { + + List entities = (List) state.template + .insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"), + new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "y"), "beta"))); + + state.template.deleteAll(List.of(entities.get(1), entities.get(0))); + } + + @Benchmark + public void updateSingleSimpleEntityWithEmbeddedPk(BenchmarkState state) { + + List entities = (List) state.template + .insertAll(List.of(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha"), + new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "y"), "beta"), + new SimpleEntityWithEmbeddedPk(new EmbeddedPk(24L, "y"), "gamma"))); + + SimpleEntityWithEmbeddedPk updated = new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "ALPHA"); + state.template.save(updated); + + state.template.deleteAll(SimpleEntityWithEmbeddedPk.class); + } + + private record WrappedPk(Long id) { + } + + @Table("SIMPLE_ENTITY") + private record BaselineEntity( // + @Id Long id, // + String name // + ) { + } + + private record SimpleEntity( // + @Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) WrappedPk wrappedPk, // + String name // + ) { + } + + private record Child(String name) { + } + + private record WithList( // + @Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) WrappedPk wrappedPk, // + String name, List children) { + } + + private record EmbeddedPk(Long one, String two) { + } + + private record SimpleEntityWithEmbeddedPk( // + @Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) EmbeddedPk embeddedPk, // + String name // + ) { + } + + private record SingleReference( // + @Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) EmbeddedPk embeddedPk, // + String name, // + Child child) { + } + + private record WithListAndCompositeId( // + @Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) EmbeddedPk embeddedPk, // + String name, // + List child) { + } + +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java index 57dc1ff487..7b2116f39d 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java @@ -15,7 +15,16 @@ */ package org.springframework.data.jdbc.core; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Function; import java.util.stream.Collectors; @@ -176,9 +185,9 @@ private Identifier getParentKeys(DbAction.WithDependingOn action, JdbcConvert Object id = getParentId(action); + AggregatePath aggregatePath = context.getAggregatePath(action.getPropertyPath()); JdbcIdentifierBuilder identifier = JdbcIdentifierBuilder // - .forBackReferences(converter, context.getAggregatePath(action.getPropertyPath()), - getValueProvider(id, context.getAggregatePath(action.getPropertyPath()), converter)); + .forBackReferences(converter, aggregatePath, getIdMapper(id, aggregatePath, converter)); for (Map.Entry, Object> qualifier : action.getQualifiers() .entrySet()) { @@ -188,20 +197,17 @@ private Identifier getParentKeys(DbAction.WithDependingOn action, JdbcConvert return identifier.build(); } - static Function getValueProvider(Object idValue, AggregatePath path, JdbcConverter converter) { + static Function getIdMapper(Object idValue, AggregatePath path, JdbcConverter converter) { RelationalPersistentEntity entity = converter.getMappingContext() - .getPersistentEntity(path.getIdDefiningParentPath().getRequiredIdProperty().getType()); + .getPersistentEntity(path.getIdDefiningParentPath().getRequiredIdProperty()); - Function valueProvider = ap -> { - if (entity == null) { - return idValue; - } else { - PersistentPropertyPathAccessor propertyPathAccessor = entity.getPropertyPathAccessor(idValue); - return propertyPathAccessor.getProperty(ap.getRequiredPersistentPropertyPath()); - } - }; - return valueProvider; + if (entity == null) { + return aggregatePath -> idValue; + } + + PersistentPropertyPathAccessor propertyPathAccessor = entity.getPropertyPathAccessor(idValue); + return aggregatePath -> propertyPathAccessor.getProperty(aggregatePath.getRequiredPersistentPropertyPath()); } private Object getParentId(DbAction.WithDependingOn action) { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BindParameterNameSanitizer.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BindParameterNameSanitizer.java index 64213bd939..09dbcefe4a 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BindParameterNameSanitizer.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BindParameterNameSanitizer.java @@ -29,6 +29,14 @@ abstract class BindParameterNameSanitizer { private static final Pattern parameterPattern = Pattern.compile("\\W"); static String sanitize(String rawName) { - return parameterPattern.matcher(rawName).replaceAll(""); + + for (int i = 0; i < rawName.length(); i++) { + char c = rawName.charAt(i); + if ((c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '_') { + return parameterPattern.matcher(rawName).replaceAll(""); + } + } + + return rawName; } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcColumnTypes.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcColumnTypes.java index fa74b3b94f..0c678ef975 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcColumnTypes.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/JdbcColumnTypes.java @@ -24,21 +24,28 @@ import java.util.Map; import org.springframework.util.ClassUtils; +import org.springframework.util.ConcurrentLruCache; /** * Utility that determines the necessary type conversions between Java types used in the domain model and types * compatible with JDBC drivers. * * @author Jens Schauder + * @author Mark Paluch * @since 2.0 */ public enum JdbcColumnTypes { INSTANCE { + private final ConcurrentLruCache, Class> cache = new ConcurrentLruCache<>(64, this::doResolve); + @SuppressWarnings({ "unchecked", "rawtypes" }) public Class resolvePrimitiveType(Class type) { + return cache.get(type); + } + private Class doResolve(Class type) { return javaToDbType.entrySet().stream() // .filter(e -> e.getKey().isAssignableFrom(type)) // .map(e -> (Class) e.getValue()) // diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java index b1d74f1876..c7d644bdb7 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java @@ -454,15 +454,9 @@ private static Function getWrappedValueProvider(Function< AggregatePath aggregatePath) { AggregatePath idDefiningParentPath = aggregatePath.getIdDefiningParentPath(); - - if (!idDefiningParentPath.hasIdProperty()) { - return ap -> { - throw new IllegalStateException("This should never happen"); - }; - } - RelationalPersistentProperty idProperty = idDefiningParentPath.getRequiredIdProperty(); AggregatePath idPath = idProperty.isEntity() ? idDefiningParentPath.append(idProperty) : idDefiningParentPath; + return ap -> valueProvider.apply(idPath.append(ap)); } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java index 8ca457deb1..07ea6400ec 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java @@ -16,6 +16,7 @@ package org.springframework.data.jdbc.core.convert; import java.util.*; +import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -118,7 +119,7 @@ public class SqlGenerator { /** * Create a basic select structure with all the necessary joins - * + * * @param table the table to base the select on * @param pathFilter a filter for excluding paths from the select. All paths for which the filter returns * {@literal true} will be skipped when determining columns to select. @@ -188,6 +189,8 @@ private Condition getSubselectCondition(AggregatePath path, Table subSelectTable = Table.create(parentPathTableInfo.qualifiedTableName()); Map selectFilterColumns = new TreeMap<>(); + + // TODO: cannot we simply pass on the columnInfos? parentPathTableInfo.effectiveIdColumnInfos().forEach( // (ap, ci) -> // selectFilterColumns.put(ap, subSelectTable.column(ci.name())) // @@ -471,6 +474,8 @@ String createDeleteAllSql(@Nullable PersistentPropertyPath path) { + // TODO: When deleting by path, why do we expect the where-value to be id and not named after the path? + // See SqlGeneratorEmbeddedUnitTests.deleteByPath return createDeleteByPathAndCriteria(mappingContext.getAggregatePath(path), this::equalityCondition); } @@ -490,12 +495,10 @@ String createDeleteInByPath(PersistentPropertyPath */ private Condition inCondition(Map columnMap) { - List columns = List.copyOf(columnMap.values()); + Collection columns = columnMap.values(); - if (columns.size() == 1) { - return Conditions.in(columns.get(0), getBindMarker(IDS_SQL_PARAMETER)); - } - return Conditions.in(TupleExpression.create(columns), getBindMarker(IDS_SQL_PARAMETER)); + return Conditions.in(columns.size() == 1 ? columns.iterator().next() : TupleExpression.create(columns), + getBindMarker(IDS_SQL_PARAMETER)); } /** @@ -504,17 +507,13 @@ private Condition inCondition(Map columnMap) { */ private Condition equalityCondition(Map columnMap) { - AggregatePath.ColumnInfos idColumnInfos = mappingContext.getAggregatePath(entity).getTableInfo().idColumnInfos(); + Assert.isTrue(!columnMap.isEmpty(), "Column map must not be empty"); - Condition result = null; - for (Map.Entry entry : columnMap.entrySet()) { - BindMarker bindMarker = getBindMarker(idColumnInfos.get(entry.getKey()).name()); - Comparison singleCondition = entry.getValue().isEqualTo(bindMarker); + AggregatePath.ColumnInfos idColumnInfos = mappingContext.getAggregatePath(entity).getTableInfo().idColumnInfos(); - result = result == null ? singleCondition : result.and(singleCondition); - } - Assert.state(result != null, "We need at least one condition"); - return result; + return createPredicate(columnMap, (aggregatePath, column) -> { + return column.isEqualTo(getBindMarker(idColumnInfos.get(aggregatePath).name())); + }); } /** @@ -522,11 +521,20 @@ private Condition equalityCondition(Map columnMap) { * {@literal IS NOT NULL AND IS NOT NULL ... } */ private Condition isNotNullCondition(Map columnMap) { + return createPredicate(columnMap, (aggregatePath, column) -> column.isNotNull()); + } + + /** + * Constructs a function for constructing where a condition. The where condition will be of the form + * {@literal IS NOT NULL AND IS NOT NULL ... } + */ + private static Condition createPredicate(Map columnMap, + BiFunction conditionFunction) { Condition result = null; - for (Column column : columnMap.values()) { - Condition singleCondition = column.isNotNull(); + for (Map.Entry entry : columnMap.entrySet()) { + Condition singleCondition = conditionFunction.apply(entry.getKey(), entry.getValue()); result = result == null ? singleCondition : result.and(singleCondition); } Assert.state(result != null, "We need at least one condition"); @@ -534,14 +542,19 @@ private Condition isNotNullCondition(Map columnMap) { } private String createFindOneSql() { - return render(selectBuilder().where(equalityIdWhereCondition()).build()); } private Condition equalityIdWhereCondition() { + return equalityIdWhereCondition(getIdColumns()); + } + + private Condition equalityIdWhereCondition(Iterable columns) { + + Assert.isTrue(columns.iterator().hasNext(), "Identifier columns must not be empty"); Condition aggregate = null; - for (Column column : getIdColumns()) { + for (Column column : columns) { Comparison condition = column.isEqualTo(getBindMarker(column.getName())); aggregate = aggregate == null ? condition : aggregate.and(condition); @@ -766,19 +779,13 @@ Join getJoin(AggregatePath path) { Table parentTable = sqlContext.getTable(idDefiningParentPath); AggregatePath.ColumnInfos idColumnInfos = idDefiningParentPath.getTableInfo().idColumnInfos(); - final Condition[] joinCondition = { null }; - backRefColumnInfos.forEach((ap, ci) -> { - - Condition elementalCondition = currentTable.column(ci.name()) - .isEqualTo(parentTable.column(idColumnInfos.get(ap).name())); - joinCondition[0] = joinCondition[0] == null ? elementalCondition : joinCondition[0].and(elementalCondition); - }); + Condition joinCondition = backRefColumnInfos.reduce(Conditions.unrestricted(), (aggregatePath, columnInfo) -> { - return new Join( // - currentTable, // - joinCondition[0] // - ); + return currentTable.column(columnInfo.name()) + .isEqualTo(parentTable.column(idColumnInfos.get(aggregatePath).name())); + }, Condition::and); + return new Join(currentTable, joinCondition); } private String createFindAllInListSql() { @@ -917,6 +924,8 @@ private String createDeleteByPathAndCriteria(AggregatePath path, Map columns = new TreeMap<>(); AggregatePath.ColumnInfos columnInfos = path.getTableInfo().backReferenceColumnInfos(); + + // TODO: cannot we simply pass on the columnInfos? columnInfos.forEach((ag, ci) -> columns.put(ag, table.column(ci.name()))); if (isFirstNonRoot(path)) { @@ -970,6 +979,10 @@ private Table getTable() { */ private Column getSingleNonNullColumn() { + // getColumn() is slightly different from the code in any(…). Why? + // AggregatePath.ColumnInfo columnInfo = path.getColumnInfo(); + // return getTable(path).column(columnInfo.name()).as(columnInfo.alias()); + AggregatePath.ColumnInfos columnInfos = mappingContext.getAggregatePath(entity).getTableInfo().idColumnInfos(); return columnInfos.any((ap, ci) -> sqlContext.getTable(columnInfos.fullPath(ap)).column(ci.name()).as(ci.alias())); } @@ -977,10 +990,9 @@ private Column getSingleNonNullColumn() { private List getIdColumns() { AggregatePath.ColumnInfos columnInfos = mappingContext.getAggregatePath(entity).getTableInfo().idColumnInfos(); - List result = new ArrayList<>(columnInfos.size()); - columnInfos.forEach((ap, ci) -> result.add(sqlContext.getColumn(columnInfos.fullPath(ap)))); - return result; + return columnInfos + .toColumnList((aggregatePath, columnInfo) -> sqlContext.getColumn(columnInfos.fullPath(aggregatePath))); } private Column getVersionColumn() { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlIdentifierParameterSource.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlIdentifierParameterSource.java index 78ff82deb2..209aa7108d 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlIdentifierParameterSource.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlIdentifierParameterSource.java @@ -35,8 +35,8 @@ */ class SqlIdentifierParameterSource extends AbstractSqlParameterSource { - private final Set identifiers = new HashSet<>(); - private final Map namesToValues = new HashMap<>(); + private final Set identifiers = new HashSet<>(16, 1f); + private final Map namesToValues = new HashMap<>(16, 1f); @Override public boolean hasValue(String paramName) { @@ -73,7 +73,7 @@ void addAll(SqlIdentifierParameterSource others) { for (SqlIdentifier identifier : others.getIdentifiers()) { - String name = BindParameterNameSanitizer.sanitize( identifier.getReference()); + String name = BindParameterNameSanitizer.sanitize(identifier.getReference()); addValue(identifier, others.getValue(name), others.getSqlType(name)); } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java index 0fdf3d5be0..8a0d853fd9 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java @@ -17,10 +17,10 @@ import java.sql.SQLType; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.function.BiFunction; -import java.util.function.Function; import java.util.function.Predicate; import org.springframework.data.jdbc.core.mapping.JdbcValue; @@ -34,9 +34,7 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.sql.SqlIdentifier; -import org.springframework.jdbc.support.JdbcUtils; import org.springframework.lang.Nullable; -import org.springframework.util.Assert; /** * Creates the {@link SqlIdentifierParameterSource} for various SQL operations, dialect identifier processing rules and @@ -45,9 +43,11 @@ * @author Jens Schauder * @author Chirag Tailor * @author Mikhail Polivakha + * @author Mark Paluch * @since 2.4 */ public class SqlParametersFactory { + private final RelationalMappingContext context; private final JdbcConverter converter; @@ -119,24 +119,20 @@ SqlIdentifierParameterSource forUpdate(T instance, Class domainType) { */ SqlIdentifierParameterSource forQueryById(Object id, Class domainType) { - SqlIdentifierParameterSource parameterSource = new SqlIdentifierParameterSource(); - - RelationalPersistentEntity entity = getRequiredPersistentEntity(domainType); - RelationalPersistentProperty singleIdProperty = entity.getRequiredIdProperty(); + return doWithIdentifiers(domainType, (columns, idProperty, complexId) -> { - RelationalPersistentEntity complexId = context.getPersistentEntity(singleIdProperty); + SqlIdentifierParameterSource parameterSource = new SqlIdentifierParameterSource(); + BiFunction valueExtractor = getIdMapper(complexId); - Function valueExtractor = complexId == null ? ap -> id - : ap -> complexId.getPropertyPathAccessor(id).getProperty(ap.getRequiredPersistentPropertyPath()); + columns.forEach((ap, ci) -> addConvertedPropertyValue( // + parameterSource, // + ap.getRequiredLeafProperty(), // + valueExtractor.apply(id, ap), // + ci.name() // + )); - context.getAggregatePath(entity).getTableInfo().idColumnInfos() // - .forEach((ap, ci) -> addConvertedPropertyValue( // - parameterSource, // - ap.getRequiredLeafProperty(), // - valueExtractor.apply(ap), // - ci.name() // - )); - return parameterSource; + return parameterSource; + }); } /** @@ -149,29 +145,44 @@ SqlIdentifierParameterSource forQueryById(Object id, Class domainType) { */ SqlIdentifierParameterSource forQueryByIds(Iterable ids, Class domainType) { - SqlIdentifierParameterSource parameterSource = new SqlIdentifierParameterSource(); + return doWithIdentifiers(domainType, (columns, idProperty, complexId) -> { - RelationalPersistentEntity entity = context.getRequiredPersistentEntity(domainType); - RelationalPersistentProperty singleIdProperty = entity.getRequiredIdProperty(); - RelationalPersistentEntity complexId = context.getPersistentEntity(singleIdProperty); - AggregatePath.ColumnInfos idColumnInfos = context.getAggregatePath(entity).getTableInfo().idColumnInfos(); + SqlIdentifierParameterSource parameterSource = new SqlIdentifierParameterSource(); - BiFunction valueExtractor = complexId == null ? (id, ap) -> id - : (id, ap) -> complexId.getPropertyPathAccessor(id).getProperty(ap.getRequiredPersistentPropertyPath()); + BiFunction valueExtractor = getIdMapper(complexId); - List parameterValues = new ArrayList<>(); - for (Object id : ids) { + List parameterValues = new ArrayList<>(ids instanceof Collection c ? c.size() : 16); + for (Object id : ids) { - List tupleList = new ArrayList<>(); - idColumnInfos.forEach((ap, ci) -> { - tupleList.add(valueExtractor.apply(id, ap)); - }); - parameterValues.add(tupleList.toArray(new Object[0])); - } + Object[] tupleList = new Object[columns.size()]; - parameterSource.addValue(SqlGenerator.IDS_SQL_PARAMETER, parameterValues); + int i = 0; + for (AggregatePath path : columns.paths()) { + tupleList[i++] = valueExtractor.apply(id, path); + } - return parameterSource; + parameterValues.add(tupleList); + } + + parameterSource.addValue(SqlGenerator.IDS_SQL_PARAMETER, parameterValues); + return parameterSource; + }); + } + + private T doWithIdentifiers(Class domainType, IdentifierCallback callback) { + + RelationalPersistentEntity entity = context.getRequiredPersistentEntity(domainType); + RelationalPersistentProperty idProperty = entity.getRequiredIdProperty(); + RelationalPersistentEntity complexId = context.getPersistentEntity(idProperty); + AggregatePath.ColumnInfos columns = context.getAggregatePath(entity).getTableInfo().idColumnInfos(); + + return callback.doWithIdentifiers(columns, idProperty, complexId); + } + + interface IdentifierCallback { + + T doWithIdentifiers(AggregatePath.ColumnInfos columns, RelationalPersistentProperty idProperty, + RelationalPersistentEntity complexId); } /** @@ -191,6 +202,19 @@ SqlIdentifierParameterSource forQueryByIdentifier(Identifier identifier) { return parameterSource; } + private BiFunction getIdMapper(@Nullable RelationalPersistentEntity complexId) { + + if (complexId == null) { + return (id, aggregatePath) -> id; + } + + return (id, aggregatePath) -> { + + PersistentPropertyAccessor accessor = complexId.getPropertyAccessor(id); + return accessor.getProperty(aggregatePath.getRequiredLeafProperty()); + }; + } + private void addConvertedPropertyValue(SqlIdentifierParameterSource parameterSource, RelationalPersistentProperty property, @Nullable Object value, SqlIdentifier name) { @@ -219,28 +243,6 @@ private void addConvertedValue(SqlIdentifierParameterSource parameterSource, @Nu jdbcValue.getJdbcType().getVendorTypeNumber()); } - private void addConvertedPropertyValuesAsList(SqlIdentifierParameterSource parameterSource, - RelationalPersistentProperty property, Iterable values) { - - List convertedIds = new ArrayList<>(); - JdbcValue jdbcValue = null; - for (Object id : values) { - - Class columnType = converter.getColumnType(property); - SQLType sqlType = converter.getTargetSqlType(property); - - jdbcValue = converter.writeJdbcValue(id, columnType, sqlType); - convertedIds.add(jdbcValue.getValue()); - } - - Assert.state(jdbcValue != null, "JdbcValue must be not null at this point; Please report this as a bug"); - - SQLType jdbcType = jdbcValue.getJdbcType(); - int typeNumber = jdbcType == null ? JdbcUtils.TYPE_UNKNOWN : jdbcType.getVendorTypeNumber(); - - parameterSource.addValue(SqlGenerator.IDS_SQL_PARAMETER, convertedIds, typeNumber); - } - @SuppressWarnings("unchecked") private RelationalPersistentEntity getRequiredPersistentEntity(Class domainType) { return (RelationalPersistentEntity) context.getRequiredPersistentEntity(domainType); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java index f81cc62260..d3968b18a0 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcDeleteQueryCreator.java @@ -17,6 +17,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.function.Consumer; import org.springframework.data.domain.Sort; import org.springframework.data.jdbc.core.convert.JdbcConverter; @@ -45,6 +46,7 @@ import org.springframework.data.relational.repository.query.RelationalParameterAccessor; import org.springframework.data.relational.repository.query.RelationalQueryCreator; import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.data.util.Predicates; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -108,7 +110,7 @@ protected List complete(@Nullable Criteria criteria, Sort sor // create delete relation queries List deleteChain = new ArrayList<>(); - deleteRelations(deleteChain, entity, select); + deleteRelations(entity, select, deleteChain::add); // crate delete query DeleteWhere deleteBuilder = StatementBuilder.delete(table); @@ -126,41 +128,39 @@ protected List complete(@Nullable Criteria criteria, Sort sor return queries; } - private void deleteRelations(List deleteChain, RelationalPersistentEntity entity, Select parentSelect) { + private void deleteRelations(RelationalPersistentEntity entity, Select parentSelect, + Consumer deleteConsumer) { for (PersistentPropertyPath path : context - .findPersistentPropertyPaths(entity.getType(), p -> true)) { + .findPersistentPropertyPaths(entity.getType(), Predicates.isTrue())) { AggregatePath aggregatePath = context.getAggregatePath(path); - if (aggregatePath.isEmbedded()) { + if (aggregatePath.isEmbedded() || !aggregatePath.isEntity()) { continue; } - if (aggregatePath.isEntity()) { + SqlContext sqlContext = new SqlContext(); - SqlContext sqlContext = new SqlContext(); + // MariaDB prior to 11.6 does not support aliases for delete statements + Table table = sqlContext.getUnaliasedTable(aggregatePath); - // MariaDB prior to 11.6 does not support aliases for delete statements - Table table = sqlContext.getUnaliasedTable(aggregatePath); + List reverseColumns = aggregatePath.getTableInfo().backReferenceColumnInfos().toColumnList(table); + Expression expression = Expressions.of(reverseColumns); - List reverseColumns = aggregatePath.getTableInfo().backReferenceColumnInfos().toColumnList(table); - Expression expression = Expressions.of(reverseColumns); + Condition inCondition = Conditions.in(expression, parentSelect); - Condition inCondition = Conditions.in(expression, parentSelect); + List parentIdColumns = aggregatePath.getIdDefiningParentPath().getTableInfo().idColumnInfos() + .toColumnList(table); - List parentIdColumns = aggregatePath.getIdDefiningParentPath().getTableInfo().idColumnInfos() - .toColumnList(table); + Select select = StatementBuilder.select( // + parentIdColumns // + ).from(table) // + .where(inCondition) // + .build(); + deleteRelations(aggregatePath.getLeafEntity(), select, deleteConsumer); - Select select = StatementBuilder.select( // - parentIdColumns // - ).from(table) // - .where(inCondition) // - .build(); - deleteRelations(deleteChain, aggregatePath.getLeafEntity(), select); - - deleteChain.add(StatementBuilder.delete(table).where(inCondition).build()); - } + deleteConsumer.accept(StatementBuilder.delete(table).where(inCondition).build()); } } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/support/JdbcUtil.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/support/JdbcUtil.java index 5fec40f1a9..c091191bb5 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/support/JdbcUtil.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/support/JdbcUtil.java @@ -27,7 +27,7 @@ import java.util.Map; import org.springframework.jdbc.support.JdbcUtils; -import org.springframework.util.Assert; +import org.springframework.util.ConcurrentLruCache; /** * Contains methods dealing with the quirks of JDBC, independent of any Entity, Aggregate or Repository abstraction. @@ -58,7 +58,10 @@ public String toString() { return getName(); } }; + private static final Map, SQLType> sqlTypeMappings = new HashMap<>(); + private static ConcurrentLruCache, SQLType> sqlTypeCache = new ConcurrentLruCache<>(64, + JdbcUtil::doGetSqlType); static { @@ -97,9 +100,10 @@ private JdbcUtil() { * @return a matching {@link SQLType} or {@link #TYPE_UNKNOWN}. */ public static SQLType targetSqlTypeFor(Class type) { + return sqlTypeCache.get(type); + } - Assert.notNull(type, "Type must not be null"); - + private static SQLType doGetSqlType(Class type) { return sqlTypeMappings.keySet().stream() // .filter(k -> k.isAssignableFrom(type)) // .findFirst() // diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java index 9354f9423a..a5bbba66af 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java @@ -45,7 +45,7 @@ */ @IntegrationTest @EnabledOnDatabase(DatabaseType.HSQL) -public class CompositeIdAggregateTemplateHsqlIntegrationTests { +class CompositeIdAggregateTemplateHsqlIntegrationTests { @Autowired JdbcAggregateOperations template; @Autowired private NamedParameterJdbcOperations namedParameterJdbcTemplate; diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java index ee7a75eddc..bf0b41a044 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java @@ -168,7 +168,7 @@ PersistentPropertyPath getPersistentPropertyPath(S Identifier createBackRef(long value) { return JdbcIdentifierBuilder.forBackReferences(converter, toAggregatePath("content"), - JdbcAggregateChangeExecutionContext.getValueProvider(value, toAggregatePath("content"), converter)).build(); + JdbcAggregateChangeExecutionContext.getIdMapper(value, toAggregatePath("content"), converter)).build(); } PersistentPropertyPath toPath(String path) { diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java index e6bf1cb5c5..afb0f224c0 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java @@ -259,7 +259,7 @@ PersistentPropertyPath getPersistentPropertyPath(S Identifier createBackRef(long value) { return forBackReferences(converter, toAggregatePath("content"), - getValueProvider(value, toAggregatePath("content"), converter)).build(); + getIdMapper(value, toAggregatePath("content"), converter)).build(); } PersistentPropertyPath toPath(String path) { diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java index f6a619af12..78fddff762 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java @@ -38,7 +38,7 @@ * * @author Jens Schauder */ -public class JdbcIdentifierBuilderUnitTests { +class JdbcIdentifierBuilderUnitTests { JdbcMappingContext context = new JdbcMappingContext(); JdbcConverter converter = new MappingJdbcConverter(context, (identifier, path) -> { diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java index 4a5973c86e..9a011a8802 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorEmbeddedUnitTests.java @@ -17,10 +17,10 @@ import static java.util.Collections.*; import static org.assertj.core.api.Assertions.*; -import static org.assertj.core.api.SoftAssertions.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; + import org.springframework.data.annotation.Id; import org.springframework.data.jdbc.core.PersistentPropertyPathTestUtils; import org.springframework.data.jdbc.core.mapping.AggregateReference; @@ -46,7 +46,7 @@ */ class SqlGeneratorEmbeddedUnitTests { - private final RelationalMappingContext context = new JdbcMappingContext(); + private RelationalMappingContext context = new JdbcMappingContext(); private JdbcConverter converter = new MappingJdbcConverter(context, (identifier, path) -> { throw new UnsupportedOperationException(); }); @@ -65,25 +65,22 @@ SqlGenerator createSqlGenerator(Class type) { @Test // DATAJDBC-111 void findOne() { - final String sql = sqlGenerator.getFindOne(); - - assertSoftly(softly -> { - - softly.assertThat(sql).startsWith("SELECT") // - .contains("dummy_entity.id1 AS id1") // - .contains("dummy_entity.test AS test") // - .contains("dummy_entity.attr1 AS attr1") // - .contains("dummy_entity.attr2 AS attr2") // - .contains("dummy_entity.prefix2_attr1 AS prefix2_attr1") // - .contains("dummy_entity.prefix2_attr2 AS prefix2_attr2") // - .contains("dummy_entity.prefix_test AS prefix_test") // - .contains("dummy_entity.prefix_attr1 AS prefix_attr1") // - .contains("dummy_entity.prefix_attr2 AS prefix_attr2") // - .contains("dummy_entity.prefix_prefix2_attr1 AS prefix_prefix2_attr1") // - .contains("dummy_entity.prefix_prefix2_attr2 AS prefix_prefix2_attr2") // - .contains("WHERE dummy_entity.id1 = :id") // - .doesNotContain("JOIN").doesNotContain("embeddable"); // - }); + String sql = sqlGenerator.getFindOne(); + + assertThat(sql).startsWith("SELECT") // + .contains("dummy_entity.id1 AS id1") // + .contains("dummy_entity.test AS test") // + .contains("dummy_entity.attr1 AS attr1") // + .contains("dummy_entity.attr2 AS attr2") // + .contains("dummy_entity.prefix2_attr1 AS prefix2_attr1") // + .contains("dummy_entity.prefix2_attr2 AS prefix2_attr2") // + .contains("dummy_entity.prefix_test AS prefix_test") // + .contains("dummy_entity.prefix_attr1 AS prefix_attr1") // + .contains("dummy_entity.prefix_attr2 AS prefix_attr2") // + .contains("dummy_entity.prefix_prefix2_attr1 AS prefix_prefix2_attr1") // + .contains("dummy_entity.prefix_prefix2_attr2 AS prefix_prefix2_attr2") // + .contains("WHERE dummy_entity.id1 = :id") // + .doesNotContain("JOIN").doesNotContain("embeddable"); // } @Test // GH-574 @@ -93,13 +90,10 @@ void findOneWrappedId() { String sql = sqlGenerator.getFindOne(); - assertSoftly(softly -> { - - softly.assertThat(sql).startsWith("SELECT") // - .contains("with_wrapped_id.name AS name") // - .contains("with_wrapped_id.id") // - .contains("WHERE with_wrapped_id.id = :id"); - }); + assertThat(sql).startsWith("SELECT") // + .contains("with_wrapped_id.name AS name") // + .contains("with_wrapped_id.id") // + .contains("WHERE with_wrapped_id.id = :id"); } @Test // GH-574 @@ -109,16 +103,13 @@ void findOneEmbeddedId() { String sql = sqlGenerator.getFindOne(); - assertSoftly(softly -> { - - softly.assertThat(sql).startsWith("SELECT") // - .contains("with_embedded_id.name AS name") // - .contains("with_embedded_id.one") // - .contains("with_embedded_id.two") // - .contains(" WHERE ") // - .contains("with_embedded_id.one = :one") // - .contains("with_embedded_id.two = :two"); - }); + assertThat(sql).startsWith("SELECT") // + .contains("with_embedded_id.name AS name") // + .contains("with_embedded_id.one") // + .contains("with_embedded_id.two") // + .contains(" WHERE ") // + .contains("with_embedded_id.one = :one") // + .contains("with_embedded_id.two = :two"); } @Test // GH-574 @@ -128,13 +119,10 @@ void deleteByIdEmbeddedId() { String sql = sqlGenerator.getDeleteById(); - assertSoftly(softly -> { - - softly.assertThat(sql).startsWith("DELETE") // - .contains(" WHERE ") // - .contains("with_embedded_id.one = :one") // - .contains("with_embedded_id.two = :two"); - }); + assertThat(sql).startsWith("DELETE") // + .contains(" WHERE ") // + .contains("with_embedded_id.one = :one") // + .contains("with_embedded_id.two = :two"); } @Test // GH-574 @@ -144,12 +132,9 @@ void deleteByIdInEmbeddedId() { String sql = sqlGenerator.getDeleteByIdIn(); - assertSoftly(softly -> { - - softly.assertThat(sql).startsWith("DELETE") // - .contains(" WHERE ") // - .contains("(with_embedded_id.one, with_embedded_id.two) IN (:ids)"); - }); + assertThat(sql).startsWith("DELETE") // + .contains(" WHERE ") // + .contains("(with_embedded_id.one, with_embedded_id.two) IN (:ids)"); } @Test // GH-574 @@ -161,12 +146,9 @@ void deleteByPathEmbeddedId() { String sql = sqlGenerator.createDeleteByPath(path); - assertSoftly(softly -> { - - softly.assertThat(sql).startsWith("DELETE FROM other_entity WHERE") // - .contains("other_entity.with_embedded_id_and_reference_one = :one") // - .contains("other_entity.with_embedded_id_and_reference_two = :two"); - }); + assertThat(sql).startsWith("DELETE FROM other_entity WHERE") // + .contains("other_entity.with_embedded_id_and_reference_one = :one") // + .contains("other_entity.with_embedded_id_and_reference_two = :two"); } @Test // GH-574 @@ -178,13 +160,10 @@ void deleteInByPathEmbeddedId() { String sql = sqlGenerator.createDeleteInByPath(path); - assertSoftly(softly -> { - - softly.assertThat(sql).startsWith("DELETE FROM other_entity WHERE") // - .contains(" WHERE ") // - .contains( - "(other_entity.with_embedded_id_and_reference_one, other_entity.with_embedded_id_and_reference_two) IN (:ids)"); - }); + assertThat(sql).startsWith("DELETE FROM other_entity WHERE") // + .contains(" WHERE ") // + .contains( + "(other_entity.with_embedded_id_and_reference_one, other_entity.with_embedded_id_and_reference_two) IN (:ids)"); } @Test // GH-574 @@ -194,13 +173,10 @@ void updateWithEmbeddedId() { String sql = sqlGenerator.getUpdate(); - assertSoftly(softly -> { - - softly.assertThat(sql).startsWith("UPDATE") // - .contains(" WHERE ") // - .contains("with_embedded_id.one = :one") // - .contains("with_embedded_id.two = :two"); - }); + assertThat(sql).startsWith("UPDATE") // + .contains(" WHERE ") // + .contains("with_embedded_id.one = :one") // + .contains("with_embedded_id.two = :two"); } @Test // GH-574 @@ -210,36 +186,30 @@ void existsByIdEmbeddedId() { String sql = sqlGenerator.getExists(); - assertSoftly(softly -> { - - softly.assertThat(sql).startsWith("SELECT COUNT") // - .contains(" WHERE ") // - .contains("with_embedded_id.one = :one") // - .contains("with_embedded_id.two = :two"); - }); + assertThat(sql).startsWith("SELECT COUNT") // + .contains(" WHERE ") // + .contains("with_embedded_id.one = :one") // + .contains("with_embedded_id.two = :two"); } @Test // DATAJDBC-111 void findAll() { - final String sql = sqlGenerator.getFindAll(); - - assertSoftly(softly -> { - - softly.assertThat(sql).startsWith("SELECT") // - .contains("dummy_entity.id1 AS id1") // - .contains("dummy_entity.test AS test") // - .contains("dummy_entity.attr1 AS attr1") // - .contains("dummy_entity.attr2 AS attr2") // - .contains("dummy_entity.prefix2_attr1 AS prefix2_attr1") // - .contains("dummy_entity.prefix2_attr2 AS prefix2_attr2") // - .contains("dummy_entity.prefix_test AS prefix_test") // - .contains("dummy_entity.prefix_attr1 AS prefix_attr1") // - .contains("dummy_entity.prefix_attr2 AS prefix_attr2") // - .contains("dummy_entity.prefix_prefix2_attr1 AS prefix_prefix2_attr1") // - .contains("dummy_entity.prefix_prefix2_attr2 AS prefix_prefix2_attr2") // - .doesNotContain("JOIN") // - .doesNotContain("embeddable"); - }); + String sql = sqlGenerator.getFindAll(); + + assertThat(sql).startsWith("SELECT") // + .contains("dummy_entity.id1 AS id1") // + .contains("dummy_entity.test AS test") // + .contains("dummy_entity.attr1 AS attr1") // + .contains("dummy_entity.attr2 AS attr2") // + .contains("dummy_entity.prefix2_attr1 AS prefix2_attr1") // + .contains("dummy_entity.prefix2_attr2 AS prefix2_attr2") // + .contains("dummy_entity.prefix_test AS prefix_test") // + .contains("dummy_entity.prefix_attr1 AS prefix_attr1") // + .contains("dummy_entity.prefix_attr2 AS prefix_attr2") // + .contains("dummy_entity.prefix_prefix2_attr1 AS prefix_prefix2_attr1") // + .contains("dummy_entity.prefix_prefix2_attr2 AS prefix_prefix2_attr2") // + .doesNotContain("JOIN") // + .doesNotContain("embeddable"); } @Test // DATAJDBC-111 @@ -247,23 +217,20 @@ void findAllInList() { String sql = sqlGenerator.getFindAllInList(); - assertSoftly(softly -> { - - softly.assertThat(sql).startsWith("SELECT") // - .contains("dummy_entity.id1 AS id1") // - .contains("dummy_entity.test AS test") // - .contains("dummy_entity.attr1 AS attr1") // - .contains("dummy_entity.attr2 AS attr2").contains("dummy_entity.prefix2_attr1 AS prefix2_attr1") // - .contains("dummy_entity.prefix2_attr2 AS prefix2_attr2") // - .contains("dummy_entity.prefix_test AS prefix_test") // - .contains("dummy_entity.prefix_attr1 AS prefix_attr1") // - .contains("dummy_entity.prefix_attr2 AS prefix_attr2") // - .contains("dummy_entity.prefix_prefix2_attr1 AS prefix_prefix2_attr1") // - .contains("dummy_entity.prefix_prefix2_attr2 AS prefix_prefix2_attr2") // - .contains("WHERE dummy_entity.id1 IN (:ids)") // - .doesNotContain("JOIN") // - .doesNotContain("embeddable"); - }); + assertThat(sql).startsWith("SELECT") // + .contains("dummy_entity.id1 AS id1") // + .contains("dummy_entity.test AS test") // + .contains("dummy_entity.attr1 AS attr1") // + .contains("dummy_entity.attr2 AS attr2").contains("dummy_entity.prefix2_attr1 AS prefix2_attr1") // + .contains("dummy_entity.prefix2_attr2 AS prefix2_attr2") // + .contains("dummy_entity.prefix_test AS prefix_test") // + .contains("dummy_entity.prefix_attr1 AS prefix_attr1") // + .contains("dummy_entity.prefix_attr2 AS prefix_attr2") // + .contains("dummy_entity.prefix_prefix2_attr1 AS prefix_prefix2_attr1") // + .contains("dummy_entity.prefix_prefix2_attr2 AS prefix_prefix2_attr2") // + .contains("WHERE dummy_entity.id1 IN (:ids)") // + .doesNotContain("JOIN") // + .doesNotContain("embeddable"); } @Test // GH-574 @@ -273,14 +240,11 @@ void findAllInListEmbeddedId() { String sql = sqlGenerator.getFindAllInList(); - assertSoftly(softly -> { - - softly.assertThat(sql).startsWith("SELECT") // - .contains("with_embedded_id.name AS name") // - .contains("with_embedded_id.one") // - .contains("with_embedded_id.two") // - .contains(" WHERE (with_embedded_id.one, with_embedded_id.two) IN (:ids)"); - }); + assertThat(sql).startsWith("SELECT") // + .contains("with_embedded_id.name AS name") // + .contains("with_embedded_id.one") // + .contains("with_embedded_id.two") // + .contains(" WHERE (with_embedded_id.one, with_embedded_id.two) IN (:ids)"); } @Test // GH-574 @@ -290,61 +254,52 @@ void findOneWithReference() { String sql = sqlGenerator.getFindOne(); - assertSoftly(softly -> { - - softly.assertThat(sql).startsWith("SELECT") // - .contains(" LEFT OUTER JOIN other_entity other ") // - .contains(" ON ") // - .contains(" other.with_embedded_id_and_reference_one = with_embedded_id_and_reference.one ") // - .contains(" other.with_embedded_id_and_reference_two = with_embedded_id_and_reference.two ") // - .contains(" WHERE ") // - .contains("with_embedded_id_and_reference.one = :one") // - .contains("with_embedded_id_and_reference.two = :two"); - }); + assertThat(sql).startsWith("SELECT") // + .contains(" LEFT OUTER JOIN other_entity other ") // + .contains(" ON ") // + .contains(" other.with_embedded_id_and_reference_one = with_embedded_id_and_reference.one ") // + .contains(" other.with_embedded_id_and_reference_two = with_embedded_id_and_reference.two ") // + .contains(" WHERE ") // + .contains("with_embedded_id_and_reference.one = :one") // + .contains("with_embedded_id_and_reference.two = :two"); } @Test // DATAJDBC-111 void insert() { - final String sql = sqlGenerator.getInsert(emptySet()); - - assertSoftly(softly -> { - - softly.assertThat(sql) // - .startsWith("INSERT INTO") // - .contains("dummy_entity") // - .contains(":test") // - .contains(":attr1") // - .contains(":attr2") // - .contains(":prefix2_attr1") // - .contains(":prefix2_attr2") // - .contains(":prefix_test") // - .contains(":prefix_attr1") // - .contains(":prefix_attr2") // - .contains(":prefix_prefix2_attr1") // - .contains(":prefix_prefix2_attr2"); - }); + String sql = sqlGenerator.getInsert(emptySet()); + + assertThat(sql) // + .startsWith("INSERT INTO") // + .contains("dummy_entity") // + .contains(":test") // + .contains(":attr1") // + .contains(":attr2") // + .contains(":prefix2_attr1") // + .contains(":prefix2_attr2") // + .contains(":prefix_test") // + .contains(":prefix_attr1") // + .contains(":prefix_attr2") // + .contains(":prefix_prefix2_attr1") // + .contains(":prefix_prefix2_attr2"); } @Test // DATAJDBC-111 void update() { - final String sql = sqlGenerator.getUpdate(); - - assertSoftly(softly -> { - - softly.assertThat(sql) // - .startsWith("UPDATE") // - .contains("dummy_entity") // - .contains("test = :test") // - .contains("attr1 = :attr1") // - .contains("attr2 = :attr2") // - .contains("prefix2_attr1 = :prefix2_attr1") // - .contains("prefix2_attr2 = :prefix2_attr2") // - .contains("prefix_test = :prefix_test") // - .contains("prefix_attr1 = :prefix_attr1") // - .contains("prefix_attr2 = :prefix_attr2") // - .contains("prefix_prefix2_attr1 = :prefix_prefix2_attr1") // - .contains("prefix_prefix2_attr2 = :prefix_prefix2_attr2"); - }); + String sql = sqlGenerator.getUpdate(); + + assertThat(sql) // + .startsWith("UPDATE") // + .contains("dummy_entity") // + .contains("test = :test") // + .contains("attr1 = :attr1") // + .contains("attr2 = :attr2") // + .contains("prefix2_attr1 = :prefix2_attr1") // + .contains("prefix2_attr2 = :prefix2_attr2") // + .contains("prefix_test = :prefix_test") // + .contains("prefix_attr1 = :prefix_attr1") // + .contains("prefix_attr2 = :prefix_attr2") // + .contains("prefix_prefix2_attr1 = :prefix_prefix2_attr1") // + .contains("prefix_prefix2_attr2 = :prefix_prefix2_attr2"); } @Test // DATAJDBC-340 @@ -352,7 +307,7 @@ void deleteByPath() { sqlGenerator = createSqlGenerator(DummyEntity2.class); - final String sql = sqlGenerator + String sql = sqlGenerator .createDeleteByPath(PersistentPropertyPathTestUtils.getPath("embedded.other", DummyEntity2.class, context)); assertThat(sql).isEqualTo("DELETE FROM other_entity WHERE other_entity.dummy_entity2 = :id"); @@ -441,11 +396,9 @@ void joinForEmbeddedWithReference() { SqlGenerator.Join join = generateJoin("embedded.other", DummyEntity2.class); - assertSoftly(softly -> { - softly.assertThat(join.joinTable().getName()).isEqualTo(SqlIdentifier.unquoted("other_entity")); - softly.assertThat(join.condition()) - .isEqualTo(SqlGeneratorUnitTests.equalsCondition("dummy_entity2", "id", join.joinTable(), "dummy_entity2")); - }); + assertThat(join.joinTable().getName()).isEqualTo(SqlIdentifier.unquoted("other_entity")); + assertThat(join.condition()) + .isEqualTo(SqlGeneratorUnitTests.equalsCondition("dummy_entity2", "id", join.joinTable(), "dummy_entity2")); } @Test // DATAJDBC-340 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 a6edd906cc..f216856031 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,6 +16,7 @@ package org.springframework.data.relational.core.mapping; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; @@ -244,8 +245,9 @@ default Stream stream() { /** * Returns the longest ancestor path that has an {@link org.springframework.data.annotation.Id} property. * - * @return A path that starts just as this path but is shorter. Guaranteed to be not {@literal null}. TODO: throws - * NoSuchElementException: No value present for empty paths + * @return a path that starts just as this path but is shorter. Guaranteed to be not {@literal null}. + * @throws IllegalStateException if the current path is not a {@link #isRoot() root} path or if there is no identifier + * associated. */ AggregatePath getIdDefiningParentPath(); @@ -476,13 +478,14 @@ static ColumnInfo of(AggregatePath path) { * ids and references to such ids. * * @author Jens Schauder + * @author Mark Paluch * @since 4.0 */ class ColumnInfos { private final AggregatePath basePath; private final Map columnInfos; - private final Map> columnCache = new HashMap<>(); + private final Map> columnCache; /** * Creates a new ColumnInfos instances based on the arguments. @@ -495,6 +498,7 @@ class ColumnInfos { this.basePath = basePath; this.columnInfos = columnInfos; + this.columnCache = new HashMap<>(columnInfos.size(), 1f); } /** @@ -533,15 +537,6 @@ public ColumnInfo any() { 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 @@ -558,6 +553,21 @@ public List toColumnList(Table table) { t -> columnInfos.values().stream().map(columnInfo -> t.column(columnInfo.name)).toList()); } + /** + * Creates a {@link List} of {@link Column} instances by applying the provided mapping function to each + * {@link AggregatePath} and {@link ColumnInfo}. + * + * @param mappingFunction function to map {@link AggregatePath} and {@link ColumnInfo} to a {@link Column}. + * @return a list of {@link Column}s. + * @since 4.0 + */ + public List toColumnList(BiFunction mappingFunction) { + + List result = new ArrayList<>(columnInfos.size()); + columnInfos.forEach((ap, ci) -> result.add(mappingFunction.apply(ap, ci))); + return result; + } + /** * 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. @@ -573,7 +583,7 @@ public List toColumnList(Table table) { * compatible with the {@code accumulator} function. * @param type of the result. * @return result of the function. - * @since 3.5 + * @since 4.0 */ public T reduce(T identity, BiFunction accumulator, BinaryOperator combiner) { @@ -631,6 +641,24 @@ public AggregatePath fullPath(AggregatePath ap) { return basePath.append(ap); } + /** + * Return an {@link Iterable} of {@link AggregatePath} associated with this column infos. + * + * @return + */ + public Iterable paths() { + return columnInfos.keySet(); + } + + /** + * 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(); + } + /** * Number of {@literal ColumnInfo} elements in this instance. * diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java index 3a5aa3f4a2..3dd353d252 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java @@ -15,14 +15,22 @@ */ package org.springframework.data.relational.core.mapping; -import java.util.NoSuchElementException; import java.util.function.Predicate; /** + * Traversal methods for {@link AggregatePath} to find paths that define the ID or own the table. + * * @author Mark Paluch + * @since 3.2 */ public class AggregatePathTraversal { + /** + * Get the path that defines the identifier of the aggregate. + * + * @param aggregatePath + * @return + */ public static AggregatePath getIdDefiningPath(AggregatePath aggregatePath) { Predicate idDefiningPathFilter = ap -> !ap.equals(aggregatePath) @@ -30,18 +38,25 @@ public static AggregatePath getIdDefiningPath(AggregatePath aggregatePath) { AggregatePath result = aggregatePath.filter(idDefiningPathFilter); if (result == null) { - throw new NoSuchElementException(); + throw new IllegalStateException( + "No identifier associated within this aggregate path: %s".formatted(aggregatePath)); } return result; } + /** + * Get the path that owns the table of the aggregate. + * + * @param aggregatePath + * @return + */ public static AggregatePath getTableOwningPath(AggregatePath aggregatePath) { Predicate tableOwningPathFilter = ap -> ap.isEntity() && !ap.isEmbedded(); AggregatePath result = aggregatePath.filter(tableOwningPathFilter); if (result == null) { - throw new NoSuchElementException(); + throw new IllegalStateException("No table associated within this aggregate path: %s".formatted(aggregatePath)); } return result; } 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 dd264dbcca..ef09545864 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 @@ -209,6 +209,7 @@ public AggregatePath getIdDefiningParentPath() { } @Override + @Nullable public AggregatePath getTail() { if (getLength() <= 2) { @@ -249,16 +250,6 @@ public AggregatePath subtract(@Nullable AggregatePath basePath) { 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. - * - * @return a path. Guaranteed to be not {@literal null}. - */ - private AggregatePath getTableOwningAncestor() { - return AggregatePathTraversal.getTableOwningPath(this); - } - /** * Creates an {@link Iterator} that iterates over the current path and all ancestors. It will start with the current * path, followed by its parent until ending with the root. 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 47bd819900..b8c6315d69 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 @@ -149,8 +149,8 @@ protected RelationalPersistentProperty createPersistentProperty(Property propert /** * @return iff single query loading is enabled. - * @see #setSingleQueryLoadingEnabled(boolean) * @since 3.2 + * @see #setSingleQueryLoadingEnabled(boolean) */ public boolean isSingleQueryLoadingEnabled() { return singleQueryLoadingEnabled; 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 c5f4fe0d88..2013e04450 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 @@ -45,6 +45,16 @@ public static Condition just(String sql) { return new ConstantCondition(sql); } + /** + * Empty {@link Condition} that can be used to indicate that no condition is present. + * + * @return empty (unrestricted) condition. + * @since 4.0 + */ + public static Condition unrestricted() { + return Unrestricted.INSTANCE; + } + /** * Creates a nested {@link Condition} that is enclosed with parentheses. Useful to combine {@code AND} and {@code OR} * statements. @@ -317,4 +327,5 @@ public static In notIn(Column column, Select subselect) { // Utility constructor. private Conditions() {} + } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Disjunct.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Disjunct.java new file mode 100644 index 0000000000..8283cd1ed2 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Disjunct.java @@ -0,0 +1,38 @@ +/* + * 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; + +/** + * Disjunct condition that does not match any rows using {@code 1 = 0} + * + * @author Mark Paluch + * @since 4.0 + */ +enum Disjunct implements Condition { + + INSTANCE; + + @Override + public Condition and(Condition other) { + return INSTANCE; + } + + @Override + public Condition not() { + return Condition.super.not(); + } + +} 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 42176e1e55..db6a348ec5 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 @@ -22,10 +22,10 @@ * * @author Mark Paluch * @author Jens Schauder + * @since 1.1 * @see SQL * @see Conditions * @see Functions - * @since 1.1 */ public abstract class 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 index 82ac2fe7f8..30fac0924f 100644 --- 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 @@ -13,17 +13,17 @@ * 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.Collection; import java.util.List; /** - * A tuple as used in conditions like + * A tuple as used for {@code IN} predicates. For example: * - *
+ * 
  *   WHERE (one, two) IN (select x, y from some_table)
  * 
* @@ -32,24 +32,36 @@ */ public class TupleExpression extends AbstractSegment implements Expression { - private final List expressions; + private final Collection expressions; - private static Segment[] children(List expressions) { + private static Segment[] children(Collection expressions) { return expressions.toArray(new Segment[0]); } - TupleExpression(List expressions) { + TupleExpression(Collection expressions) { super(children(expressions)); this.expressions = expressions; } + /** + * Creates a {@link TupleExpression} from the given expressions. + * + * @param expressions must not be {@literal null} or empty. + * @return the new {@link TupleExpression}. + */ public static TupleExpression create(Expression... expressions) { return new TupleExpression(List.of(expressions)); } - public static TupleExpression create(List expressions) { + /** + * Creates a {@link TupleExpression} from the given expressions. + * + * @param expressions must not be {@literal null} or empty. + * @return the new {@link TupleExpression}. + */ + public static TupleExpression create(Collection expressions) { return new TupleExpression(expressions); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Unrestricted.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Unrestricted.java new file mode 100644 index 0000000000..d66e955bd6 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Unrestricted.java @@ -0,0 +1,42 @@ +/* + * 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; + +/** + * Unrestricted condition. Any condition combined with this condition will yield the other condition. + * + * @author Mark Paluch + * @since 4.0 + */ +enum Unrestricted implements Condition { + + INSTANCE; + + @Override + public Condition and(Condition other) { + return other; + } + + @Override + public Condition or(Condition other) { + return other; + } + + @Override + public Condition not() { + return Disjunct.INSTANCE; + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/JoinVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/JoinVisitor.java index 94c36b11be..e73061d9c3 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/JoinVisitor.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/JoinVisitor.java @@ -77,8 +77,12 @@ Delegation leaveNested(Visitable segment) { if (hasSeenCondition) { - joinClause.append(" ON "); - joinClause.append(conditionVisitor.getRenderedPart()); + CharSequence renderedPart = conditionVisitor.getRenderedPart(); + + if (!renderedPart.isEmpty()) { + joinClause.append(" ON "); + joinClause.append(renderedPart); + } hasSeenCondition = false; } 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 index fa1db8e202..82de510ad4 100644 --- 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 @@ -13,7 +13,6 @@ * 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.*; diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java index 6ee97eacd7..f673190aad 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/TupleExpressionUnitTests.java @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.data.relational.core.sql; import static org.assertj.core.api.Assertions.*; diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java index 788605a294..2a662a2919 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java @@ -138,6 +138,17 @@ void shouldRenderOrderByAlias() { .isEqualTo("SELECT emp.name AS my_emp_name FROM employee emp ORDER BY my_emp_name ASC"); } + @Test // GH-574 + void shouldNotRenderEmptyCondition() { + + Table table = SQL.table("foo"); + Column bar = table.column("bar"); + + Select select = Select.builder().select(bar).from(table).where(Conditions.unrestricted()).build(); + + assertThat(SqlRenderer.toString(select)).isEqualTo("SELECT foo.bar FROM foo"); + } + @Test // DATAJDBC-309 void shouldRenderIsNull() { @@ -576,6 +587,20 @@ void shouldRenderSimpleJoin() { + "JOIN department ON employee.department_id = department.id"); } + @Test // GH-574 + void shouldRenderSimpleJoinWithUnrestrictedCondition() { + + Table employee = SQL.table("employee"); + Table department = SQL.table("department"); + + Select select = Select.builder().select(employee.column("id"), department.column("name")).from(employee) // + .join(department).on(Conditions.unrestricted()) // + .build(); + + assertThat(SqlRenderer.toString(select)) + .isEqualTo("SELECT employee.id, department.name FROM employee " + "JOIN department"); + } + @Test // DATAJDBC-340 void shouldRenderOuterJoin() { From 51d95de85f9fa8e7a7dbb4f6d27659877b1ace3f Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 27 May 2025 11:29:22 +0200 Subject: [PATCH 6/8] Allow using embedded Id's without Embedded annotation. --- .../core/convert/MappingJdbcConverter.java | 8 ++++ .../convert/RowDocumentExtractorSupport.java | 3 +- .../core/convert/SqlParametersFactory.java | 5 +- .../data/jdbc/core/mapping/schema/Tables.java | 3 +- ...AggregateTemplateHsqlIntegrationTests.java | 4 +- .../MappingRelationalConverter.java | 15 +++++- .../RelationalEntityDeleteWriter.java | 3 +- .../core/conversion/WritingContext.java | 3 +- .../BasicRelationalPersistentProperty.java | 5 +- .../core/mapping/RelationalPredicates.java | 47 +++++++++++++++++++ .../ROOT/partials/mapping-annotations.adoc | 4 +- .../antora/modules/ROOT/partials/mapping.adoc | 4 +- 12 files changed, 89 insertions(+), 15 deletions(-) create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPredicates.java diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java index c7d644bdb7..22cc33ae6f 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java @@ -454,6 +454,14 @@ private static Function getWrappedValueProvider(Function< AggregatePath aggregatePath) { AggregatePath idDefiningParentPath = aggregatePath.getIdDefiningParentPath(); + + if (!idDefiningParentPath.hasIdProperty()) { + return ap -> { + throw new IllegalStateException( + "AggregatePath %s does not define an identifier property".formatted(idDefiningParentPath)); + }; + } + RelationalPersistentProperty idProperty = idDefiningParentPath.getRequiredIdProperty(); AggregatePath idPath = idProperty.isEntity() ? idDefiningParentPath.append(idProperty) : idDefiningParentPath; diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/RowDocumentExtractorSupport.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/RowDocumentExtractorSupport.java index 2d27a453ac..5509157847 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/RowDocumentExtractorSupport.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/RowDocumentExtractorSupport.java @@ -26,6 +26,7 @@ import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.core.mapping.RelationalPredicates; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.domain.RowDocument; import org.springframework.lang.Nullable; @@ -235,7 +236,7 @@ private void readEntity(RS row, RowDocument document, AggregatePath basePath, AggregatePath path = basePath.append(property); - if (property.isEntity() && !property.isEmbedded() && (property.isCollectionLike() || property.isQualified())) { + if (RelationalPredicates.isRelation(property) && (property.isCollectionLike() || property.isQualified())) { readerState.put(property, new ContainerSink<>(aggregateContext, property, path)); continue; diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java index 8a0d853fd9..ca8c7ad644 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java @@ -33,6 +33,7 @@ import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.core.mapping.RelationalPredicates; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.lang.Nullable; @@ -257,12 +258,14 @@ private SqlIdentifierParameterSource getParameterSource(@Nullable S insta PersistentPropertyAccessor propertyAccessor = instance != null ? persistentEntity.getPropertyAccessor(instance) : NoValuePropertyAccessor.instance(); + persistentEntity.doWithAll(property -> { if (skipProperty.test(property) || !property.isWritable()) { return; } - if (property.isEntity() && !property.isEmbedded()) { + + if (RelationalPredicates.isRelation(property)) { return; } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/mapping/schema/Tables.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/mapping/schema/Tables.java index c8d28cc309..e77cdd6884 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/mapping/schema/Tables.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/mapping/schema/Tables.java @@ -33,6 +33,7 @@ import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.core.mapping.RelationalPredicates; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -68,7 +69,7 @@ public static Tables from(Stream> persis for (RelationalPersistentProperty property : entity) { - if (property.isEntity() && !property.isEmbedded()) { + if (RelationalPredicates.isRelation(property)) { foreignKeyMetadataList.add(createForeignKeyMetadata(entity, property, context, sqlTypeMapping)); continue; } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java index a5bbba66af..2d464b1bf9 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java @@ -255,7 +255,7 @@ private record WrappedPk(Long id) { } private record SimpleEntity( // - @Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) WrappedPk wrappedPk, // + @Id WrappedPk wrappedPk, // String name // ) { } @@ -272,7 +272,7 @@ private record EmbeddedPk(Long one, String two) { } private record SimpleEntityWithEmbeddedPk( // - @Id @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL) EmbeddedPk embeddedPk, // + @Id EmbeddedPk embeddedPk, // String name // ) { } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MappingRelationalConverter.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MappingRelationalConverter.java index 395c64e677..82e7e133c3 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MappingRelationalConverter.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MappingRelationalConverter.java @@ -44,7 +44,16 @@ import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.PersistentPropertyPathAccessor; import org.springframework.data.mapping.context.MappingContext; -import org.springframework.data.mapping.model.*; +import org.springframework.data.mapping.model.CachingValueExpressionEvaluatorFactory; +import org.springframework.data.mapping.model.ConvertingPropertyAccessor; +import org.springframework.data.mapping.model.EntityInstantiator; +import org.springframework.data.mapping.model.ParameterValueProvider; +import org.springframework.data.mapping.model.PersistentEntityParameterValueProvider; +import org.springframework.data.mapping.model.PropertyValueProvider; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.mapping.model.SpELContext; +import org.springframework.data.mapping.model.ValueExpressionEvaluator; +import org.springframework.data.mapping.model.ValueExpressionParameterValueProvider; import org.springframework.data.projection.EntityProjection; import org.springframework.data.projection.EntityProjectionIntrospector; import org.springframework.data.projection.EntityProjectionIntrospector.ProjectionPredicate; @@ -578,6 +587,10 @@ private Object readEmbedded(ConversionContext conversionContext, RelationalPrope private boolean shouldReadEmbeddable(ConversionContext context, RelationalPersistentProperty property, RelationalPersistentEntity unwrappedEntity, RelationalPropertyValueProvider propertyValueProvider) { + if (property.isIdProperty() && !property.isAnnotationPresent(Embedded.class)) { + return true; + } + OnEmpty onEmpty = property.getRequiredAnnotation(Embedded.class).onEmpty(); if (onEmpty.equals(OnEmpty.USE_EMPTY)) { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java index 2a68bc061e..cc706f7cb5 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java @@ -24,6 +24,7 @@ import org.springframework.data.mapping.PersistentPropertyPath; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.core.mapping.RelationalPredicates; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -124,7 +125,7 @@ private List> deleteReferencedEntities(Object id, AggregateChange private void forAllTableRepresentingPaths(Class entityType, Consumer> pathConsumer) { - context.findPersistentPropertyPaths(entityType, property -> property.isEntity() && !property.isEmbedded()) // + context.findPersistentPropertyPaths(entityType, RelationalPredicates.isRelation()) // .filter(path -> context.getAggregatePath(path).isWritable()) // .forEach(pathConsumer); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/WritingContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/WritingContext.java index 84be15dfb2..6602e72841 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/WritingContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/WritingContext.java @@ -27,6 +27,7 @@ import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.core.mapping.RelationalPredicates; import org.springframework.data.util.Pair; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -61,7 +62,7 @@ class WritingContext { this.aggregateChange = aggregateChange; this.rootIdValueSource = IdValueSource.forInstance(root, context.getRequiredPersistentEntity(aggregateChange.getEntityType())); - this.paths = context.findPersistentPropertyPaths(entityType, (p) -> p.isEntity() && !p.isEmbedded()) // + this.paths = context.findPersistentPropertyPaths(entityType, RelationalPredicates::isRelation) // .filter(ppp -> context.getAggregatePath(ppp).isWritable()).toList(); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentProperty.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentProperty.java index 0538a98103..395174a645 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentProperty.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentProperty.java @@ -250,7 +250,7 @@ public boolean isOrdered() { @Override public boolean isEmbedded() { - return isEmbedded; + return isEmbedded || (isIdProperty() && isEntity()); } @Override @@ -263,7 +263,8 @@ public boolean shouldCreateEmptyEmbedded() { Embedded findAnnotation = findAnnotation(Embedded.class); - return findAnnotation != null && OnEmpty.USE_EMPTY.equals(findAnnotation.onEmpty()); + return (findAnnotation != null && OnEmpty.USE_EMPTY.equals(findAnnotation.onEmpty())) + || (isIdProperty() && isEntity()); } @Override diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPredicates.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPredicates.java new file mode 100644 index 0000000000..c308a4a49d --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPredicates.java @@ -0,0 +1,47 @@ +/* + * 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.Predicate; + +/** + * Collection of relational predicates. + * + * @author Mark Paluch + * @since 4.0 + */ +public class RelationalPredicates { + + /** + * Predicate to determine whether a property is a relation (i.e. it is an entity, not an identifier property, and not + * an embedded property). + * + * @return a predicate that tests if the given property is a relation. + */ + public static Predicate isRelation() { + return RelationalPredicates::isRelation; + } + + /** + * Determine whether a property is a relation (i.e. it is an entity, not an identifier property, and not an embedded + * property). + * + * @return {@literal true} if the property is a relation; {@literal false} otherwise. + */ + public static boolean isRelation(RelationalPersistentProperty property) { + return !property.isIdProperty() && property.isEntity() && !property.isEmbedded(); + } +} diff --git a/src/main/antora/modules/ROOT/partials/mapping-annotations.adoc b/src/main/antora/modules/ROOT/partials/mapping-annotations.adoc index 0fb12a5706..b61072851a 100644 --- a/src/main/antora/modules/ROOT/partials/mapping-annotations.adoc +++ b/src/main/antora/modules/ROOT/partials/mapping-annotations.adoc @@ -1,7 +1,7 @@ The `RelationalConverter` can use metadata to drive the mapping of objects to rows. The following annotations are available: -* `@Embedded`: an entity with this annotation will be mapped to the table of the parent entity, instead of a separate table. +* `@Embedded`: a property with this annotation will be mapped to the table of the parent entity, instead of a separate table. Allows to specify if the resulting columns should have a common prefix. If all columns resulting from such an entity are `null` either the annotated entity will be `null` or _empty_, i.e. all of its properties will be `null`, depending on the value of `@Embedded.onEmpty()` May be combined with `@Id` to form a composite id. @@ -32,5 +32,3 @@ However, this is not recommended, since it may cause problems with other tools. The value is `null` (`zero` for primitive types) is considered as marker for entities to be new. The initially stored value is `zero` (`one` for primitive types). The version gets incremented automatically on every update. - - diff --git a/src/main/antora/modules/ROOT/partials/mapping.adoc b/src/main/antora/modules/ROOT/partials/mapping.adoc index 16e0c5b833..e6cacbaada 100644 --- a/src/main/antora/modules/ROOT/partials/mapping.adoc +++ b/src/main/antora/modules/ROOT/partials/mapping.adoc @@ -151,7 +151,7 @@ endif::[] [[entity-persistence.embedded-ids]] === Embedded Ids -Entities may be annotated with `@Id` and `@Embedded`, resulting in a composite id on the database side. +The identifier property can be annotated with `@Embedded` allowing to use composite ids. The full embedded entity is considered the id, and therefore the check for determining if an aggregate is considered a new aggregate requiring an insert or an existing one, asking for an update is based on that entity, not its elements. Most use cases will require a custom `BeforeConvertCallback` to set the id for new aggregate. @@ -189,7 +189,7 @@ CREATE TABLE PERSON_WITH_COMPOSITE_ID ( <2> `pk` is marked as id and embedded <3> the two columns from the embedded `Name` entity make up the primary key in the database. -Details of the create tables will depend on the database used. +Details of table creation depends on the used database. ==== [[entity-persistence.read-only-properties]] From 6dcdd6031845fe7d93afe95c2fcec3014588f4bf Mon Sep 17 00:00:00 2001 From: Jens Schauder Date: Mon, 2 Jun 2025 13:18:57 +0200 Subject: [PATCH 7/8] Migrate ColumnInfos to use of the long path only. Turns out the concept of base path becomes superfluous. Original pull request #1957 See #574 --- .../JdbcAggregateChangeExecutionContext.java | 5 +- .../core/convert/MappingJdbcConverter.java | 12 ++++- .../data/jdbc/core/convert/SqlGenerator.java | 5 +- .../core/convert/SqlParametersFactory.java | 6 ++- .../JdbcIdentifierBuilderUnitTests.java | 2 + .../core/mapping/AggregatePath.java | 46 +++++++++---------- .../core/mapping/ColumInfosBuilder.java | 4 +- .../core/mapping/DefaultAggregatePath.java | 18 ++++++++ .../core/mapping/ColumnInfosUnitTests.java | 7 +-- 9 files changed, 66 insertions(+), 39 deletions(-) diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java index 7b2116f39d..edb2faee97 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java @@ -199,15 +199,16 @@ private Identifier getParentKeys(DbAction.WithDependingOn action, JdbcConvert static Function getIdMapper(Object idValue, AggregatePath path, JdbcConverter converter) { + RelationalPersistentProperty idProperty = path.getIdDefiningParentPath().getRequiredIdProperty(); RelationalPersistentEntity entity = converter.getMappingContext() - .getPersistentEntity(path.getIdDefiningParentPath().getRequiredIdProperty()); + .getPersistentEntity(idProperty); if (entity == null) { return aggregatePath -> idValue; } PersistentPropertyPathAccessor propertyPathAccessor = entity.getPropertyPathAccessor(idValue); - return aggregatePath -> propertyPathAccessor.getProperty(aggregatePath.getRequiredPersistentPropertyPath()); + return aggregatePath -> propertyPathAccessor.getProperty(aggregatePath.getSubPathBasedOn(idProperty.getActualType()).getRequiredPersistentPropertyPath()); } private Object getParentId(DbAction.WithDependingOn action) { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java index 22cc33ae6f..2c3feffdb6 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/MappingJdbcConverter.java @@ -465,7 +465,17 @@ private static Function getWrappedValueProvider(Function< RelationalPersistentProperty idProperty = idDefiningParentPath.getRequiredIdProperty(); AggregatePath idPath = idProperty.isEntity() ? idDefiningParentPath.append(idProperty) : idDefiningParentPath; - return ap -> valueProvider.apply(idPath.append(ap)); + return ap -> valueProvider.apply(smartAppend(idPath, ap)); + } + + private static AggregatePath smartAppend(AggregatePath base, AggregatePath suffix) { + + RelationalPersistentEntity owner = suffix.getRequiredBaseProperty().getOwner(); + if (owner.equals(base.getRequiredLeafEntity())) { + return base.append(suffix); + } else { + return smartAppend(base, suffix.getTail()); + } } /** diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java index 07ea6400ec..5139dd7197 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java @@ -984,15 +984,16 @@ private Column getSingleNonNullColumn() { // return getTable(path).column(columnInfo.name()).as(columnInfo.alias()); AggregatePath.ColumnInfos columnInfos = mappingContext.getAggregatePath(entity).getTableInfo().idColumnInfos(); - return columnInfos.any((ap, ci) -> sqlContext.getTable(columnInfos.fullPath(ap)).column(ci.name()).as(ci.alias())); + return columnInfos.any((ap, ci) -> sqlContext.getColumn(ap)); } private List getIdColumns() { AggregatePath.ColumnInfos columnInfos = mappingContext.getAggregatePath(entity).getTableInfo().idColumnInfos(); + // sqlcontext.getColumn (vs sqlContext.getTable return columnInfos - .toColumnList((aggregatePath, columnInfo) -> sqlContext.getColumn(columnInfos.fullPath(aggregatePath))); + .toColumnList((aggregatePath, columnInfo) -> sqlContext.getColumn(aggregatePath)); } private Column getVersionColumn() { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java index ca8c7ad644..5e893d26fa 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java @@ -86,8 +86,10 @@ SqlIdentifierParameterSource forInsert(T instance, Class domainType, Iden PersistentPropertyPathAccessor propertyPathAccessor = persistentEntity.getPropertyPathAccessor(instance); AggregatePath.ColumnInfos columnInfos = context.getAggregatePath(persistentEntity).getTableInfo().idColumnInfos(); - columnInfos.forEach((ap, __) -> { - Object idValue = propertyPathAccessor.getProperty(columnInfos.fullPath(ap).getRequiredPersistentPropertyPath()); + + // fullPath: because we use the result with a PropertyPathAccessor + columnInfos.forEachLong((ap, __) -> { + Object idValue = propertyPathAccessor.getProperty(ap.getRequiredPersistentPropertyPath()); RelationalPersistentProperty idProperty = ap.getRequiredLeafProperty(); addConvertedPropertyValue(parameterSource, idProperty, idValue, idProperty.getColumnName()); }); diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java index 78fddff762..5c0daa2e0b 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/JdbcIdentifierBuilderUnitTests.java @@ -144,6 +144,8 @@ static Function getValueProvider(Object idValue, Aggregat if (entity == null) { return idValue; } else { + + ap = ap.getTail(); PersistentPropertyPathAccessor propertyPathAccessor = entity.getPropertyPathAccessor(idValue); return propertyPathAccessor.getProperty(ap.getRequiredPersistentPropertyPath()); } 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 f216856031..bb4ab12469 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 @@ -21,6 +21,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.TreeMap; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.BinaryOperator; @@ -288,6 +289,8 @@ default int compareTo(AggregatePath other) { return toDotPath().compareTo(other.toDotPath()); } + AggregatePath getSubPathBasedOn(Class baseType); + /** * Information about a table underlying an entity. * @@ -342,7 +345,7 @@ static TableInfo of(AggregatePath path) { private static ColumnInfos computeIdColumnInfos(AggregatePath tableOwner, RelationalPersistentEntity leafEntity) { - ColumnInfos idColumnInfos = ColumnInfos.empty(tableOwner); + ColumnInfos idColumnInfos = ColumnInfos.empty(); if (!leafEntity.hasIdProperty()) { return idColumnInfos; } @@ -369,7 +372,7 @@ private static ColumnInfos computeBackReferenceColumnInfos(AggregatePath path) { AggregatePath tableOwner = AggregatePathTraversal.getTableOwningPath(path); if (tableOwner.isRoot()) { - return ColumnInfos.empty(tableOwner); + return ColumnInfos.empty(); } AggregatePath idDefiningParentPath = tableOwner.getIdDefiningParentPath(); @@ -438,7 +441,7 @@ public ColumnInfo reverseColumnInfo() { * @return ColumnInfos representing the effective id of this entity. Guaranteed not to be {@literal null}. */ public ColumnInfos effectiveIdColumnInfos() { - return backReferenceColumnInfos.columnInfos.isEmpty() ? idColumnInfos : backReferenceColumnInfos; + return backReferenceColumnInfos.isEmpty() ? idColumnInfos : backReferenceColumnInfos; } } @@ -483,21 +486,18 @@ static ColumnInfo of(AggregatePath path) { */ class ColumnInfos { - private final AggregatePath basePath; private final Map columnInfos; private final Map> columnCache; /** * 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) { + ColumnInfos(Map columnInfos) { - this.basePath = basePath; - this.columnInfos = columnInfos; + this.columnInfos = new TreeMap<>(); + this.columnInfos.putAll(columnInfos); this.columnCache = new HashMap<>(columnInfos.size(), 1f); } @@ -505,11 +505,10 @@ class 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<>()); + public static ColumnInfos empty() { + return new ColumnInfos(new HashMap<>()); } /** @@ -549,8 +548,7 @@ public ColumnInfo any() { */ public List toColumnList(Table table) { - return columnCache.computeIfAbsent(table, - t -> columnInfos.values().stream().map(columnInfo -> t.column(columnInfo.name)).toList()); + return columnCache.computeIfAbsent(table, t -> toColumnList((__, ci) -> t.column(ci.name))); } /** @@ -607,6 +605,15 @@ public void forEach(BiConsumer consumer) { columnInfos.forEach(consumer); } + /** + * Calls the consumer for each pair of {@link AggregatePath} and {@literal ColumnInfo}. + * + * @param consumer the function to call. + */ + public void forEachLong(BiConsumer consumer) { + columnInfos.forEach(consumer); + } + /** * Calls the {@literal mapper} for each pair one pair of {@link AggregatePath} and {@link ColumnInfo}, if there is * any. @@ -631,16 +638,6 @@ 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); - } - /** * Return an {@link Iterable} of {@link AggregatePath} associated with this column infos. * @@ -668,5 +665,4 @@ public int size() { return columnInfos.size(); } } - } 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 index 2e5c290325..9f230a492c 100644 --- 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 @@ -69,7 +69,7 @@ void add(RelationalPersistentProperty property, SqlIdentifier name, SqlIdentifie * @param columnInfo the {@literal ColumnInfo} added. */ void add(AggregatePath path, AggregatePath.ColumnInfo columnInfo) { - columnInfoMap.put(path.subtract(basePath), columnInfo); + columnInfoMap.put(path, columnInfo); } /** @@ -78,7 +78,7 @@ void add(AggregatePath path, AggregatePath.ColumnInfo columnInfo) { * @return a {@literal ColumnInfos} instance containing all the added {@link AggregatePath.ColumnInfo} instances. */ AggregatePath.ColumnInfos build() { - return new AggregatePath.ColumnInfos(basePath, columnInfoMap); + return new AggregatePath.ColumnInfos(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 ef09545864..42f25a2475 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 @@ -250,6 +250,24 @@ public AggregatePath subtract(@Nullable AggregatePath basePath) { throw new IllegalStateException("Can't subtract [%s] from [%s]".formatted(basePath, this)); } + @Override + public AggregatePath getSubPathBasedOn(Class baseType) { + + if (isRoot()) { + if (rootType.getType() != baseType) { + throw new IllegalStateException("No matching path found for [%s]".formatted(baseType)); + } + return this; + } + + RelationalPersistentEntity owner = getRequiredBaseProperty().getOwner(); + if (owner.getType() == baseType) { + return this; + } + + return getTail().getSubPathBasedOn(baseType); + } + /** * Creates an {@link Iterator} that iterates over the current path and all ancestors. It will start with the current * path, followed by its parent until ending with the root. 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 index 82de510ad4..17583ed10e 100644 --- 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 @@ -41,7 +41,7 @@ class ColumnInfosUnitTests { @Test // GH-574 void emptyColumnInfos() { - AggregatePath.ColumnInfos columnInfos = AggregatePath.ColumnInfos.empty(basePath(DummyEntity.class)); + AggregatePath.ColumnInfos columnInfos = AggregatePath.ColumnInfos.empty(); assertThat(columnInfos.isEmpty()).isTrue(); assertThrows(NoSuchElementException.class, columnInfos::any); @@ -76,10 +76,7 @@ void multiElementColumnInfos() { 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")); - + assertThat(collector).containsExactly("id.one+\"ONE\"", "id.two+\"TWO\""); } private AggregatePath getPath(Class type, String name) { From 45d2ee1826f3aeb46b42d67c7687c776910063db Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 16 Jun 2025 16:30:27 +0200 Subject: [PATCH 8/8] Polishing. Refactor duplications, introduce QueryAssert. --- .../data/jdbc/core/convert/SqlContext.java | 10 +- .../data/jdbc/core/convert/SqlGenerator.java | 120 +++++--- .../core/convert/SqlParametersFactory.java | 2 +- ...AggregateTemplateHsqlIntegrationTests.java | 9 +- .../query/PartTreeJdbcQueryUnitTests.java | 275 ++++++++++-------- .../jdbc/repository/query/QueryAssert.java | 115 ++++++++ .../core/mapping/AggregatePath.java | 27 +- 7 files changed, 366 insertions(+), 192 deletions(-) create mode 100644 spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/QueryAssert.java diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java index 586da2c22f..2076c9b167 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java @@ -56,21 +56,21 @@ Table getTable(AggregatePath path) { } Column getColumn(AggregatePath path) { - - AggregatePath.ColumnInfo columnInfo = path.getColumnInfo(); - return getTable(path).column(columnInfo.name()).as(columnInfo.alias()); + return getAliasedColumn(path, path.getColumnInfo()); } /** * A token reverse column, used in selects to identify, if an entity is present or {@literal null}. - * + * * @param path must not be null. * @return a {@literal Column} that is part of the effective primary key for the given path. * @since 4.0 */ Column getAnyReverseColumn(AggregatePath path) { + return getAliasedColumn(path, path.getTableInfo().backReferenceColumnInfos().any()); + } - AggregatePath.ColumnInfo columnInfo = path.getTableInfo().backReferenceColumnInfos().any(); + private Column getAliasedColumn(AggregatePath path, AggregatePath.ColumnInfo columnInfo) { return getTable(path).column(columnInfo.name()).as(columnInfo.alias()); } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java index 5139dd7197..82f2db5158 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java @@ -15,7 +15,16 @@ */ package org.springframework.data.jdbc.core.convert; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Predicate; @@ -38,7 +47,6 @@ import org.springframework.data.relational.core.sql.*; import org.springframework.data.relational.core.sql.render.SqlRenderer; import org.springframework.data.util.Lazy; -import org.springframework.data.util.Pair; import org.springframework.data.util.Predicates; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.lang.Nullable; @@ -123,11 +131,11 @@ public class SqlGenerator { * @param table the table to base the select on * @param pathFilter a filter for excluding paths from the select. All paths for which the filter returns * {@literal true} will be skipped when determining columns to select. - * @return A select structure suitable for constructing more specialized selects by adding conditions. + * @return a select structure suitable for constructing more specialized selects by adding conditions. * @since 4.0 */ public SelectBuilder.SelectWhere createSelectBuilder(Table table, Predicate pathFilter) { - return createSelectBuilder(table, pathFilter, Collections.emptyList()); + return createSelectBuilder(table, pathFilter, Collections.emptyList(), Query.empty()); } /** @@ -188,13 +196,7 @@ private Condition getSubselectCondition(AggregatePath path, AggregatePath.TableInfo parentPathTableInfo = parentPath.getTableInfo(); Table subSelectTable = Table.create(parentPathTableInfo.qualifiedTableName()); - Map selectFilterColumns = new TreeMap<>(); - - // TODO: cannot we simply pass on the columnInfos? - parentPathTableInfo.effectiveIdColumnInfos().forEach( // - (ap, ci) -> // - selectFilterColumns.put(ap, subSelectTable.column(ci.name())) // - ); + Map selectFilterColumns = parentPathTableInfo.effectiveIdColumnInfos().toMap(subSelectTable); Condition innerCondition; @@ -609,29 +611,24 @@ private SelectBuilder.SelectWhere selectBuilder(Collection keyCol } private SelectBuilder.SelectWhere selectBuilder(Collection keyColumns, Query query) { - - return createSelectBuilder(getTable(), ap -> false, keyColumns); + return createSelectBuilder(getTable(), ap -> false, keyColumns, query); } private SelectBuilder.SelectWhere createSelectBuilder(Table table, Predicate pathFilter, - Collection keyColumns) { + Collection keyColumns, Query query) { Projection projection = getProjection(pathFilter, keyColumns, query, table); SelectBuilder.SelectJoin baseSelect = StatementBuilder.select(projection.columns()).from(table); - return (SelectBuilder.SelectWhere) addJoins(baseSelect, joinTables); + return (SelectBuilder.SelectWhere) addJoins(baseSelect, projection.joins()); } - private static SelectBuilder.SelectJoin addJoins(SelectBuilder.SelectJoin baseSelect, List joinTables) { - - for (Join join : projection.joins()) { - - baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(join.condition); - } - return baseSelect; + private static SelectBuilder.SelectJoin addJoins(SelectBuilder.SelectJoin baseSelect, Joins joins) { + return joins.reduce(baseSelect, (join, select) -> select.leftOuterJoin(join.joinTable).on(join.condition)); } - private Projection getProjection(Predicate pathFilter, Collection keyColumns, Query query, Table table) { + private Projection getProjection(Predicate pathFilter, Collection keyColumns, + Query query, Table table) { Set columns = new LinkedHashSet<>(); Set joins = new LinkedHashSet<>(); @@ -642,7 +639,7 @@ private Projection getProjection(Predicate pathFilter, Collection AggregatePath aggregatePath = mappingContext.getAggregatePath( mappingContext.getPersistentPropertyPath(columnName.getReference(), entity.getTypeInformation())); - includeColumnAndJoin(aggregatePath, joins, columns); + includeColumnAndJoin(aggregatePath, pathFilter, joins, columns); } catch (InvalidPersistentPropertyPath e) { columns.add(Column.create(columnName, table)); } @@ -656,10 +653,10 @@ private Projection getProjection(Predicate pathFilter, Collection AggregatePath aggregatePath = mappingContext.getAggregatePath(path); if (pathFilter.test(aggregatePath)) { - continue; - } + continue; + } - includeColumnAndJoin(aggregatePath, joins, columns); + includeColumnAndJoin(aggregatePath, pathFilter, joins, columns); } } @@ -667,11 +664,29 @@ private Projection getProjection(Predicate pathFilter, Collection columns.add(table.column(keyColumn).as(keyColumn)); } - return new Projection(columns, joins); + return new Projection(columns, Joins.of(joins)); } - private void includeColumnAndJoin(AggregatePath aggregatePath, Collection joins, - Collection columns) { + private void includeColumnAndJoin(AggregatePath aggregatePath, Predicate pathFilter, + Collection joins, Collection columns) { + + if (aggregatePath.isEmbedded()) { + + RelationalPersistentEntity entity = aggregatePath.getRequiredLeafEntity(); + + for (RelationalPersistentProperty property : entity) { + + AggregatePath nested = aggregatePath.append(property); + + if (pathFilter.test(nested)) { + continue; + } + + includeColumnAndJoin(nested, pathFilter, joins, columns); + } + + return; + } joins.addAll(getJoins(aggregatePath)); @@ -687,7 +702,24 @@ private void includeColumnAndJoin(AggregatePath aggregatePath, Collection * @param columns * @param joins */ - record Projection(Set columns, Set joins) { + record Projection(Collection columns, Joins joins) { + + } + + record Joins(Collection joins) { + + public static Joins of(Collection joins) { + return new Joins(joins); + } + + public T reduce(T identity, BiFunction accumulator) { + + T result = identity; + for (Join join : joins) { + result = accumulator.apply(join, result); + } + return result; + } } private SelectBuilder.SelectOrdered selectBuilder(Collection keyColumns, Sort sort, @@ -922,11 +954,8 @@ private String createDeleteByPathAndCriteria(AggregatePath path, .from(table); Delete delete; - Map columns = new TreeMap<>(); AggregatePath.ColumnInfos columnInfos = path.getTableInfo().backReferenceColumnInfos(); - - // TODO: cannot we simply pass on the columnInfos? - columnInfos.forEach((ag, ci) -> columns.put(ag, table.column(ci.name()))); + Map columns = columnInfos.toMap(table); if (isFirstNonRoot(path)) { @@ -978,22 +1007,19 @@ private Table getTable() { * @return a single column of the primary key to be used in places where one need something not null to be selected. */ private Column getSingleNonNullColumn() { - - // getColumn() is slightly different from the code in any(…). Why? - // AggregatePath.ColumnInfo columnInfo = path.getColumnInfo(); - // return getTable(path).column(columnInfo.name()).as(columnInfo.alias()); - - AggregatePath.ColumnInfos columnInfos = mappingContext.getAggregatePath(entity).getTableInfo().idColumnInfos(); - return columnInfos.any((ap, ci) -> sqlContext.getColumn(ap)); + return doGetColumn(AggregatePath.ColumnInfos::any); } private List getIdColumns() { + return doGetColumn(AggregatePath.ColumnInfos::toColumnList); + } + + private T doGetColumn( + BiFunction, T> columnListFunction) { AggregatePath.ColumnInfos columnInfos = mappingContext.getAggregatePath(entity).getTableInfo().idColumnInfos(); - // sqlcontext.getColumn (vs sqlContext.getTable - return columnInfos - .toColumnList((aggregatePath, columnInfo) -> sqlContext.getColumn(aggregatePath)); + return columnListFunction.apply(columnInfos, (aggregatePath, columnInfo) -> sqlContext.getColumn(aggregatePath)); } private Column getVersionColumn() { @@ -1164,7 +1190,7 @@ private SelectBuilder.SelectJoin getExistsSelect() { } } - return addJoins(baseSelect, joins); + return addJoins(baseSelect, Joins.of(joins)); } /** @@ -1199,7 +1225,7 @@ private SelectBuilder.SelectJoin getSelectCountWithExpression(Expression... coun joins.add(join); } } - return addJoins(baseSelect, joins); + return addJoins(baseSelect, Joins.of(joins)); } private SelectBuilder.SelectOrdered applyQueryOnSelect(Query query, MapSqlParameterSource parameterSource, diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java index 5e893d26fa..14a4743352 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java @@ -88,7 +88,7 @@ SqlIdentifierParameterSource forInsert(T instance, Class domainType, Iden AggregatePath.ColumnInfos columnInfos = context.getAggregatePath(persistentEntity).getTableInfo().idColumnInfos(); // fullPath: because we use the result with a PropertyPathAccessor - columnInfos.forEachLong((ap, __) -> { + columnInfos.forEach((ap, __) -> { Object idValue = propertyPathAccessor.getProperty(ap.getRequiredPersistentPropertyPath()); RelationalPersistentProperty idProperty = ap.getRequiredLeafProperty(); addConvertedPropertyValue(parameterSource, idProperty, idValue, idProperty.getColumnName()); diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java index 2d464b1bf9..9b5df44a80 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/CompositeIdAggregateTemplateHsqlIntegrationTests.java @@ -237,7 +237,7 @@ void sortByCompositeIdParts() { @Test // GH-574 void projectByCompositeIdParts() { - SimpleEntityWithEmbeddedPk alpha = template.insert( // + template.insert( // new SimpleEntityWithEmbeddedPk( // new EmbeddedPk(23L, "x"), "alpha" // )); @@ -246,8 +246,11 @@ void projectByCompositeIdParts() { SimpleEntityWithEmbeddedPk projected = template.findOne(projectingQuery, SimpleEntityWithEmbeddedPk.class) .orElseThrow(); - // Projection still does a full select, otherwise one would be null. - // See https://github.com/spring-projects/spring-data-relational/issues/1821 + assertThat(projected).isEqualTo(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(null, "x"), "alpha")); + + projectingQuery = Query.empty().columns("embeddedPk", "name"); + projected = template.findOne(projectingQuery, SimpleEntityWithEmbeddedPk.class).orElseThrow(); + assertThat(projected).isEqualTo(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha")); } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java index ef297a193e..3738e1a6c9 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java @@ -29,6 +29,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; + import org.springframework.data.annotation.Id; import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.jdbc.core.convert.MappingJdbcConverter; @@ -38,11 +39,11 @@ import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.relational.core.dialect.Escaper; -import org.springframework.data.relational.core.dialect.H2Dialect; import org.springframework.data.relational.core.mapping.Embedded; import org.springframework.data.relational.core.mapping.Embedded.Nullable; import org.springframework.data.relational.core.mapping.MappedCollection; import org.springframework.data.relational.core.mapping.Table; +import org.springframework.data.relational.core.sql.Column; import org.springframework.data.relational.core.sql.LockMode; import org.springframework.data.relational.repository.Lock; import org.springframework.data.relational.repository.query.RelationalParametersParameterAccessor; @@ -67,23 +68,38 @@ public class PartTreeJdbcQueryUnitTests { private static final String TABLE = "\"users\""; - private static final String ALL_FIELDS = "\"users\".\"AGE\" AS \"AGE\", \"users\".\"ACTIVE\" AS \"ACTIVE\", \"users\".\"LAST_NAME\" AS \"LAST_NAME\", \"users\".\"FIRST_NAME\" AS \"FIRST_NAME\", \"users\".\"DATE_OF_BIRTH\" AS \"DATE_OF_BIRTH\", \"users\".\"HOBBY_REFERENCE\" AS \"HOBBY_REFERENCE\", \"users\".\"ID\" AS \"ID\", \"users\".\"SUB_ID\" AS \"SUB_ID\", \"hated\".\"NAME\" AS \"HATED_NAME\", \"users\".\"USER_CITY\" AS \"USER_CITY\", \"users\".\"USER_STREET\" AS \"USER_STREET\""; private static final String JOIN_CLAUSE = "FROM \"users\" LEFT OUTER JOIN \"HOBBY\" \"hated\" ON \"hated\".\"USERS_ID\" = \"users\".\"ID\" AND \"hated\".\"USERS_SUB_ID\" = \"users\".\"SUB_ID\""; - private static final String BASE_SELECT = "SELECT " + ALL_FIELDS + " " + JOIN_CLAUSE; - JdbcMappingContext mappingContext = new JdbcMappingContext(); - JdbcConverter converter = new MappingJdbcConverter(mappingContext, mock(RelationResolver.class)); - ReturnedType returnedType = mock(ReturnedType.class); + private JdbcMappingContext mappingContext = new JdbcMappingContext(); + private JdbcConverter converter = new MappingJdbcConverter(mappingContext, mock(RelationResolver.class)); + private ReturnedType returnedType = mock(ReturnedType.class); + + private org.springframework.data.relational.core.sql.Table users = org.springframework.data.relational.core.sql.Table + .create("users"); + + private org.springframework.data.relational.core.sql.Table hobby = org.springframework.data.relational.core.sql.Table + .create("hobby").as("hated"); + private List columns = List.of(users.column("ID"), // + users.column("SUB_ID"), // + users.column("AGE"), // + users.column("ACTIVE"), // + users.column("USER_STREET"), // + users.column("USER_CITY"), // + users.column("LAST_NAME"), // + users.column("FIRST_NAME"), // + users.column("DATE_OF_BIRTH"), // + users.column("HOBBY_REFERENCE"), // + hobby.column("NAME").as("HATED_NAME")); @Test // DATAJDBC-318 - public void shouldFailForQueryByReference() throws Exception { + void shouldFailForQueryByReference() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByHated", Hobby.class); assertThatIllegalArgumentException().isThrownBy(() -> createQuery(queryMethod)); } @Test // GH-922 - public void createQueryByAggregateReference() throws Exception { + void createQueryByAggregateReference() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByHobbyReference", Hobby.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); @@ -91,13 +107,9 @@ public void createQueryByAggregateReference() throws Exception { hobby.name = "twentythree"; ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] { hobby }), returnedType); - assertSoftly(softly -> { - - softly.assertThat(query.getQuery()) - .isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"HOBBY_REFERENCE\" = :hobby_reference"); - - softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("hobby_reference")).isEqualTo("twentythree"); - }); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"HOBBY_REFERENCE\" = :hobby_reference") + .hasBindValue("hobby_reference", "twentythree"); } @Test // GH-922 @@ -115,8 +127,7 @@ void createQueryWithPessimisticWriteLock() throws Exception { softly.assertThat(query.getQuery().toUpperCase()).endsWith("FOR UPDATE"); - softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("first_name")).isEqualTo(firstname); - softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("last_name")).isEqualTo(lastname); + QueryAssert.assertThat(query).hasBindValue("first_name", firstname).hasBindValue("last_name", lastname); }); } @@ -136,27 +147,26 @@ void createQueryWithPessimisticReadLock() throws Exception { // this is also for update since h2 dialect does not distinguish between lockmodes softly.assertThat(query.getQuery().toUpperCase()).endsWith("FOR UPDATE"); - softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("first_name")).isEqualTo(firstname); - softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("age")).isEqualTo(age); + QueryAssert.assertThat(query).hasBindValue("first_name", firstname).hasBindValue("age", age); }); } @Test // DATAJDBC-318 - public void shouldFailForQueryByList() throws Exception { + void shouldFailForQueryByList() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByHobbies", Object.class); assertThatIllegalArgumentException().isThrownBy(() -> createQuery(queryMethod)); } @Test // DATAJDBC-318 - public void shouldFailForQueryByEmbeddedList() throws Exception { + void shouldFailForQueryByEmbeddedList() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findByAnotherEmbeddedList", Object.class); assertThatIllegalArgumentException().isThrownBy(() -> createQuery(queryMethod)); } @Test // GH-922 - public void createQueryForQueryByAggregateReference() throws Exception { + void createQueryForQueryByAggregateReference() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findViaReferenceByHobbyReference", AggregateReference.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); @@ -165,15 +175,14 @@ public void createQueryForQueryByAggregateReference() throws Exception { assertSoftly(softly -> { - softly.assertThat(query.getQuery()) - .isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"HOBBY_REFERENCE\" = :hobby_reference"); - - softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("hobby_reference")).isEqualTo("twentythree"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"HOBBY_REFERENCE\" = :hobby_reference") + .hasBindValue("hobby_reference", "twentythree"); }); } @Test // GH-922 - public void createQueryForQueryByAggregateReferenceId() throws Exception { + void createQueryForQueryByAggregateReferenceId() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findViaIdByHobbyReference", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); @@ -182,25 +191,25 @@ public void createQueryForQueryByAggregateReferenceId() throws Exception { assertSoftly(softly -> { - softly.assertThat(query.getQuery()) - .isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"HOBBY_REFERENCE\" = :hobby_reference"); - - softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("hobby_reference")).isEqualTo("twentythree"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"HOBBY_REFERENCE\" = :hobby_reference") + .hasBindValue("hobby_reference", "twentythree"); }); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByStringAttribute() throws Exception { + void createsQueryToFindAllEntitiesByStringAttribute() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstName", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] { "John" }), returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name"); } @Test // GH-971 - public void createsQueryToFindAllEntitiesByProjectionAttribute() throws Exception { + void createsQueryToFindAllEntitiesByProjectionAttribute() throws Exception { when(returnedType.needsCustomConstruction()).thenReturn(true); when(returnedType.getInputProperties()).thenReturn(Collections.singletonList("firstName")); @@ -214,17 +223,18 @@ public void createsQueryToFindAllEntitiesByProjectionAttribute() throws Exceptio } @Test // DATAJDBC-318 - public void createsQueryWithIsNullCondition() throws Exception { + void createsQueryWithIsNullCondition() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstName", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); ParametrizedQuery query = jdbcQuery.createQuery((getAccessor(queryMethod, new Object[] { null })), returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" IS NULL"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" IS NULL"); } @Test // DATAJDBC-318 - public void createsQueryWithLimitForExistsProjection() throws Exception { + void createsQueryWithLimitForExistsProjection() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("existsByFirstName", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); @@ -235,31 +245,31 @@ public void createsQueryWithLimitForExistsProjection() throws Exception { } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByTwoStringAttributes() throws Exception { + void createsQueryToFindAllEntitiesByTwoStringAttributes() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByLastNameAndFirstName", String.class, String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] { "Doe", "John" }), returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"LAST_NAME\" = :last_name AND (" + TABLE - + ".\"FIRST_NAME\" = :first_name)"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"LAST_NAME\" = :last_name AND (" + TABLE + ".\"FIRST_NAME\" = :first_name)"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByOneOfTwoStringAttributes() throws Exception { + void createsQueryToFindAllEntitiesByOneOfTwoStringAttributes() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByLastNameOrFirstName", String.class, String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] { "Doe", "John" }), returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"LAST_NAME\" = :last_name OR (" + TABLE - + ".\"FIRST_NAME\" = :first_name)"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"LAST_NAME\" = :last_name OR (" + TABLE + ".\"FIRST_NAME\" = :first_name)"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByDateAttributeBetween() throws Exception { + void createsQueryToFindAllEntitiesByDateAttributeBetween() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByDateOfBirthBetween", Date.class, Date.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); @@ -270,251 +280,261 @@ public void createsQueryToFindAllEntitiesByDateAttributeBetween() throws Excepti assertSoftly(softly -> { - softly.assertThat(query.getQuery()) - .isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"DATE_OF_BIRTH\" BETWEEN :date_of_birth AND :date_of_birth1"); - - softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("date_of_birth")).isEqualTo(from); - softly.assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("date_of_birth1")).isEqualTo(to); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"DATE_OF_BIRTH\" BETWEEN :date_of_birth AND :date_of_birth1") + .hasBindValue("date_of_birth", from).hasBindValue("date_of_birth1", to); }); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByIntegerAttributeLessThan() throws Exception { + void createsQueryToFindAllEntitiesByIntegerAttributeLessThan() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeLessThan", Integer.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 30 }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" < :age"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns).contains(" WHERE " + TABLE + ".\"AGE\" < :age"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByIntegerAttributeLessThanEqual() throws Exception { + void createsQueryToFindAllEntitiesByIntegerAttributeLessThanEqual() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeLessThanEqual", Integer.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 30 }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" <= :age"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"AGE\" <= :age"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByIntegerAttributeGreaterThan() throws Exception { + void createsQueryToFindAllEntitiesByIntegerAttributeGreaterThan() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeGreaterThan", Integer.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 30 }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" > :age"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns).contains(" WHERE " + TABLE + ".\"AGE\" > :age"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByIntegerAttributeGreaterThanEqual() throws Exception { + void createsQueryToFindAllEntitiesByIntegerAttributeGreaterThanEqual() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeGreaterThanEqual", Integer.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 30 }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" >= :age"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"AGE\" >= :age"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByDateAttributeAfter() throws Exception { + void createsQueryToFindAllEntitiesByDateAttributeAfter() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByDateOfBirthAfter", Date.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { new Date() }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"DATE_OF_BIRTH\" > :date_of_birth"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"DATE_OF_BIRTH\" > :date_of_birth"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByDateAttributeBefore() throws Exception { + void createsQueryToFindAllEntitiesByDateAttributeBefore() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByDateOfBirthBefore", Date.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { new Date() }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"DATE_OF_BIRTH\" < :date_of_birth"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"DATE_OF_BIRTH\" < :date_of_birth"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByIntegerAttributeIsNull() throws Exception { + void createsQueryToFindAllEntitiesByIntegerAttributeIsNull() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeIsNull"); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[0]); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" IS NULL"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"AGE\" IS NULL"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByIntegerAttributeIsNotNull() throws Exception { + void createsQueryToFindAllEntitiesByIntegerAttributeIsNotNull() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeIsNotNull"); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[0]); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" IS NOT NULL"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"AGE\" IS NOT NULL"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByStringAttributeLike() throws Exception { + void createsQueryToFindAllEntitiesByStringAttributeLike() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameLike", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "%John%" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByStringAttributeNotLike() throws Exception { + void createsQueryToFindAllEntitiesByStringAttributeNotLike() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameNotLike", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "%John%" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByStringAttributeStartingWith() throws Exception { + void createsQueryToFindAllEntitiesByStringAttributeStartingWith() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameStartingWith", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "Jo" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name"); } @Test // DATAJDBC-318 - public void appendsLikeOperatorParameterWithPercentSymbolForStartingWithQuery() throws Exception { + void appendsLikeOperatorParameterWithPercentSymbolForStartingWithQuery() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameStartingWith", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "Jo" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name"); - assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("first_name")).isEqualTo("Jo%"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "Jo%"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByStringAttributeEndingWith() throws Exception { + void createsQueryToFindAllEntitiesByStringAttributeEndingWith() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameEndingWith", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "hn" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "%hn"); } @Test // DATAJDBC-318 - public void prependsLikeOperatorParameterWithPercentSymbolForEndingWithQuery() throws Exception { + void prependsLikeOperatorParameterWithPercentSymbolForEndingWithQuery() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameEndingWith", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "hn" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name"); - assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("first_name")).isEqualTo("%hn"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "%hn"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByStringAttributeContaining() throws Exception { + void createsQueryToFindAllEntitiesByStringAttributeContaining() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameContaining", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "oh" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "%oh%"); } @Test // DATAJDBC-318 - public void wrapsLikeOperatorParameterWithPercentSymbolsForContainingQuery() throws Exception { + void wrapsLikeOperatorParameterWithPercentSymbolsForContainingQuery() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameContaining", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "oh" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name"); - assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("first_name")).isEqualTo("%oh%"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" LIKE :first_name").hasBindValue("first_name", "%oh%"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByStringAttributeNotContaining() throws Exception { + void createsQueryToFindAllEntitiesByStringAttributeNotContaining() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameNotContaining", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "oh" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name").hasBindValue("first_name", "%oh%"); } @Test // DATAJDBC-318 - public void wrapsLikeOperatorParameterWithPercentSymbolsForNotContainingQuery() throws Exception { + void wrapsLikeOperatorParameterWithPercentSymbolsForNotContainingQuery() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameNotContaining", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "oh" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name"); - assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("first_name")).isEqualTo("%oh%"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" NOT LIKE :first_name").hasBindValue("first_name", "%oh%"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByIntegerAttributeWithDescendingOrderingByStringAttribute() - throws Exception { + void createsQueryToFindAllEntitiesByIntegerAttributeWithDescendingOrderingByStringAttribute() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeOrderByLastNameDesc", Integer.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 123 }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()) - .isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" = :age ORDER BY \"users\".\"LAST_NAME\" DESC"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"AGE\" = :age ORDER BY \"users\".\"LAST_NAME\" DESC"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByIntegerAttributeWithAscendingOrderingByStringAttribute() throws Exception { + void createsQueryToFindAllEntitiesByIntegerAttributeWithAscendingOrderingByStringAttribute() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeOrderByLastNameAsc", Integer.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 123 }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()) - .isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" = :age ORDER BY \"users\".\"LAST_NAME\" ASC"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"AGE\" = :age ORDER BY \"users\".\"LAST_NAME\" ASC"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByStringAttributeNot() throws Exception { + void createsQueryToFindAllEntitiesByStringAttributeNot() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByLastNameNot", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "Doe" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"LAST_NAME\" != :last_name"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"LAST_NAME\" != :last_name"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByIntegerAttributeIn() throws Exception { + void createsQueryToFindAllEntitiesByIntegerAttributeIn() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeIn", Collection.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); @@ -522,56 +542,60 @@ public void createsQueryToFindAllEntitiesByIntegerAttributeIn() throws Exception new Object[] { Collections.singleton(25) }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" IN (:age)"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"AGE\" IN (:age)"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByIntegerAttributeNotIn() throws Exception { + void createsQueryToFindAllEntitiesByIntegerAttributeNotIn() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByAgeNotIn", Collection.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { Collections.singleton(25) }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"AGE\" NOT IN (:age)"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"AGE\" NOT IN (:age)"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByBooleanAttributeTrue() throws Exception { + void createsQueryToFindAllEntitiesByBooleanAttributeTrue() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByActiveTrue"); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[0]); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"ACTIVE\" = :active"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"ACTIVE\" = :active"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByBooleanAttributeFalse() throws Exception { + void createsQueryToFindAllEntitiesByBooleanAttributeFalse() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByActiveFalse"); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[0]); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()).isEqualTo(BASE_SELECT + " WHERE " + TABLE + ".\"ACTIVE\" = :active"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"ACTIVE\" = :active"); } @Test // DATAJDBC-318 - public void createsQueryToFindAllEntitiesByStringAttributeIgnoringCase() throws Exception { + void createsQueryToFindAllEntitiesByStringAttributeIgnoringCase() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameIgnoreCase", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "John" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - assertThat(query.getQuery()) - .isEqualTo(BASE_SELECT + " WHERE UPPER(" + TABLE + ".\"FIRST_NAME\") = UPPER(:first_name)"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE UPPER(" + TABLE + ".\"FIRST_NAME\") = UPPER(:first_name)"); } @Test // DATAJDBC-318 - public void throwsExceptionWhenIgnoringCaseIsImpossible() throws Exception { + void throwsExceptionWhenIgnoringCaseIsImpossible() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findByIdIgnoringCase", Long.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); @@ -581,7 +605,7 @@ public void throwsExceptionWhenIgnoringCaseIsImpossible() throws Exception { } @Test // DATAJDBC-318 - public void throwsExceptionWhenConditionKeywordIsUnsupported() throws Exception { + void throwsExceptionWhenConditionKeywordIsUnsupported() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByIdIsEmpty"); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); @@ -591,7 +615,7 @@ public void throwsExceptionWhenConditionKeywordIsUnsupported() throws Exception } @Test // DATAJDBC-318 - public void throwsExceptionWhenInvalidNumberOfParameterIsGiven() throws Exception { + void throwsExceptionWhenInvalidNumberOfParameterIsGiven() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstName", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); @@ -601,31 +625,31 @@ public void throwsExceptionWhenInvalidNumberOfParameterIsGiven() throws Exceptio } @Test // DATAJDBC-318 - public void createsQueryWithLimitToFindEntitiesByStringAttribute() throws Exception { + void createsQueryWithLimitToFindEntitiesByStringAttribute() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findTop3ByFirstName", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "John" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - String expectedSql = BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name LIMIT 3"; - assertThat(query.getQuery()).isEqualTo(expectedSql); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name LIMIT 3"); } @Test // DATAJDBC-318 - public void createsQueryToFindFirstEntityByStringAttribute() throws Exception { + void createsQueryToFindFirstEntityByStringAttribute() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findFirstByFirstName", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "John" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - String expectedSql = BASE_SELECT + " WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name LIMIT 1"; - assertThat(query.getQuery()).isEqualTo(expectedSql); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"FIRST_NAME\" = :first_name LIMIT 1"); } @Test // DATAJDBC-318 - public void createsQueryByEmbeddedObject() throws Exception { + void createsQueryByEmbeddedObject() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findByAddress", Address.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); @@ -636,7 +660,8 @@ public void createsQueryByEmbeddedObject() throws Exception { String actualSql = query.getQuery(); assertThat(actualSql) // - .startsWith(BASE_SELECT + " WHERE (" + TABLE + ".\"USER_") // + .contains(JOIN_CLAUSE) // + .contains(" WHERE (" + TABLE + ".\"USER_") // .endsWith(")") // .contains(TABLE + ".\"USER_STREET\" = :user_street", // " AND ", // @@ -646,21 +671,19 @@ public void createsQueryByEmbeddedObject() throws Exception { } @Test // DATAJDBC-318 - public void createsQueryByEmbeddedProperty() throws Exception { + void createsQueryByEmbeddedProperty() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("findByAddressStreet", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "Hello" }); ParametrizedQuery query = jdbcQuery.createQuery(accessor, returnedType); - String expectedSql = BASE_SELECT + " WHERE " + TABLE + ".\"USER_STREET\" = :user_street"; - - assertThat(query.getQuery()).isEqualTo(expectedSql); - assertThat(query.getParameterSource(Escaper.DEFAULT).getValue("user_street")).isEqualTo("Hello"); + QueryAssert.assertThat(query).containsQuotedAliasedColumns(columns) + .contains(" WHERE " + TABLE + ".\"USER_STREET\" = :user_street").hasBindValue("user_street", "Hello"); } @Test // DATAJDBC-534 - public void createsQueryForCountProjection() throws Exception { + void createsQueryForCountProjection() throws Exception { JdbcQueryMethod queryMethod = getQueryMethod("countByFirstName", String.class); PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod); diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/QueryAssert.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/QueryAssert.java new file mode 100644 index 0000000000..b656c82c27 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/QueryAssert.java @@ -0,0 +1,115 @@ +/* + * 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.jdbc.repository.query; + +import java.util.Collection; + +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.api.Assertions; + +import org.springframework.data.jdbc.core.dialect.JdbcH2Dialect; +import org.springframework.data.relational.core.dialect.Dialect; +import org.springframework.data.relational.core.dialect.Escaper; +import org.springframework.data.relational.core.sql.Aliased; +import org.springframework.data.relational.core.sql.Column; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; + +/** + * AssertJ Assertions for {@link ParametrizedQuery} allowing to assert the query string and bind values. + * + * @author Mark Paluch + */ +class QueryAssert extends AbstractAssert { + + private static final Dialect DIALECT = JdbcH2Dialect.INSTANCE; + + private QueryAssert(ParametrizedQuery parametrizedQuery) { + super(parametrizedQuery, QueryAssert.class); + } + + /** + * Entrypoint. + * + * @param parametrizedQuery + * @return + */ + public static QueryAssert assertThat(ParametrizedQuery parametrizedQuery) { + return new QueryAssert(parametrizedQuery).as("Query: ", parametrizedQuery.getQuery()); + } + + /** + * Assert that the query contains the given columns, quoted and aliased. + * + * @param columns + * @return + */ + public QueryAssert containsQuotedAliasedColumns(Collection columns) { + + isNotNull(); + + for (Column column : columns) { + + String item = quote(column.getTable().getReferenceName()) + "." + quote(column.getName()); + if (column instanceof Aliased aliased) { + item += " AS " + quote(aliased.getAlias()); + } else { + item += " AS " + quote(column.getReferenceName()); + + } + + Assertions.assertThat(actual.getQuery()).contains(item); + } + + return this; + } + + /** + * Assert that the query contains the given string. + * + * @param expected + * @return + */ + public QueryAssert contains(String expected) { + + isNotNull(); + + Assertions.assertThat(actual.getQuery()).contains(expected); + + return this; + } + + /** + * Assert that the query defines a bind value for the given key and that the value matches the expected value. + * + * @param key + * @param value + * @return + */ + public QueryAssert hasBindValue(String key, Object value) { + + SqlParameterSource parameterSource = actual.getParameterSource(Escaper.DEFAULT); + Assertions.assertThat(parameterSource.getValue(key)) + .describedAs("Parameter source [%s] shouldn contain value [%s] for key [%s]", parameterSource, value, key) + .isEqualTo(value); + + return this; + } + + private static String quote(SqlIdentifier identifier) { + return DIALECT.getIdentifierProcessing().quote(identifier.getReference()); + } +} 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 bb4ab12469..f5befcb930 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 @@ -536,6 +536,22 @@ public ColumnInfo any() { return values.iterator().next(); } + /** + * Create a {@link Map} of {@link AggregatePath} to {@link Column} using the given {@link Table} as column source. + * + * @param table + * @return a {@link Map} of {@link AggregatePath} to {@link Column}s. + * @since 4.0 + */ + public Map toMap(Table table) { + + Map columns = new TreeMap<>(); + + columnInfos.forEach((ag, ci) -> columns.put(ag, table.column(ci.name()))); + + return columns; + } + /** * 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 @@ -547,7 +563,6 @@ public ColumnInfo any() { * {@literal null}. */ public List toColumnList(Table table) { - return columnCache.computeIfAbsent(table, t -> toColumnList((__, ci) -> t.column(ci.name))); } @@ -605,15 +620,6 @@ public void forEach(BiConsumer consumer) { columnInfos.forEach(consumer); } - /** - * Calls the consumer for each pair of {@link AggregatePath} and {@literal ColumnInfo}. - * - * @param consumer the function to call. - */ - public void forEachLong(BiConsumer consumer) { - columnInfos.forEach(consumer); - } - /** * Calls the {@literal mapper} for each pair one pair of {@link AggregatePath} and {@link ColumnInfo}, if there is * any. @@ -664,5 +670,6 @@ public boolean isEmpty() { public int size() { return columnInfos.size(); } + } }