From ddf04bc5cbeabc95d98367bb4f3160d5e134e67c Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Wed, 11 Mar 2026 14:12:51 +0100 Subject: [PATCH 01/12] Prepare issue branch. --- 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 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pom.xml b/pom.xml index 524c7402b8..564377c5eb 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-relational-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-493-SNAPSHOT pom Spring Data Relational Parent diff --git a/spring-data-jdbc-distribution/pom.xml b/spring-data-jdbc-distribution/pom.xml index 824e70541b..20e3e06fa4 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.1.0-SNAPSHOT + 4.1.x-GH-493-SNAPSHOT ../pom.xml diff --git a/spring-data-jdbc/pom.xml b/spring-data-jdbc/pom.xml index 87567d6220..f8e18bd805 100644 --- a/spring-data-jdbc/pom.xml +++ b/spring-data-jdbc/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-jdbc - 4.1.0-SNAPSHOT + 4.1.x-GH-493-SNAPSHOT Spring Data JDBC Spring Data module for JDBC repositories. @@ -15,7 +15,7 @@ org.springframework.data spring-data-relational-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-493-SNAPSHOT diff --git a/spring-data-r2dbc/pom.xml b/spring-data-r2dbc/pom.xml index 98a4e11e60..65b342d91b 100644 --- a/spring-data-r2dbc/pom.xml +++ b/spring-data-r2dbc/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-r2dbc - 4.1.0-SNAPSHOT + 4.1.x-GH-493-SNAPSHOT Spring Data R2DBC Spring Data module for R2DBC @@ -15,7 +15,7 @@ org.springframework.data spring-data-relational-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-493-SNAPSHOT diff --git a/spring-data-relational/pom.xml b/spring-data-relational/pom.xml index 652ad2853d..1a1bdff1ce 100644 --- a/spring-data-relational/pom.xml +++ b/spring-data-relational/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-relational - 4.1.0-SNAPSHOT + 4.1.x-GH-493-SNAPSHOT Spring Data Relational Spring Data Relational support @@ -14,7 +14,7 @@ org.springframework.data spring-data-relational-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-493-SNAPSHOT From 8fc42a8212d383a6e9c3e857a90c75ac500a3b36 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Thu, 12 Mar 2026 11:36:20 +0100 Subject: [PATCH 02/12] Upsert - Hacking --- .../jdbc/core/JdbcAggregateOperations.java | 12 +++ .../data/jdbc/core/JdbcAggregateTemplate.java | 18 +++- .../convert/CascadingDataAccessStrategy.java | 5 + .../jdbc/core/convert/DataAccessStrategy.java | 14 +++ .../convert/DefaultDataAccessStrategy.java | 20 ++++ .../convert/DelegatingDataAccessStrategy.java | 5 + .../data/jdbc/core/convert/SqlGenerator.java | 29 ++++++ .../jdbc/core/dialect/JdbcDb2Dialect.java | 7 ++ .../data/jdbc/core/dialect/JdbcH2Dialect.java | 8 ++ .../jdbc/core/dialect/JdbcHsqlDbDialect.java | 8 ++ .../jdbc/core/dialect/JdbcMariaDbDialect.java | 8 ++ .../jdbc/core/dialect/JdbcMySqlDialect.java | 7 ++ .../jdbc/core/dialect/JdbcOracleDialect.java | 8 ++ .../core/dialect/JdbcPostgresDialect.java | 7 ++ .../core/dialect/JdbcSqlServerDialect.java | 7 ++ .../dialect/MergeUpsertRenderContext.java | 90 ++++++++++++++++++ .../dialect/MySqlUpsertRenderContext.java | 75 +++++++++++++++ .../OracleMergeUpsertRenderContext.java | 89 ++++++++++++++++++ .../dialect/PostgresUpsertRenderContext.java | 79 ++++++++++++++++ .../SqlServerMergeUpsertRenderContext.java | 48 ++++++++++ .../mybatis/MyBatisDataAccessStrategy.java | 5 + ...JdbcAggregateTemplateIntegrationTests.java | 32 +++++++ ...AggregateTemplateHsqlIntegrationTests.java | 40 ++++++++ .../core/convert/SqlGeneratorUnitTests.java | 26 +++++ .../dialect/UpsertRenderContextUnitTests.java | 94 +++++++++++++++++++ .../data/relational/core/dialect/Dialect.java | 11 +++ .../core/dialect/UpsertRenderContext.java | 53 +++++++++++ .../ROOT/pages/jdbc/entity-persistence.adoc | 7 ++ 28 files changed, 810 insertions(+), 2 deletions(-) create mode 100644 spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MergeUpsertRenderContext.java create mode 100644 spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MySqlUpsertRenderContext.java create mode 100644 spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/OracleMergeUpsertRenderContext.java create mode 100644 spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/PostgresUpsertRenderContext.java create mode 100644 spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/SqlServerMergeUpsertRenderContext.java create mode 100644 spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/dialect/UpsertRenderContextUnitTests.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/UpsertRenderContext.java diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java index f8d4c5e83e..4fef3847d1 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java @@ -115,6 +115,18 @@ public interface JdbcAggregateOperations { */ List updateAll(Iterable instances); + /** + * Upserts a single aggregate root (insert if row for id does not exist, update if it exists). The instance must have + * an id set. Only supported when the dialect supports single-statement upsert. + * + * @param instance the aggregate root to upsert. Must not be {@code null}. Must have an id set. + * @param the type of the aggregate root. + * @return the same instance (possibly with generated id set if the dialect returns one). + * @throws UnsupportedOperationException if the dialect does not support upsert. + * @since 4.x + */ + T upsert(T instance); + /** * Counts the number of aggregates of a given type. * diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java index 41d263e34f..b4ee10bd32 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java @@ -41,6 +41,7 @@ import org.springframework.data.domain.Sort; import org.springframework.data.jdbc.core.convert.DataAccessStrategy; import org.springframework.data.jdbc.core.convert.EntityRowMapper; +import org.springframework.data.jdbc.core.convert.Identifier; import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.mapping.IdentifierAccessor; import org.springframework.data.mapping.callback.EntityCallbacks; @@ -266,6 +267,17 @@ public List updateAll(Iterable instances) { return doInBatch(instances, entity -> createUpdateChange(prepareVersionForUpdate(entity))); } + @Override + @SuppressWarnings("unchecked") + public T upsert(T instance) { + + Assert.notNull(instance, "Aggregate instance must not be null"); + + Class entityType = (Class) ClassUtils.getUserClass(instance); + accessStrategy.upsert(instance, entityType, Identifier.empty()); + return instance; + } + private List saveInBatch(Iterable instances, Function> changes) { Assert.notNull(instances, "Aggregate instances must not be null"); @@ -734,7 +746,8 @@ private T triggerAfterSave(T aggregateRoot, AggregateChange change) { private void triggerAfterDelete(@Nullable T aggregateRoot, Object id, AggregateChange change) { - eventDelegate.publishEvent(() -> new AfterDeleteEvent<>(Identifier.of(id), aggregateRoot, change)); + eventDelegate.publishEvent(() -> new AfterDeleteEvent<>( + org.springframework.data.relational.core.mapping.event.Identifier.of(id), aggregateRoot, change)); if (aggregateRoot != null && entityCallbacks != null) { entityCallbacks.callback(AfterDeleteCallback.class, aggregateRoot); @@ -744,7 +757,8 @@ private void triggerAfterDelete(@Nullable T aggregateRoot, Object id, Aggreg @Nullable private T triggerBeforeDelete(@Nullable T aggregateRoot, Object id, MutableAggregateChange change) { - eventDelegate.publishEvent(() -> new BeforeDeleteEvent<>(Identifier.of(id), aggregateRoot, change)); + eventDelegate.publishEvent(() -> new BeforeDeleteEvent<>( + org.springframework.data.relational.core.mapping.event.Identifier.of(id), aggregateRoot, change)); if (aggregateRoot != null && entityCallbacks != null) { return entityCallbacks.callback(BeforeDeleteCallback.class, aggregateRoot, change); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java index c187dc726d..9a62a0a2ee 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java @@ -87,6 +87,11 @@ public NamedParameterJdbcOperations getJdbcOperations() { return collect(das -> das.insert(insertSubjects, domainType, idValueSource)); } + @Override + public @Nullable Object upsert(T instance, Class domainType, Identifier identifier) { + return collect(das -> das.upsert(instance, domainType, identifier)); + } + @Override public boolean update(S instance, Class domainType) { return collect(das -> das.update(instance, domainType)); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java index d07e96e328..734f3aed5d 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java @@ -118,6 +118,20 @@ public interface DataAccessStrategy extends ReadingDataAccessStrategy, RelationR */ boolean updateWithVersion(T instance, Class domainType, Number previousVersion); + /** + * Upserts the data of a single entity (insert if row for id does not exist, update if it exists). Requires a + * provided id. Only supported when the dialect supports single-statement upsert. + * + * @param instance the instance to upsert. Must not be {@code null}. Must have an id set. + * @param domainType the type of the instance. Must not be {@code null}. + * @param identifier information about data that needs to be considered (e.g. back-references). May be empty for root. + * @param the type of the instance. + * @return the id generated by the database if any (typically {@code null} when id is provided). + * @throws UnsupportedOperationException if the dialect does not support upsert. + * @since 4.x + */ + @Nullable Object upsert(T instance, Class domainType, Identifier identifier); + /** * Deletes a single row identified by the id, from the table identified by the domainType. Does not handle cascading * deletes. 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 cd7e5b6b09..bbfb5835e6 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 @@ -24,6 +24,8 @@ import java.util.Optional; import java.util.stream.Stream; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; import org.springframework.dao.EmptyResultDataAccessException; @@ -69,6 +71,8 @@ */ public class DefaultDataAccessStrategy implements DataAccessStrategy { + private final Log logger = LogFactory.getLog(getClass()); + private final SqlGeneratorSource sqlGeneratorSource; private final RelationalMappingContext context; private final JdbcConverter converter; @@ -179,6 +183,22 @@ public boolean updateWithVersion(S instance, Class domainType, Number pre return true; } + @Override + public @Nullable Object upsert(T instance, Class domainType, Identifier identifier) { + + SqlIdentifierParameterSource parameterSource = sqlParametersFactory.forInsert(instance, domainType, identifier, + IdValueSource.PROVIDED); + + String upsertSql = sql(domainType).getUpsert(parameterSource.getIdentifiers()); + + if(logger.isTraceEnabled()) { + logger.trace("Upsert SQL: " + upsertSql); + } + + operations.update(upsertSql, parameterSource); + return null; + } + @Override public void delete(Object id, Class domainType) { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java index acfcf90efd..1c4358bbd0 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java @@ -80,6 +80,11 @@ public NamedParameterJdbcOperations getJdbcOperations() { return delegate.insert(insertSubjects, domainType, idValueSource); } + @Override + public @Nullable Object upsert(T instance, Class domainType, Identifier identifier) { + return delegate.upsert(instance, domainType, identifier); + } + @Override public boolean update(S instance, Class domainType) { return delegate.update(instance, domainType); 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 3df0f4c3d5..469b026933 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 @@ -39,6 +39,7 @@ import org.springframework.data.mapping.context.InvalidPersistentPropertyPath; import org.springframework.data.relational.core.dialect.Dialect; import org.springframework.data.relational.core.dialect.RenderContextFactory; +import org.springframework.data.relational.core.dialect.UpsertRenderContext; import org.springframework.data.relational.core.mapping.AggregatePath; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; @@ -394,6 +395,34 @@ String getInsert(Set additionalColumns) { return createInsertSql(additionalColumns); } + /** + * Create a dialect-specific upsert statement (insert or update by id). Requires the dialect to support upsert via + * {@link Dialect#getUpsertRenderContext()}. + * + * @param additionalColumns additional column names to include in the insert (e.g. back-references). + * @return the upsert SQL statement. + * @throws UnsupportedOperationException if the dialect does not support upsert. + */ + String getUpsert(Set additionalColumns) { + + // TODO: create some nice api like the one we have for inserts + + UpsertRenderContext context = dialect.getUpsertRenderContext() + .orElseThrow(() -> new UnsupportedOperationException( + "Upsert is not supported by dialect " + dialect.getClass().getName())); + Table table = getTable(); + Set columnNamesForInsert = new TreeSet<>(Comparator.comparing(SqlIdentifier::getReference)); + columnNamesForInsert.addAll(columns.getInsertableColumns()); + columnNamesForInsert.addAll(additionalColumns); + List conflictColumns = getIdColumns().stream().map(Column::getName).toList(); + columnNamesForInsert.addAll(conflictColumns); + List insertColumns = new ArrayList<>(columnNamesForInsert); + Function bindMarkerFn = cn -> ":" + + BindParameterNameSanitizer.sanitize(renderReference(cn)); + return context.renderUpsert(table, insertColumns, conflictColumns, bindMarkerFn, + dialect.getIdentifierProcessing()); + } + /** * Create a {@code UPDATE … SET …} statement. * diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcDb2Dialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcDb2Dialect.java index a88bdb9a89..86e92e64d7 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcDb2Dialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcDb2Dialect.java @@ -20,11 +20,13 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Optional; import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.WritingConverter; import org.springframework.data.jdbc.core.convert.Jsr310TimestampBasedConverters; import org.springframework.data.relational.core.dialect.Db2Dialect; +import org.springframework.data.relational.core.dialect.UpsertRenderContext; /** * {@link Db2Dialect} that registers JDBC specific converters. @@ -39,6 +41,11 @@ public class JdbcDb2Dialect extends Db2Dialect implements JdbcDialect { protected JdbcDb2Dialect() {} + @Override + public Optional getUpsertRenderContext() { + return Optional.of(MergeUpsertRenderContext.INSTANCE); + } + @Override public Collection getConverters() { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcH2Dialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcH2Dialect.java index 83806c5b0e..3310a70fd1 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcH2Dialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcH2Dialect.java @@ -15,7 +15,10 @@ */ package org.springframework.data.jdbc.core.dialect; +import java.util.Optional; + import org.springframework.data.relational.core.dialect.H2Dialect; +import org.springframework.data.relational.core.dialect.UpsertRenderContext; /** * JDBC-specific H2 Dialect. @@ -34,4 +37,9 @@ public JdbcArrayColumns getArraySupport() { return ARRAY_COLUMNS; } + @Override + public Optional getUpsertRenderContext() { + return Optional.of(MergeUpsertRenderContext.INSTANCE); + } + } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcHsqlDbDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcHsqlDbDialect.java index affd91707b..61a6defb80 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcHsqlDbDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcHsqlDbDialect.java @@ -15,7 +15,10 @@ */ package org.springframework.data.jdbc.core.dialect; +import java.util.Optional; + import org.springframework.data.relational.core.dialect.HsqlDbDialect; +import org.springframework.data.relational.core.dialect.UpsertRenderContext; /** * JDBC-specific HsqlDB Dialect. @@ -32,4 +35,9 @@ public JdbcArrayColumns getArraySupport() { return JdbcArrayColumns.DefaultSupport.INSTANCE; } + @Override + public Optional getUpsertRenderContext() { + return Optional.of(MergeUpsertRenderContext.INSTANCE); + } + } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMariaDbDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMariaDbDialect.java index c6cd8f9902..7c03a26f66 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMariaDbDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMariaDbDialect.java @@ -15,7 +15,10 @@ */ package org.springframework.data.jdbc.core.dialect; +import java.util.Optional; + import org.springframework.data.relational.core.dialect.MariaDbDialect; +import org.springframework.data.relational.core.dialect.UpsertRenderContext; import org.springframework.data.relational.core.sql.IdentifierProcessing; /** @@ -30,4 +33,9 @@ public JdbcMariaDbDialect(IdentifierProcessing identifierProcessing) { super(identifierProcessing); } + @Override + public Optional getUpsertRenderContext() { + return Optional.of(MySqlUpsertRenderContext.INSTANCE); + } + } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMySqlDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMySqlDialect.java index 07008a6e83..081f7aee56 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMySqlDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMySqlDialect.java @@ -23,12 +23,14 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Date; +import java.util.Optional; import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.ReadingConverter; import org.springframework.data.convert.WritingConverter; import org.springframework.data.jdbc.core.mapping.JdbcValue; import org.springframework.data.relational.core.dialect.MySqlDialect; +import org.springframework.data.relational.core.dialect.UpsertRenderContext; import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.lang.NonNull; @@ -60,6 +62,11 @@ public JdbcMySqlDialect(IdentifierProcessing identifierProcessing) { protected JdbcMySqlDialect() {} + @Override + public Optional getUpsertRenderContext() { + return Optional.of(MySqlUpsertRenderContext.INSTANCE); + } + @Override public Collection getConverters() { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcOracleDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcOracleDialect.java index a1886b13d9..d2f3ec88e5 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcOracleDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcOracleDialect.java @@ -16,8 +16,11 @@ package org.springframework.data.jdbc.core.dialect; +import java.util.Optional; + import org.springframework.data.relational.core.dialect.ObjectArrayColumns; import org.springframework.data.relational.core.dialect.OracleDialect; +import org.springframework.data.relational.core.dialect.UpsertRenderContext; /** * JDBC-specific Oracle Dialect. @@ -35,4 +38,9 @@ public JdbcArrayColumns getArraySupport() { return ARRAY_COLUMNS; } + @Override + public Optional getUpsertRenderContext() { + return Optional.of(OracleMergeUpsertRenderContext.INSTANCE); + } + } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcPostgresDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcPostgresDialect.java index 6072ccdc5d..27eb8e98b9 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcPostgresDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcPostgresDialect.java @@ -27,6 +27,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.function.Consumer; @@ -34,6 +35,7 @@ import org.postgresql.core.Oid; import org.postgresql.jdbc.TypeInfoCache; import org.springframework.data.relational.core.dialect.PostgresDialect; +import org.springframework.data.relational.core.dialect.UpsertRenderContext; import org.springframework.util.ClassUtils; /** @@ -78,6 +80,11 @@ public JdbcArrayColumns getArraySupport() { return ARRAY_COLUMNS; } + @Override + public Optional getUpsertRenderContext() { + return Optional.of(PostgresUpsertRenderContext.INSTANCE); + } + /** * Creates a Postgres {@link SQLType} for the given name and vendor type number. * diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcSqlServerDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcSqlServerDialect.java index 86a2ddf916..235f7bcc7a 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcSqlServerDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcSqlServerDialect.java @@ -22,6 +22,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Optional; import java.util.Set; import org.jspecify.annotations.Nullable; @@ -29,6 +30,7 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.ReadingConverter; import org.springframework.data.relational.core.dialect.SqlServerDialect; +import org.springframework.data.relational.core.dialect.UpsertRenderContext; import org.springframework.data.util.ClassUtils; /** @@ -65,6 +67,11 @@ public Set> simpleTypes() { return SIMPLE_TYPES; } + @Override + public Optional getUpsertRenderContext() { + return Optional.of(SqlServerMergeUpsertRenderContext.INSTANCE); + } + @Override public Collection getConverters() { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MergeUpsertRenderContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MergeUpsertRenderContext.java new file mode 100644 index 0000000000..ec82ac8174 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MergeUpsertRenderContext.java @@ -0,0 +1,90 @@ +/* + * Copyright 2026-present 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.dialect; + +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.IdentifierProcessing; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.util.Assert; + +/** + * Standard SQL {@code MERGE} upsert for dialects that support it (H2, HSQLDB, SQL Server, DB2). + *

+ * Uses a table value constructor {@code (VALUES (?, ?)) AS s (col1, col2)} as the source so that + * no SELECT is used. This avoids H2 interpreting {@code ? AS "ID"} as a type cast (unknown data + * type "ID"), and avoids HSQLDB requiring a FROM clause on SELECT. + * + * @since 4.x + */ +public enum MergeUpsertRenderContext implements UpsertRenderContext { + + INSTANCE; + + @Override + public boolean supportsUpsert() { + return true; + } + + @Override + public String renderUpsert(Table table, List insertColumns, List conflictColumns, + Function bindMarkerFn, IdentifierProcessing identifierProcessing) { + + Assert.notEmpty(insertColumns, "Insert columns must not be empty"); + Assert.notEmpty(conflictColumns, "Conflict columns must not be empty"); + + Set conflictSet = Set.copyOf(conflictColumns); + String tableSql = table.getName().toSql(identifierProcessing); + + String valuesList = String.join(", ", insertColumns.stream().map(bindMarkerFn).toList()); + String sourceColumnsSql = String.join(", ", + insertColumns.stream().map(col -> col.toSql(identifierProcessing)).toList()); + + String onCondition = String.join(" AND ", conflictColumns.stream() + .map(col -> "t." + col.toSql(identifierProcessing) + " = s." + col.toSql(identifierProcessing)) + .toList()); + + List updateColumns = insertColumns.stream() + .filter(col -> !conflictSet.contains(col)) + .toList(); + + String updateSetClause; + if (updateColumns.isEmpty()) { + SqlIdentifier firstConflict = conflictColumns.get(0); + updateSetClause = "t." + firstConflict.toSql(identifierProcessing) + " = s." + + firstConflict.toSql(identifierProcessing); + } else { + updateSetClause = String.join(", ", updateColumns.stream() + .map(col -> "t." + col.toSql(identifierProcessing) + " = s." + col.toSql(identifierProcessing)) + .toList()); + } + + String insertColumnsSql = String.join(", ", + insertColumns.stream().map(col -> col.toSql(identifierProcessing)).toList()); + + String insertValuesSql = String.join(", ", + insertColumns.stream().map(col -> "s." + col.toSql(identifierProcessing)).toList()); + + return "MERGE INTO " + tableSql + " t USING (VALUES (" + valuesList + ")) AS s (" + sourceColumnsSql + ") ON " + + onCondition + + " WHEN MATCHED THEN UPDATE SET " + updateSetClause + + " WHEN NOT MATCHED THEN INSERT (" + insertColumnsSql + ") VALUES (" + insertValuesSql + ")"; + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MySqlUpsertRenderContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MySqlUpsertRenderContext.java new file mode 100644 index 0000000000..8202e47f26 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MySqlUpsertRenderContext.java @@ -0,0 +1,75 @@ +/* + * Copyright 2026-present 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.dialect; + +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.IdentifierProcessing; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.util.Assert; + +/** + * MySQL / MariaDB upsert using {@code INSERT ... ON DUPLICATE KEY UPDATE}. + * + * @since 4.x + */ +public enum MySqlUpsertRenderContext implements UpsertRenderContext { + + INSTANCE; + + @Override + public boolean supportsUpsert() { + return true; + } + + @Override + public String renderUpsert(Table table, List insertColumns, List conflictColumns, + Function bindMarkerFn, IdentifierProcessing identifierProcessing) { + + Assert.notEmpty(insertColumns, "Insert columns must not be empty"); + Assert.notEmpty(conflictColumns, "Conflict columns must not be empty"); + + Set conflictSet = Set.copyOf(conflictColumns); + String tableSql = table.getName().toSql(identifierProcessing); + + String columnsSql = String.join(", ", + insertColumns.stream().map(col -> col.toSql(identifierProcessing)).toList()); + + String valuesSql = String.join(", ", insertColumns.stream().map(bindMarkerFn).toList()); + + List updateColumns = insertColumns.stream() + .filter(col -> !conflictSet.contains(col)) + .toList(); + + String setClause; + if (updateColumns.isEmpty()) { + SqlIdentifier firstConflict = conflictColumns.get(0); + setClause = firstConflict.toSql(identifierProcessing) + " = VALUES(" + + firstConflict.toSql(identifierProcessing) + ")"; + } else { + setClause = String.join(", ", updateColumns.stream() + .map(col -> col.toSql(identifierProcessing) + " = VALUES(" + col.toSql(identifierProcessing) + ")") + .toList()); + } + + return "INSERT INTO " + tableSql + " (" + columnsSql + ") VALUES (" + valuesSql + ")" + + " ON DUPLICATE KEY UPDATE " + setClause; + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/OracleMergeUpsertRenderContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/OracleMergeUpsertRenderContext.java new file mode 100644 index 0000000000..c37be7a897 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/OracleMergeUpsertRenderContext.java @@ -0,0 +1,89 @@ +/* + * Copyright 2026-present 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.dialect; + +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.IdentifierProcessing; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.util.Assert; + +/** + * Oracle MERGE upsert. Uses {@code SELECT ... FROM DUAL} as the source, which Oracle requires for + * literal/bind-marker-only selects. + * + * @since 4.x + */ +public enum OracleMergeUpsertRenderContext implements UpsertRenderContext { + + INSTANCE; + + private static final String SOURCE_FROM_CLAUSE = " FROM DUAL"; + + @Override + public boolean supportsUpsert() { + return true; + } + + @Override + public String renderUpsert(Table table, List insertColumns, List conflictColumns, + Function bindMarkerFn, IdentifierProcessing identifierProcessing) { + + Assert.notEmpty(insertColumns, "Insert columns must not be empty"); + Assert.notEmpty(conflictColumns, "Conflict columns must not be empty"); + + Set conflictSet = Set.copyOf(conflictColumns); + String tableSql = table.getName().toSql(identifierProcessing); + + String sourceSelectList = String.join(", ", insertColumns.stream() + .map(col -> bindMarkerFn.apply(col) + " AS " + col.toSql(identifierProcessing)) + .toList()); + + String onCondition = "(" + String.join(" AND ", conflictColumns.stream() + .map(col -> "t." + col.toSql(identifierProcessing) + " = s." + col.toSql(identifierProcessing)) + .toList()) + ")"; + + List updateColumns = insertColumns.stream() + .filter(col -> !conflictSet.contains(col)) + .toList(); + + String updateSetClause; + if (updateColumns.isEmpty()) { + SqlIdentifier firstConflict = conflictColumns.get(0); + updateSetClause = "t." + firstConflict.toSql(identifierProcessing) + " = s." + + firstConflict.toSql(identifierProcessing); + } else { + updateSetClause = String.join(", ", updateColumns.stream() + .map(col -> "t." + col.toSql(identifierProcessing) + " = s." + col.toSql(identifierProcessing)) + .toList()); + } + + String insertColumnsSql = String.join(", ", + insertColumns.stream().map(col -> col.toSql(identifierProcessing)).toList()); + + String insertValuesSql = String.join(", ", + insertColumns.stream().map(col -> "s." + col.toSql(identifierProcessing)).toList()); + + return "MERGE INTO " + tableSql + " t USING (SELECT " + sourceSelectList + SOURCE_FROM_CLAUSE + ") s ON " + + onCondition + + " WHEN MATCHED THEN UPDATE SET " + updateSetClause + + " WHEN NOT MATCHED THEN INSERT (" + insertColumnsSql + ") VALUES (" + insertValuesSql + ")"; + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/PostgresUpsertRenderContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/PostgresUpsertRenderContext.java new file mode 100644 index 0000000000..f685337d62 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/PostgresUpsertRenderContext.java @@ -0,0 +1,79 @@ +/* + * Copyright 2026-present 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.dialect; + +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.IdentifierProcessing; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.util.Assert; + +/** + * PostgreSQL upsert using {@code INSERT ... ON CONFLICT ... DO UPDATE SET}. + * + * @since 4.x + */ +public enum PostgresUpsertRenderContext implements UpsertRenderContext { + + INSTANCE; + + @Override + public boolean supportsUpsert() { + return true; + } + + @Override + public String renderUpsert(Table table, List insertColumns, List conflictColumns, + Function bindMarkerFn, IdentifierProcessing identifierProcessing) { + + Assert.notEmpty(insertColumns, "Insert columns must not be empty"); + Assert.notEmpty(conflictColumns, "Conflict columns must not be empty"); + + Set conflictSet = Set.copyOf(conflictColumns); + String tableSql = table.getName().toSql(identifierProcessing); + + String columnsSql = String.join(", ", + insertColumns.stream().map(col -> col.toSql(identifierProcessing)).toList()); + + String valuesSql = String.join(", ", insertColumns.stream().map(bindMarkerFn).toList()); + + String conflictSql = String.join(", ", + conflictColumns.stream().map(col -> col.toSql(identifierProcessing)).toList()); + + List updateColumns = insertColumns.stream() + .filter(col -> !conflictSet.contains(col)) + .toList(); + + String setClause; + if (updateColumns.isEmpty()) { + // PostgreSQL requires at least one SET; use conflict column as no-op + SqlIdentifier firstConflict = conflictColumns.get(0); + setClause = firstConflict.toSql(identifierProcessing) + " = EXCLUDED." + + firstConflict.toSql(identifierProcessing); + } else { + setClause = String.join(", ", updateColumns.stream() + .map(col -> col.toSql(identifierProcessing) + " = EXCLUDED." + col.toSql(identifierProcessing)) + .toList()); + } + + return "INSERT INTO " + tableSql + " (" + columnsSql + ") VALUES (" + valuesSql + ")" + + " ON CONFLICT (" + conflictSql + ") DO UPDATE SET " + setClause; + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/SqlServerMergeUpsertRenderContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/SqlServerMergeUpsertRenderContext.java new file mode 100644 index 0000000000..72e92f71a4 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/SqlServerMergeUpsertRenderContext.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026-present 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.dialect; + +import java.util.List; +import java.util.function.Function; + +import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.IdentifierProcessing; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.Table; + +/** + * SQL Server MERGE upsert. Delegates to {@link MergeUpsertRenderContext} and appends a required semicolon. + * + * @since 4.x + */ +public enum SqlServerMergeUpsertRenderContext implements UpsertRenderContext { + + INSTANCE; + + private static final String STATEMENT_TERMINATOR = ";"; + + @Override + public boolean supportsUpsert() { + return true; + } + + @Override + public String renderUpsert(Table table, List insertColumns, List conflictColumns, + Function bindMarkerFn, IdentifierProcessing identifierProcessing) { + return MergeUpsertRenderContext.INSTANCE.renderUpsert(table, insertColumns, conflictColumns, bindMarkerFn, + identifierProcessing) + STATEMENT_TERMINATOR; + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java index efd431aecd..1a38c1ca48 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java @@ -184,6 +184,11 @@ public void setNamespaceStrategy(NamespaceStrategy namespaceStrategy) { .toArray(); } + @Override + public @Nullable Object upsert(T instance, Class domainType, Identifier identifier) { + throw new UnsupportedOperationException("Upsert is not supported by MyBatisDataAccessStrategy"); + } + @Override public boolean update(S instance, Class domainType) { diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java index bf2ab36884..d87161ef2f 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java @@ -18,6 +18,7 @@ import static java.util.Arrays.*; import static java.util.Collections.*; import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assumptions.assumeThat; import static org.assertj.core.api.SoftAssertions.*; import static org.springframework.data.jdbc.testing.TestConfiguration.*; import static org.springframework.data.jdbc.testing.TestDatabaseFeatures.Feature.*; @@ -51,6 +52,7 @@ import org.springframework.data.jdbc.testing.TestConfiguration; import org.springframework.data.jdbc.testing.TestDatabaseFeatures; import org.springframework.data.mapping.context.InvalidPersistentPropertyPath; +import org.springframework.data.relational.core.dialect.SqlServerDialect; import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.mapping.Embedded; import org.springframework.data.relational.core.mapping.InsertOnlyProperty; @@ -187,6 +189,36 @@ private static LegoSet createLegoSet(String name) { return entity; } + @Test // GH-493 + void upsertInsertsWhenIdDoesNotExistAndUpdatesWhenItExists() { + + assumeThat(template).isInstanceOf(JdbcAggregateTemplate.class); + JdbcAggregateTemplate jdbcTemplate = (JdbcAggregateTemplate) template; + assumeThat(jdbcTemplate.getDataAccessStrategy().getDialect().getUpsertRenderContext().isEmpty()).isFalse(); + + if(template.getDataAccessStrategy().getDialect() instanceof SqlServerDialect) { + String tableName = "with_insert_only"; + jdbc.getJdbcOperations().execute("SET IDENTITY_INSERT " + tableName + " ON"); + } + + WithInsertOnly entity = new WithInsertOnly(); + entity.id = 8888L; + entity.insertOnly = "upserted"; + template.upsert(entity); + + assertThat(template.findById(8888L, WithInsertOnly.class).insertOnly).isEqualTo("upserted"); + + entity.insertOnly = "updated"; + template.upsert(entity); + + assertThat(template.findById(8888L, WithInsertOnly.class).insertOnly).isEqualTo("updated"); + + if(template.getDataAccessStrategy().getDialect() instanceof SqlServerDialect) { + String tableName = "with_insert_only"; + jdbc.getJdbcOperations().execute("SET IDENTITY_INSERT " + tableName + " OFF"); + } + } + @Test // GH-1446 void findById() { 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 60d593b542..2a37cb13c0 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 @@ -60,6 +60,19 @@ void saveAndLoadSimpleEntity() { assertThat(reloaded).isEqualTo(entity); } + @Test // GH-493 + void upsertAndLoadSimpleEntity() { + + SimpleEntity entity = template.upsert(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() { @@ -85,6 +98,17 @@ void saveAndLoadSimpleEntityWithEmbeddedPk() { assertThat(reloaded).isEqualTo(entity); } + @Test // GH-493 + void upsertAndLoadSimpleEntityWithEmbeddedPk() { + + SimpleEntityWithEmbeddedPk entity = template + .upsert(new SimpleEntityWithEmbeddedPk(new EmbeddedPk(23L, "x"), "alpha")); + + SimpleEntityWithEmbeddedPk reloaded = template.findById(entity.embeddedPk, SimpleEntityWithEmbeddedPk.class); + + assertThat(reloaded).isEqualTo(entity); + } + @Test // GH-574 void saveAndLoadSimpleEntitiesWithEmbeddedPk() { @@ -158,6 +182,22 @@ void updateSingleSimpleEntityWithEmbeddedPk() { assertThat(reloaded).containsExactlyInAnyOrder(updated, entities.get(1), entities.get(2)); } + @Test // GH-493 + void upsertUpdatesExistingSingleSimpleEntityWithEmbeddedPk() { + + 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.upsert(updated); + + Iterable reloaded = template.findAll(SimpleEntityWithEmbeddedPk.class); + + assertThat(reloaded).containsExactlyInAnyOrder(updated, entities.get(1), entities.get(2)); + } + @Test // GH-574 void saveAndLoadSingleReferenceAggregate() { 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 bdd2884b41..075607f0ef 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 @@ -599,6 +599,32 @@ void getInsertForQuotedColumnName() { + "(\"test\"\"_@123\") " + "VALUES (:test_123)"); } + @Test // GH-493 + void getUpsertThrowsWhenDialectDoesNotSupportUpsert() { + + SqlGenerator sqlGenerator = createSqlGenerator(DummyEntity.class); + + assertThatThrownBy(() -> sqlGenerator.getUpsert(emptySet())) // + .isInstanceOf(UnsupportedOperationException.class) // + .hasMessageContaining("Upsert is not supported"); + } + + @Test // GH-493 + void getUpsertReturnsSqlWhenDialectSupportsUpsert() { + + SqlGenerator sqlGenerator = createSqlGenerator(DummyEntity.class, JdbcPostgresDialect.INSTANCE); + + String upsert = sqlGenerator.getUpsert(emptySet()); + + assertThat(upsert) // + .startsWith("INSERT INTO") // + .contains("ON CONFLICT") // + .contains("DO UPDATE SET") // + .contains(":id1") // + .contains(":x_name") // + .contains(":x_other"); + } + @Test // DATAJDBC-266 void joinForOneToOneWithoutIdIncludesTheBackReferenceOfTheOuterJoin() { diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/dialect/UpsertRenderContextUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/dialect/UpsertRenderContextUnitTests.java new file mode 100644 index 0000000000..1d938f2118 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/dialect/UpsertRenderContextUnitTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026-present 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.dialect; + +import java.util.List; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; +import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.IdentifierProcessing; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.Table; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link UpsertRenderContext} implementations. + */ +class UpsertRenderContextUnitTests { + + private static final Table TABLE = Table.create(SqlIdentifier.unquoted("my_table")); + private static final List INSERT_COLUMNS = List.of(SqlIdentifier.unquoted("id"), + SqlIdentifier.unquoted("name")); + private static final List CONFLICT_COLUMNS = List.of(SqlIdentifier.unquoted("id")); + private static final Function BIND_MARKER = id -> ":" + id.getReference(); + private static final IdentifierProcessing IDENTIFIER_PROCESSING = IdentifierProcessing.ANSI; + + @Test // GH-493 + void postgresUpsertRendersInsertOnConflictDoUpdate() { + + String sql = PostgresUpsertRenderContext.INSTANCE.renderUpsert(TABLE, INSERT_COLUMNS, CONFLICT_COLUMNS, + BIND_MARKER, IDENTIFIER_PROCESSING); + + assertThat(sql).startsWith("INSERT INTO"); + assertThat(sql).contains("my_table"); + assertThat(sql).contains("id"); + assertThat(sql).contains("name"); + assertThat(sql).contains(":id"); + assertThat(sql).contains(":name"); + assertThat(sql).contains("ON CONFLICT ("); + assertThat(sql).contains("DO UPDATE SET"); + assertThat(sql).contains("EXCLUDED"); + } + + @Test // GH-493 + void mergeUpsertRendersMergeInto() { + + String sql = MergeUpsertRenderContext.INSTANCE.renderUpsert(TABLE, INSERT_COLUMNS, CONFLICT_COLUMNS, + BIND_MARKER, IDENTIFIER_PROCESSING); + + assertThat(sql).isEqualTo( + "MERGE INTO my_table t USING (VALUES (:id, :name)) AS s (id, name) ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.name = s.name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name)"); + } + + @Test // GH-493 + void mySqlUpsertRendersOnDuplicateKeyUpdate() { + + String sql = MySqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, INSERT_COLUMNS, CONFLICT_COLUMNS, + BIND_MARKER, IDENTIFIER_PROCESSING); + + assertThat(sql).startsWith("INSERT INTO"); + assertThat(sql).contains("my_table"); + assertThat(sql).contains("ON DUPLICATE KEY UPDATE"); + assertThat(sql).contains("VALUES("); + } + + @Test // GH-493 + void oracleMergeUpsertRendersOnConditionInParentheses() { + + String sql = OracleMergeUpsertRenderContext.INSTANCE.renderUpsert(TABLE, INSERT_COLUMNS, CONFLICT_COLUMNS, + BIND_MARKER, IDENTIFIER_PROCESSING); + + assertThat(sql).startsWith("MERGE INTO"); + assertThat(sql).contains("USING (SELECT"); + assertThat(sql).contains("FROM DUAL"); + // Oracle requires ON condition in parentheses (ORA-00969 otherwise). + assertThat(sql).contains("ON (t.id = s.id)"); + assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET"); + assertThat(sql).contains("WHEN NOT MATCHED THEN INSERT"); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java index ca3b5264cf..b90e638134 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java @@ -17,6 +17,7 @@ import java.util.Collection; import java.util.Collections; +import java.util.Optional; import java.util.Set; import org.springframework.data.relational.core.sql.Functions; @@ -147,4 +148,14 @@ default SimpleFunction getExistsFunction() { default boolean supportsSingleQueryLoading() { return true; } + + /** + * Returns an {@link UpsertRenderContext} for single-statement upsert if supported by this dialect. + * + * @return optional upsert render context, empty if upsert is not supported. + * @since 4.x + */ + default Optional getUpsertRenderContext() { + return Optional.empty(); + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/UpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/UpsertRenderContext.java new file mode 100644 index 0000000000..bd109959c9 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/UpsertRenderContext.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026-present 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.dialect; + +import java.util.List; +import java.util.function.Function; + +import org.springframework.data.relational.core.sql.IdentifierProcessing; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.Table; + +/** + * Encapsulates dialect-specific rendering of a single-statement upsert (insert or update by id). + * Implementations produce vendor-specific SQL such as {@code INSERT ... ON CONFLICT ... DO UPDATE}, + * {@code INSERT ... ON DUPLICATE KEY UPDATE}, or standard {@code MERGE}. + * + * @since 4.x + */ +public interface UpsertRenderContext { + + /** + * Whether this dialect supports a single-statement upsert. + * + * @return {@literal true} if upsert is supported. + */ + boolean supportsUpsert(); + + /** + * Render a full upsert statement. + * + * @param table the target table. + * @param insertColumns column names for INSERT (order preserved for VALUES clause). + * @param conflictColumns columns that define the conflict (e.g. primary key). + * @param bindMarkerFn function from column name to bind marker placeholder (e.g. {@code "id" -> ":id"}). + * @param identifierProcessing identifier processing for rendering table and column names to SQL. + * @return the full upsert SQL statement. + */ + String renderUpsert(Table table, List insertColumns, List conflictColumns, + Function bindMarkerFn, IdentifierProcessing identifierProcessing); +} diff --git a/src/main/antora/modules/ROOT/pages/jdbc/entity-persistence.adoc b/src/main/antora/modules/ROOT/pages/jdbc/entity-persistence.adoc index 39cc3af02f..99322c73a5 100644 --- a/src/main/antora/modules/ROOT/pages/jdbc/entity-persistence.adoc +++ b/src/main/antora/modules/ROOT/pages/jdbc/entity-persistence.adoc @@ -79,6 +79,13 @@ Operating on single aggregates, named exactly as mentioned above, and with an `A `insert` and `update` skip the test if the entity is new and assume a new or existing aggregate as indicated by their names. +=== Upsert + +`JdbcAggregateTemplate` also offers `upsert(instance)`, which performs a single-statement *upsert*: insert the row if no row exists for the entity's id, or update it if it already exists. +The instance must have an id set; upsert is only supported when the database dialect supports it (e.g. PostgreSQL, H2, MySQL, MariaDB, SQL Server, Oracle, DB2, HSQLDB). +Use upsert when you want to persist a root entity by id in one round-trip without checking beforehand whether the row exists. +Upsert does not sync referenced entities (children); it only writes the aggregate root row. + === Querying `JdbcAggregateTemplate` offers a considerable array of methods for querying aggregates and about collections of aggregates. From 17ed01172962bd50e031ed1fc0defbab3137a973 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Fri, 13 Mar 2026 14:53:38 +0100 Subject: [PATCH 03/12] Upsert Rendering - Hacking Move UpsertRenderContext from jdbc to relationan Add UpsertStatementVisitor Move and rename MergeSqlRendererContext from jdbc to relational --- .../data/jdbc/core/convert/SqlGenerator.java | 39 +++-- .../jdbc/core/dialect/DialectResolver.java | 6 + .../jdbc/core/dialect/JdbcDb2Dialect.java | 8 +- .../data/jdbc/core/dialect/JdbcH2Dialect.java | 9 +- .../jdbc/core/dialect/JdbcHsqlDbDialect.java | 9 +- .../jdbc/core/dialect/JdbcMariaDbDialect.java | 8 +- .../jdbc/core/dialect/JdbcMySqlDialect.java | 8 +- .../jdbc/core/dialect/JdbcOracleDialect.java | 8 +- .../core/dialect/JdbcPostgresDialect.java | 7 +- .../core/dialect/JdbcSqlServerDialect.java | 7 +- .../dialect/MySqlUpsertRenderContext.java | 30 ++-- ...xt.java => OracleUpsertRenderContext.java} | 14 +- .../dialect/PostgresUpsertRenderContext.java | 12 +- ...java => SqlServerUpsertRenderContext.java} | 21 +-- ...JdbcAggregateTemplateIntegrationTests.java | 2 +- .../jdbc/core/convert/NonQuotingDialect.java | 1 + .../dialect/UpsertRenderContextUnitTests.java | 29 ++-- .../sql/render/UpsertRendererUnitTests.java | 137 ++++++++++++++++++ .../data/relational/core/dialect/Dialect.java | 9 +- .../core/dialect/RenderContextFactory.java | 9 ++ .../relational/core/sql/DefaultUpsert.java | 97 +++++++++++++ .../core/sql/DefaultUpsertBuilder.java | 95 ++++++++++++ .../relational/core/sql/StatementBuilder.java | 4 + .../data/relational/core/sql/Upsert.java | 36 +++++ .../relational/core/sql/UpsertBuilder.java | 98 +++++++++++++ .../sql/render/ConflictColumnCollector.java | 58 ++++++++ .../core/sql/render/RenderContext.java | 8 + .../relational/core/sql/render/Renderer.java | 9 ++ .../core/sql/render/SimpleRenderContext.java | 5 + .../core/sql/render/SqlRenderer.java | 10 ++ .../StandardSqlUpsertRenderContext.java | 20 +-- .../render}/UpsertRenderContext.java | 27 ++-- .../sql/render/UpsertStatementVisitor.java | 120 +++++++++++++++ .../data/relational/DependencyTests.java | 2 + .../core/sql/UpsertBuilderUnitTests.java | 46 ++++++ ...andardSqlUpsertRenderContextUnitTests.java | 65 +++++++++ 36 files changed, 928 insertions(+), 145 deletions(-) rename spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/{OracleMergeUpsertRenderContext.java => OracleUpsertRenderContext.java} (86%) rename spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/{SqlServerMergeUpsertRenderContext.java => SqlServerUpsertRenderContext.java} (54%) create mode 100644 spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsert.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsertBuilder.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Upsert.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/UpsertBuilder.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConflictColumnCollector.java rename spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MergeUpsertRenderContext.java => spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java (81%) rename spring-data-relational/src/main/java/org/springframework/data/relational/core/{dialect => sql/render}/UpsertRenderContext.java (71%) create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java create mode 100644 spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/UpsertBuilderUnitTests.java create mode 100644 spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java 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 469b026933..3305214859 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 @@ -39,7 +39,7 @@ import org.springframework.data.mapping.context.InvalidPersistentPropertyPath; import org.springframework.data.relational.core.dialect.Dialect; import org.springframework.data.relational.core.dialect.RenderContextFactory; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.render.RenderContext; import org.springframework.data.relational.core.mapping.AggregatePath; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; @@ -87,6 +87,7 @@ public class SqlGenerator { private final JdbcConverter converter; private final SqlContext sqlContext; + private final RenderContext renderContext; private final SqlRenderer sqlRenderer; private final Columns columns; @@ -122,7 +123,8 @@ public class SqlGenerator { this.converter = converter; this.entity = entity; this.sqlContext = new SqlContext(entity); - this.sqlRenderer = SqlRenderer.create(new RenderContextFactory(dialect).createRenderContext()); + this.renderContext = new RenderContextFactory(dialect).createRenderContext(); + this.sqlRenderer = SqlRenderer.create(renderContext); this.columns = new Columns(entity, mappingContext, converter); this.queryMapper = new QueryMapper(converter); this.dialect = dialect; @@ -397,7 +399,7 @@ String getInsert(Set additionalColumns) { /** * Create a dialect-specific upsert statement (insert or update by id). Requires the dialect to support upsert via - * {@link Dialect#getUpsertRenderContext()}. + * {@link RenderContext#getUpsertRenderContext()}. * * @param additionalColumns additional column names to include in the insert (e.g. back-references). * @return the upsert SQL statement. @@ -405,22 +407,33 @@ String getInsert(Set additionalColumns) { */ String getUpsert(Set additionalColumns) { - // TODO: create some nice api like the one we have for inserts + if (renderContext.getUpsertRenderContext() == null) { + throw new UnsupportedOperationException( + "Upsert is not supported by dialect " + dialect.getClass().getName()); + } + return render(createUpsertSql(additionalColumns)); + } + + private Upsert createUpsertSql(Set additionalColumns) { - UpsertRenderContext context = dialect.getUpsertRenderContext() - .orElseThrow(() -> new UnsupportedOperationException( - "Upsert is not supported by dialect " + dialect.getClass().getName())); Table table = getTable(); + Set columnNamesForInsert = new TreeSet<>(Comparator.comparing(SqlIdentifier::getReference)); columnNamesForInsert.addAll(columns.getInsertableColumns()); columnNamesForInsert.addAll(additionalColumns); List conflictColumns = getIdColumns().stream().map(Column::getName).toList(); columnNamesForInsert.addAll(conflictColumns); List insertColumns = new ArrayList<>(columnNamesForInsert); - Function bindMarkerFn = cn -> ":" - + BindParameterNameSanitizer.sanitize(renderReference(cn)); - return context.renderUpsert(table, insertColumns, conflictColumns, bindMarkerFn, - dialect.getIdentifierProcessing()); + + List assignments = insertColumns.stream() + .map(cn -> table.column(cn).set(getBindMarker(cn))) + .collect(Collectors.toList()); + + return StatementBuilder.upsert() // + .table(table) // + .columnValue(assignments) // + .where(equalityIdWhereCondition()) // + .build(); } /** @@ -1061,6 +1074,10 @@ private String render(Delete delete) { return this.sqlRenderer.render(delete); } + private String render(Upsert upsert) { + return this.sqlRenderer.render(upsert); + } + private Table getTable() { return sqlContext.getTable(); } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/DialectResolver.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/DialectResolver.java index dd1e016c31..0ae794babc 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/DialectResolver.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/DialectResolver.java @@ -42,6 +42,7 @@ import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SimpleFunction; import org.springframework.data.relational.core.sql.render.SelectRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.data.util.Optionals; import org.springframework.jdbc.core.ConnectionCallback; import org.springframework.jdbc.core.JdbcOperations; @@ -268,6 +269,11 @@ public SimpleFunction getExistsFunction() { public boolean supportsSingleQueryLoading() { return delegate.supportsSingleQueryLoading(); } + + @Override + public UpsertRenderContext getUpsertRenderContext() { + return delegate.getUpsertRenderContext(); + } } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcDb2Dialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcDb2Dialect.java index 86e92e64d7..2adc11b095 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcDb2Dialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcDb2Dialect.java @@ -20,13 +20,13 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; -import java.util.Optional; import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.WritingConverter; import org.springframework.data.jdbc.core.convert.Jsr310TimestampBasedConverters; import org.springframework.data.relational.core.dialect.Db2Dialect; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.render.StandardSqlUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; /** * {@link Db2Dialect} that registers JDBC specific converters. @@ -42,8 +42,8 @@ public class JdbcDb2Dialect extends Db2Dialect implements JdbcDialect { protected JdbcDb2Dialect() {} @Override - public Optional getUpsertRenderContext() { - return Optional.of(MergeUpsertRenderContext.INSTANCE); + public UpsertRenderContext getUpsertRenderContext() { + return StandardSqlUpsertRenderContext.INSTANCE; } @Override diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcH2Dialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcH2Dialect.java index 3310a70fd1..c249ffc6c7 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcH2Dialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcH2Dialect.java @@ -15,10 +15,9 @@ */ package org.springframework.data.jdbc.core.dialect; -import java.util.Optional; - import org.springframework.data.relational.core.dialect.H2Dialect; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.render.StandardSqlUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; /** * JDBC-specific H2 Dialect. @@ -38,8 +37,8 @@ public JdbcArrayColumns getArraySupport() { } @Override - public Optional getUpsertRenderContext() { - return Optional.of(MergeUpsertRenderContext.INSTANCE); + public UpsertRenderContext getUpsertRenderContext() { + return StandardSqlUpsertRenderContext.INSTANCE; } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcHsqlDbDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcHsqlDbDialect.java index 61a6defb80..e69b9bccb3 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcHsqlDbDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcHsqlDbDialect.java @@ -15,10 +15,9 @@ */ package org.springframework.data.jdbc.core.dialect; -import java.util.Optional; - import org.springframework.data.relational.core.dialect.HsqlDbDialect; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.render.StandardSqlUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; /** * JDBC-specific HsqlDB Dialect. @@ -36,8 +35,8 @@ public JdbcArrayColumns getArraySupport() { } @Override - public Optional getUpsertRenderContext() { - return Optional.of(MergeUpsertRenderContext.INSTANCE); + public UpsertRenderContext getUpsertRenderContext() { + return StandardSqlUpsertRenderContext.INSTANCE; } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMariaDbDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMariaDbDialect.java index 7c03a26f66..540eacb95c 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMariaDbDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMariaDbDialect.java @@ -15,10 +15,8 @@ */ package org.springframework.data.jdbc.core.dialect; -import java.util.Optional; - import org.springframework.data.relational.core.dialect.MariaDbDialect; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.data.relational.core.sql.IdentifierProcessing; /** @@ -34,8 +32,8 @@ public JdbcMariaDbDialect(IdentifierProcessing identifierProcessing) { } @Override - public Optional getUpsertRenderContext() { - return Optional.of(MySqlUpsertRenderContext.INSTANCE); + public UpsertRenderContext getUpsertRenderContext() { + return MySqlUpsertRenderContext.INSTANCE; } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMySqlDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMySqlDialect.java index 081f7aee56..8c4fc3e353 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMySqlDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMySqlDialect.java @@ -23,14 +23,12 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Date; -import java.util.Optional; - import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.ReadingConverter; import org.springframework.data.convert.WritingConverter; import org.springframework.data.jdbc.core.mapping.JdbcValue; import org.springframework.data.relational.core.dialect.MySqlDialect; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.lang.NonNull; @@ -63,8 +61,8 @@ public JdbcMySqlDialect(IdentifierProcessing identifierProcessing) { protected JdbcMySqlDialect() {} @Override - public Optional getUpsertRenderContext() { - return Optional.of(MySqlUpsertRenderContext.INSTANCE); + public UpsertRenderContext getUpsertRenderContext() { + return MySqlUpsertRenderContext.INSTANCE; } @Override diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcOracleDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcOracleDialect.java index d2f3ec88e5..52c9d6178c 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcOracleDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcOracleDialect.java @@ -16,11 +16,9 @@ package org.springframework.data.jdbc.core.dialect; -import java.util.Optional; - import org.springframework.data.relational.core.dialect.ObjectArrayColumns; import org.springframework.data.relational.core.dialect.OracleDialect; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; /** * JDBC-specific Oracle Dialect. @@ -39,8 +37,8 @@ public JdbcArrayColumns getArraySupport() { } @Override - public Optional getUpsertRenderContext() { - return Optional.of(OracleMergeUpsertRenderContext.INSTANCE); + public UpsertRenderContext getUpsertRenderContext() { + return OracleUpsertRenderContext.INSTANCE; } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcPostgresDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcPostgresDialect.java index 27eb8e98b9..eae753e97e 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcPostgresDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcPostgresDialect.java @@ -27,7 +27,6 @@ import java.util.Iterator; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.function.Consumer; @@ -35,7 +34,7 @@ import org.postgresql.core.Oid; import org.postgresql.jdbc.TypeInfoCache; import org.springframework.data.relational.core.dialect.PostgresDialect; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.util.ClassUtils; /** @@ -81,8 +80,8 @@ public JdbcArrayColumns getArraySupport() { } @Override - public Optional getUpsertRenderContext() { - return Optional.of(PostgresUpsertRenderContext.INSTANCE); + public UpsertRenderContext getUpsertRenderContext() { + return PostgresUpsertRenderContext.INSTANCE; } /** diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcSqlServerDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcSqlServerDialect.java index 235f7bcc7a..d3691ca0e7 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcSqlServerDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcSqlServerDialect.java @@ -22,7 +22,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; -import java.util.Optional; import java.util.Set; import org.jspecify.annotations.Nullable; @@ -30,7 +29,7 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.ReadingConverter; import org.springframework.data.relational.core.dialect.SqlServerDialect; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.data.util.ClassUtils; /** @@ -68,8 +67,8 @@ public Set> simpleTypes() { } @Override - public Optional getUpsertRenderContext() { - return Optional.of(SqlServerMergeUpsertRenderContext.INSTANCE); + public UpsertRenderContext getUpsertRenderContext() { + return SqlServerUpsertRenderContext.INSTANCE; } @Override diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MySqlUpsertRenderContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MySqlUpsertRenderContext.java index 8202e47f26..ad6f057e6f 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MySqlUpsertRenderContext.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MySqlUpsertRenderContext.java @@ -19,10 +19,10 @@ import java.util.Set; import java.util.function.Function; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.util.Assert; /** @@ -35,13 +35,11 @@ public enum MySqlUpsertRenderContext implements UpsertRenderContext { INSTANCE; @Override - public boolean supportsUpsert() { - return true; - } + public String renderUpsert(Table table, Columns merge, Function bindMarkerFn) { - @Override - public String renderUpsert(Table table, List insertColumns, List conflictColumns, - Function bindMarkerFn, IdentifierProcessing identifierProcessing) { + List insertColumns = merge.insertColumns(); + List conflictColumns = merge.filterColumns(); + IdentifierProcessing identifierProcessing = merge.identifierProcessing(); Assert.notEmpty(insertColumns, "Insert columns must not be empty"); Assert.notEmpty(conflictColumns, "Conflict columns must not be empty"); @@ -49,27 +47,23 @@ public String renderUpsert(Table table, List insertColumns, List< Set conflictSet = Set.copyOf(conflictColumns); String tableSql = table.getName().toSql(identifierProcessing); - String columnsSql = String.join(", ", - insertColumns.stream().map(col -> col.toSql(identifierProcessing)).toList()); + String columnsSql = String.join(", ", insertColumns.stream().map(col -> col.toSql(identifierProcessing)).toList()); String valuesSql = String.join(", ", insertColumns.stream().map(bindMarkerFn).toList()); - List updateColumns = insertColumns.stream() - .filter(col -> !conflictSet.contains(col)) - .toList(); + List updateColumns = insertColumns.stream().filter(col -> !conflictSet.contains(col)).toList(); String setClause; if (updateColumns.isEmpty()) { SqlIdentifier firstConflict = conflictColumns.get(0); - setClause = firstConflict.toSql(identifierProcessing) + " = VALUES(" - + firstConflict.toSql(identifierProcessing) + ")"; + setClause = firstConflict.toSql(identifierProcessing) + " = VALUES(" + firstConflict.toSql(identifierProcessing) + + ")"; } else { setClause = String.join(", ", updateColumns.stream() - .map(col -> col.toSql(identifierProcessing) + " = VALUES(" + col.toSql(identifierProcessing) + ")") - .toList()); + .map(col -> col.toSql(identifierProcessing) + " = VALUES(" + col.toSql(identifierProcessing) + ")").toList()); } - return "INSERT INTO " + tableSql + " (" + columnsSql + ") VALUES (" + valuesSql + ")" - + " ON DUPLICATE KEY UPDATE " + setClause; + return "INSERT INTO " + tableSql + " (" + columnsSql + ") VALUES (" + valuesSql + ")" + " ON DUPLICATE KEY UPDATE " + + setClause; } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/OracleMergeUpsertRenderContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/OracleUpsertRenderContext.java similarity index 86% rename from spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/OracleMergeUpsertRenderContext.java rename to spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/OracleUpsertRenderContext.java index c37be7a897..ca5ec6aea2 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/OracleMergeUpsertRenderContext.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/OracleUpsertRenderContext.java @@ -19,7 +19,7 @@ import java.util.Set; import java.util.function.Function; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; @@ -31,20 +31,18 @@ * * @since 4.x */ -public enum OracleMergeUpsertRenderContext implements UpsertRenderContext { +public enum OracleUpsertRenderContext implements UpsertRenderContext { INSTANCE; private static final String SOURCE_FROM_CLAUSE = " FROM DUAL"; @Override - public boolean supportsUpsert() { - return true; - } + public String renderUpsert(Table table, Columns merge, Function bindMarkerFn) { - @Override - public String renderUpsert(Table table, List insertColumns, List conflictColumns, - Function bindMarkerFn, IdentifierProcessing identifierProcessing) { + List insertColumns = merge.insertColumns(); + List conflictColumns = merge.filterColumns(); + IdentifierProcessing identifierProcessing = merge.identifierProcessing(); Assert.notEmpty(insertColumns, "Insert columns must not be empty"); Assert.notEmpty(conflictColumns, "Conflict columns must not be empty"); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/PostgresUpsertRenderContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/PostgresUpsertRenderContext.java index f685337d62..8443b46a3f 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/PostgresUpsertRenderContext.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/PostgresUpsertRenderContext.java @@ -19,7 +19,7 @@ import java.util.Set; import java.util.function.Function; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; @@ -35,13 +35,11 @@ public enum PostgresUpsertRenderContext implements UpsertRenderContext { INSTANCE; @Override - public boolean supportsUpsert() { - return true; - } + public String renderUpsert(Table table, Columns merge, Function bindMarkerFn) { - @Override - public String renderUpsert(Table table, List insertColumns, List conflictColumns, - Function bindMarkerFn, IdentifierProcessing identifierProcessing) { + List insertColumns = merge.insertColumns(); + List conflictColumns = merge.filterColumns(); + IdentifierProcessing identifierProcessing = merge.identifierProcessing(); Assert.notEmpty(insertColumns, "Insert columns must not be empty"); Assert.notEmpty(conflictColumns, "Conflict columns must not be empty"); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/SqlServerMergeUpsertRenderContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/SqlServerUpsertRenderContext.java similarity index 54% rename from spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/SqlServerMergeUpsertRenderContext.java rename to spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/SqlServerUpsertRenderContext.java index 72e92f71a4..b819ed5e57 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/SqlServerMergeUpsertRenderContext.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/SqlServerUpsertRenderContext.java @@ -15,34 +15,27 @@ */ package org.springframework.data.jdbc.core.dialect; -import java.util.List; import java.util.function.Function; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; -import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.render.StandardSqlUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.data.relational.core.sql.Table; /** - * SQL Server MERGE upsert. Delegates to {@link MergeUpsertRenderContext} and appends a required semicolon. + * SQL Server MERGE upsert. Delegates to {@link StandardSqlUpsertRenderContext} and appends a required semicolon. * * @since 4.x */ -public enum SqlServerMergeUpsertRenderContext implements UpsertRenderContext { +public enum SqlServerUpsertRenderContext implements UpsertRenderContext { INSTANCE; private static final String STATEMENT_TERMINATOR = ";"; @Override - public boolean supportsUpsert() { - return true; - } - - @Override - public String renderUpsert(Table table, List insertColumns, List conflictColumns, - Function bindMarkerFn, IdentifierProcessing identifierProcessing) { - return MergeUpsertRenderContext.INSTANCE.renderUpsert(table, insertColumns, conflictColumns, bindMarkerFn, - identifierProcessing) + STATEMENT_TERMINATOR; + public String renderUpsert(Table table, Columns merge, Function bindMarkerFn) { + return StandardSqlUpsertRenderContext.INSTANCE.renderUpsert(table, merge, + bindMarkerFn) + STATEMENT_TERMINATOR; } } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java index d87161ef2f..139de48c39 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java @@ -194,7 +194,7 @@ void upsertInsertsWhenIdDoesNotExistAndUpdatesWhenItExists() { assumeThat(template).isInstanceOf(JdbcAggregateTemplate.class); JdbcAggregateTemplate jdbcTemplate = (JdbcAggregateTemplate) template; - assumeThat(jdbcTemplate.getDataAccessStrategy().getDialect().getUpsertRenderContext().isEmpty()).isFalse(); + assumeThat(jdbcTemplate.getDataAccessStrategy().getDialect().getUpsertRenderContext()).isNotNull(); if(template.getDataAccessStrategy().getDialect() instanceof SqlServerDialect) { String tableName = "with_insert_only"; diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/NonQuotingDialect.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/NonQuotingDialect.java index 1a70295ae0..62bbe5dcb5 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/NonQuotingDialect.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/NonQuotingDialect.java @@ -50,4 +50,5 @@ public LockClause lock() { public IdentifierProcessing getIdentifierProcessing() { return IdentifierProcessing.create(new IdentifierProcessing.Quoting(""), IdentifierProcessing.LetterCasing.AS_IS); } + } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/dialect/UpsertRenderContextUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/dialect/UpsertRenderContextUnitTests.java index 1d938f2118..26d70dd9f8 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/dialect/UpsertRenderContextUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/dialect/UpsertRenderContextUnitTests.java @@ -15,16 +15,17 @@ */ package org.springframework.data.jdbc.core.dialect; +import static org.assertj.core.api.Assertions.assertThat; + import java.util.List; import java.util.function.Function; import org.junit.jupiter.api.Test; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; - -import static org.assertj.core.api.Assertions.assertThat; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext.Columns; /** * Unit tests for {@link UpsertRenderContext} implementations. @@ -41,8 +42,8 @@ class UpsertRenderContextUnitTests { @Test // GH-493 void postgresUpsertRendersInsertOnConflictDoUpdate() { - String sql = PostgresUpsertRenderContext.INSTANCE.renderUpsert(TABLE, INSERT_COLUMNS, CONFLICT_COLUMNS, - BIND_MARKER, IDENTIFIER_PROCESSING); + String sql = PostgresUpsertRenderContext.INSTANCE.renderUpsert(TABLE, + new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); assertThat(sql).startsWith("INSERT INTO"); assertThat(sql).contains("my_table"); @@ -55,21 +56,11 @@ void postgresUpsertRendersInsertOnConflictDoUpdate() { assertThat(sql).contains("EXCLUDED"); } - @Test // GH-493 - void mergeUpsertRendersMergeInto() { - - String sql = MergeUpsertRenderContext.INSTANCE.renderUpsert(TABLE, INSERT_COLUMNS, CONFLICT_COLUMNS, - BIND_MARKER, IDENTIFIER_PROCESSING); - - assertThat(sql).isEqualTo( - "MERGE INTO my_table t USING (VALUES (:id, :name)) AS s (id, name) ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.name = s.name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name)"); - } - @Test // GH-493 void mySqlUpsertRendersOnDuplicateKeyUpdate() { - String sql = MySqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, INSERT_COLUMNS, CONFLICT_COLUMNS, - BIND_MARKER, IDENTIFIER_PROCESSING); + String sql = MySqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, + new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); assertThat(sql).startsWith("INSERT INTO"); assertThat(sql).contains("my_table"); @@ -80,8 +71,8 @@ void mySqlUpsertRendersOnDuplicateKeyUpdate() { @Test // GH-493 void oracleMergeUpsertRendersOnConditionInParentheses() { - String sql = OracleMergeUpsertRenderContext.INSTANCE.renderUpsert(TABLE, INSERT_COLUMNS, CONFLICT_COLUMNS, - BIND_MARKER, IDENTIFIER_PROCESSING); + String sql = OracleUpsertRenderContext.INSTANCE.renderUpsert(TABLE, + new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); assertThat(sql).startsWith("MERGE INTO"); assertThat(sql).contains("USING (SELECT"); diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java new file mode 100644 index 0000000000..e114487a75 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java @@ -0,0 +1,137 @@ +/* + * Copyright 2026-present 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.sql.render; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.springframework.data.relational.core.dialect.RenderContextFactory; +import org.springframework.data.relational.core.sql.SQL; +import org.springframework.data.relational.core.sql.StatementBuilder; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.Upsert; +import org.springframework.data.relational.core.sql.render.SqlRenderer; + +/** + * Unit tests for rendering {@link Upsert} AST via {@link SqlRenderer} with dialect-specific + * {@link org.springframework.data.relational.core.sql.render.UpsertRenderContext}. + */ +class UpsertRendererUnitTests { + + @Test // GH-493 + void renderUpsertThrowsWhenContextHasNoUpsertSupport() { + + Table table = SQL.table("my_table"); + Upsert upsert = StatementBuilder.upsert().table(table) + .columnValue(table.column("id").set(SQL.bindMarker("id")), + table.column("name").set(SQL.bindMarker("name"))) + .where(table.column("id").isEqualTo(SQL.bindMarker("id"))) + .build(); + + // Use a dialect that returns null for getUpsertRenderContext() + var context = new RenderContextFactory(org.springframework.data.jdbc.core.convert.NonQuotingDialect.INSTANCE) + .createRenderContext(); + assertThatThrownBy(() -> SqlRenderer.create(context).render(upsert)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Upsert is not supported"); + } + + @Test // GH-493 + void postgresRendersInsertOnConflictDoUpdate() { + + Table table = SQL.table("my_table"); + Upsert upsert = StatementBuilder.upsert().table(table) + .columnValue(table.column("id").set(SQL.bindMarker("id")), + table.column("name").set(SQL.bindMarker("name"))) + .where(table.column("id").isEqualTo(SQL.bindMarker("id"))) + .build(); + + var context = new RenderContextFactory(org.springframework.data.jdbc.core.dialect.JdbcPostgresDialect.INSTANCE) + .createRenderContext(); + String sql = SqlRenderer.create(context).render(upsert); + + assertThat(sql).startsWith("INSERT INTO"); + assertThat(sql).contains("my_table"); + assertThat(sql).contains("id"); + assertThat(sql).contains("name"); + assertThat(sql).contains(":id"); + assertThat(sql).contains(":name"); + assertThat(sql).contains("ON CONFLICT ("); + assertThat(sql).contains("DO UPDATE SET"); + assertThat(sql).contains("EXCLUDED"); + } + + @Test // GH-493 + void mySqlRendersOnDuplicateKeyUpdate() { + + Table table = SQL.table("my_table"); + Upsert upsert = StatementBuilder.upsert().table(table) + .columnValue(table.column("id").set(SQL.bindMarker("id")), + table.column("name").set(SQL.bindMarker("name"))) + .where(table.column("id").isEqualTo(SQL.bindMarker("id"))) + .build(); + + var context = new RenderContextFactory( + org.springframework.data.jdbc.core.dialect.JdbcMySqlDialect.INSTANCE).createRenderContext(); + String sql = SqlRenderer.create(context).render(upsert); + + assertThat(sql).startsWith("INSERT INTO"); + assertThat(sql).contains("my_table"); + assertThat(sql).contains("ON DUPLICATE KEY UPDATE"); + } + + @Test // GH-493 + void sqlServerRendersMergeWithSemicolon() { + + Table table = SQL.table("my_table"); + Upsert upsert = StatementBuilder.upsert().table(table) + .columnValue(table.column("id").set(SQL.bindMarker("id")), + table.column("name").set(SQL.bindMarker("name"))) + .where(table.column("id").isEqualTo(SQL.bindMarker("id"))) + .build(); + + var context = new RenderContextFactory( + org.springframework.data.jdbc.core.dialect.JdbcSqlServerDialect.INSTANCE).createRenderContext(); + String sql = SqlRenderer.create(context).render(upsert); + + assertThat(sql).contains("MERGE INTO"); + assertThat(sql).contains("my_table"); + assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET"); + assertThat(sql).contains("WHEN NOT MATCHED THEN INSERT"); + assertThat(sql.trim()).endsWith(";"); + } + + @Test // GH-493 + void h2RendersMerge() { + + Table table = SQL.table("my_table"); + Upsert upsert = StatementBuilder.upsert().table(table) + .columnValue(table.column("id").set(SQL.bindMarker("id")), + table.column("name").set(SQL.bindMarker("name"))) + .where(table.column("id").isEqualTo(SQL.bindMarker("id"))) + .build(); + + var context = new RenderContextFactory(org.springframework.data.jdbc.core.dialect.JdbcH2Dialect.INSTANCE) + .createRenderContext(); + String sql = SqlRenderer.create(context).render(upsert); + + assertThat(sql).contains("MERGE INTO"); + assertThat(sql).contains("my_table"); + assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET"); + assertThat(sql).contains("WHEN NOT MATCHED THEN INSERT"); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java index b90e638134..d90bcc6b46 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java @@ -17,15 +17,16 @@ import java.util.Collection; import java.util.Collections; -import java.util.Optional; import java.util.Set; +import org.jspecify.annotations.Nullable; import org.springframework.data.relational.core.sql.Functions; import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SQL; import org.springframework.data.relational.core.sql.SimpleFunction; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.render.SelectRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; /** * Represents a dialect that is implemented by a particular database. Please note that not all features are supported by @@ -152,10 +153,10 @@ default boolean supportsSingleQueryLoading() { /** * Returns an {@link UpsertRenderContext} for single-statement upsert if supported by this dialect. * - * @return optional upsert render context, empty if upsert is not supported. + * @return the upsert render context, or {@literal null} if upsert is not supported. * @since 4.x */ - default Optional getUpsertRenderContext() { - return Optional.empty(); + default @Nullable UpsertRenderContext getUpsertRenderContext() { + return null; } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/RenderContextFactory.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/RenderContextFactory.java index c0fb030c10..0c2c1a4051 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/RenderContextFactory.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/RenderContextFactory.java @@ -15,11 +15,13 @@ */ package org.springframework.data.relational.core.dialect; +import org.jspecify.annotations.Nullable; import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.render.NamingStrategies; import org.springframework.data.relational.core.sql.render.RenderContext; import org.springframework.data.relational.core.sql.render.RenderNamingStrategy; import org.springframework.data.relational.core.sql.render.SelectRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.util.Assert; /** @@ -81,6 +83,7 @@ static class DialectRenderContext implements RenderContext { private final Dialect renderingDialect; private final SelectRenderContext selectRenderContext; private final InsertRenderContext insertRenderContext; + private final @Nullable UpsertRenderContext upsertRenderContext; DialectRenderContext(RenderNamingStrategy renderNamingStrategy, Dialect renderingDialect, SelectRenderContext selectRenderContext) { @@ -95,6 +98,7 @@ static class DialectRenderContext implements RenderContext { this.renderingDialect = renderingDialect; this.selectRenderContext = selectRenderContext; this.insertRenderContext = renderingDialect.getInsertRenderContext(); + this.upsertRenderContext = renderingDialect.getUpsertRenderContext(); } @Override @@ -116,5 +120,10 @@ public SelectRenderContext getSelectRenderContext() { public InsertRenderContext getInsertRenderContext() { return insertRenderContext; } + + @Override + public UpsertRenderContext getUpsertRenderContext() { + return upsertRenderContext; + } } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsert.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsert.java new file mode 100644 index 0000000000..2035658dd2 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsert.java @@ -0,0 +1,97 @@ +/* + * Copyright 2026-present 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 java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * @author Christoph Strobl + * @since 4.x + */ +class DefaultUpsert implements Upsert { + + protected final Table table; + protected final List assignments; + protected final Where where; + + DefaultUpsert(Table table, List assignments, Condition where) { + + this.table = table; + this.assignments = new ArrayList<>(assignments); + this.where = new Where(where); + } + + @Override + public void visit(Visitor visitor) { + + Assert.notNull(visitor, "Visitor must not be null"); + + visitor.enter(this); + + this.table.visit(visitor); + this.where.visit(visitor); + this.assignments.forEach(it -> it.visit(visitor)); + + visitor.leave(this); + } + + @Override + public String toString() { + + StringBuilder builder = new StringBuilder(); + builder.append("MERGE INTO ").append(table); + + String onCondition = this.where.toString().replaceFirst("^WHERE ", " ON "); + builder.append(onCondition); + + builder.append(" WHEN MATCHED THEN UPDATE SET ") + .append(StringUtils.collectionToDelimitedString(this.assignments, ", ")); + + builder.append(" WHEN NOT MATCHED THEN INSERT "); + Map assignmentMap = new LinkedHashMap<>(); + + for (int i = 0; i < assignments.size(); i++) { + + if (assignments.get(i) instanceof AssignValue av) { + assignmentMap.put(av.getColumn(), av.getValue()); + } else { + String[] parts = assignments.get(i).toString().split("="); + if (parts.length == 2) { + assignmentMap.put(new Column(parts[0].trim(), null), Expressions.just(parts[1].trim())); + } else { + assignmentMap.put(new Column("column-" + i, null), Expressions.just("?")); + } + } + } + if (!assignmentMap.isEmpty()) { + builder.append("("); + builder.append(StringUtils.collectionToDelimitedString(assignmentMap.keySet(), ", ")); + builder.append(") VALUES ("); + builder.append(StringUtils.collectionToDelimitedString(assignmentMap.values(), ", ")); + builder.append(")"); + } else { + builder.append("(...) VALUES (...)"); + } + + return builder.toString(); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsertBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsertBuilder.java new file mode 100644 index 0000000000..2ee9d70e86 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsertBuilder.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026-present 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 java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import org.jspecify.annotations.Nullable; +import org.springframework.data.relational.core.sql.UpsertBuilder.UpsertAssign; +import org.springframework.data.relational.core.sql.UpsertBuilder.UpsertWhere; +import org.springframework.util.Assert; + +/** + * Default {@link UpsertBuilder} implementation. + * + * @author Christoph Strobl + * @since 4.x + */ +class DefaultUpsertBuilder implements UpsertBuilder, UpsertWhere, UpsertAssign { + + private @Nullable Table table; + private final List assignments = new ArrayList<>(); + private @Nullable Condition where; + + @Override + public DefaultUpsertBuilder table(Table table) { + + Assert.notNull(table, "Table must not be null"); + + this.table = table; + + return this; + } + + @Override + public DefaultUpsertBuilder columnValue(Assignment assignment) { + + Assert.notNull(assignment, "Assignment must not be null"); + + this.assignments.add(assignment); + + return this; + } + + @Override + public DefaultUpsertBuilder columnValue(Assignment... assignments) { + + Assert.notNull(assignments, "Assignment must not be null"); + + return columnValue(Arrays.asList(assignments)); + } + + @Override + public DefaultUpsertBuilder columnValue(Collection assignments) { + + Assert.notNull(assignments, "Assignment must not be null"); + + this.assignments.addAll(assignments); + + return this; + } + + @Override + public DefaultUpsertBuilder where(Condition condition) { + + Assert.notNull(condition, "Condition must not be null"); + + this.where = condition; + + return this; + } + + @Override + public Upsert build() { + + Assert.state(this.table != null, "Table must not be null"); + + return new DefaultUpsert(this.table, this.assignments, this.where); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/StatementBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/StatementBuilder.java index 41dfb48c9e..ee09fe23f2 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/StatementBuilder.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/StatementBuilder.java @@ -120,6 +120,10 @@ public static UpdateBuilder update() { return Update.builder(); } + public static UpsertBuilder upsert() { + return Upsert.builder(); + } + /** * Creates a new {@link DeleteBuilder} and declares the {@link Table} to delete from. * diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Upsert.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Upsert.java new file mode 100644 index 0000000000..6ccfa0a58b --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Upsert.java @@ -0,0 +1,36 @@ +/* + * Copyright 2026-present 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 java.util.function.Consumer; + +/** + * @author Christoph Strobl + * @since 4.x + */ +public interface Upsert extends Segment, Visitable { + + static UpsertBuilder builder() { + return new DefaultUpsertBuilder(); + } + + static Upsert create(Consumer consumer) { + + DefaultUpsertBuilder builder = new DefaultUpsertBuilder(); + consumer.accept(builder); + return builder.build(); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/UpsertBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/UpsertBuilder.java new file mode 100644 index 0000000000..132ea16899 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/UpsertBuilder.java @@ -0,0 +1,98 @@ +/* + * Copyright 2026-present 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 java.util.Collection; + +/** + * Entry point to construct an {@link Update} statement. + * + * @author Christoph Strobl + * @since 4.x + * @see StatementBuilder + */ +public interface UpsertBuilder { + + /** + * Configure the {@link Table} to which the update is applied. + * + * @param table the table to update. + * @return {@code this} {@link SelectBuilder}. + */ + UpsertAssign table(Table table); + + /** + * Interface exposing {@code SET} methods. + */ + interface UpsertAssign { + + /** + * Apply a {@link Assignment SET assignment}. + * + * @param assignment a single {@link Assignment column assignment}. + * @return {@code this} builder. + * @see Assignment + */ + UpsertWhere columnValue(Assignment assignment); + + /** + * Apply one or more {@link Assignment SET assignments}. + * + * @param assignments the {@link Assignment column assignments}. + * @return {@code this} builder. + * @see Assignment + */ + UpsertWhere columnValue(Assignment... assignments); + + /** + * Apply one or more {@link Assignment SET assignments}. + * + * @param assignments the {@link Assignment column assignments}. + * @return {@code this} builder. + * @see Assignment + */ + UpsertWhere columnValue(Collection assignments); + } + + /** + * Interface exposing {@code WHERE} methods. + */ + interface UpsertWhere extends BuildUpsert { + + /** + * Apply a {@code WHERE} clause. + * + * @param condition the {@code WHERE} condition. + * @return {@code this} builder. + * @see Where + * @see Condition + */ + UpsertWhere where(Condition condition); + } + + /** + * Interface exposing the {@link Update} build method. + */ + interface BuildUpsert { + + /** + * Build the {@link Update}. + * + * @return the build and immutable {@link Upsert} statement. + */ + Upsert build(); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConflictColumnCollector.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConflictColumnCollector.java new file mode 100644 index 0000000000..a6f7de610c --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConflictColumnCollector.java @@ -0,0 +1,58 @@ +/* + * Copyright 2026-present 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 java.util.ArrayList; +import java.util.List; + +import org.springframework.data.relational.core.sql.Column; +import org.springframework.data.relational.core.sql.Comparison; +import org.springframework.data.relational.core.sql.Condition; +import org.springframework.data.relational.core.sql.MultipleCondition; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.Visitable; +import org.springframework.data.relational.core.sql.Visitor; + +/** + * Collects conflict columns from a {@link Condition} by traversing equality comparisons. + * For {@link Comparison} with {@code =} and a {@link Column} on the left, the column name is collected. + * For {@link MultipleCondition} (e.g. AND), recurses into child conditions. + * + * @since 4.x + */ +final class ConflictColumnCollector implements Visitor { + + private final List conflictColumns = new ArrayList<>(); + + @Override + public void enter(Visitable segment) { + + if (segment instanceof Comparison comparison && "=".equals(comparison.getComparator()) + && comparison.getLeft() instanceof Column column) { + conflictColumns.add(column.getName()); + } + + if (segment instanceof MultipleCondition multiple) { + for (Condition condition : multiple.getConditions()) { + condition.visit(this); + } + } + } + + List getConflictColumns() { + return conflictColumns; + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/RenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/RenderContext.java index cab9dfa428..72b255c62d 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/RenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/RenderContext.java @@ -15,6 +15,7 @@ */ package org.springframework.data.relational.core.sql.render; +import org.jspecify.annotations.Nullable; import org.springframework.data.relational.core.dialect.InsertRenderContext; import org.springframework.data.relational.core.sql.IdentifierProcessing; @@ -52,4 +53,11 @@ public interface RenderContext { * @return the {@link InsertRenderContext} */ InsertRenderContext getInsertRenderContext(); + + /** + * @return the {@link UpsertRenderContext}, or {@literal null} if upsert is not supported. + * @since 4.x + */ + @Nullable + UpsertRenderContext getUpsertRenderContext(); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/Renderer.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/Renderer.java index e49410538b..0b5abe7f8e 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/Renderer.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/Renderer.java @@ -19,6 +19,7 @@ import org.springframework.data.relational.core.sql.Insert; import org.springframework.data.relational.core.sql.Select; import org.springframework.data.relational.core.sql.Update; +import org.springframework.data.relational.core.sql.Upsert; /** * SQL renderer for {@link Select} and {@link Delete} statements. @@ -59,4 +60,12 @@ public interface Renderer { * @return the rendered statement. */ String render(Delete delete); + + /** + * Render the {@link Upsert} AST into a SQL statement. + * + * @param upsert the statement to render, must not be {@literal null}. + * @return the rendered statement. + */ + String render(Upsert upsert); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SimpleRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SimpleRenderContext.java index 96851ddd20..fe1acb2d81 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SimpleRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SimpleRenderContext.java @@ -49,6 +49,11 @@ public InsertRenderContext getInsertRenderContext() { return InsertRenderContexts.DEFAULT; } + @Override + public UpsertRenderContext getUpsertRenderContext() { + return StandardSqlUpsertRenderContext.INSTANCE; + } + public RenderNamingStrategy getNamingStrategy() { return this.namingStrategy; } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlRenderer.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlRenderer.java index 9a1ebd01af..097dd61bb8 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlRenderer.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlRenderer.java @@ -19,6 +19,7 @@ import org.springframework.data.relational.core.sql.Insert; import org.springframework.data.relational.core.sql.Select; import org.springframework.data.relational.core.sql.Update; +import org.springframework.data.relational.core.sql.Upsert; import org.springframework.util.Assert; /** @@ -152,4 +153,13 @@ public String render(Delete delete) { return visitor.getRenderedPart().toString(); } + + @Override + public String render(Upsert upsert) { + + UpsertStatementVisitor visitor = new UpsertStatementVisitor(context); + upsert.visit(visitor); + + return visitor.getRenderedPart().toString(); + } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MergeUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java similarity index 81% rename from spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MergeUpsertRenderContext.java rename to spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java index ec82ac8174..b48d65d028 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MergeUpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java @@ -13,39 +13,35 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.jdbc.core.dialect; +package org.springframework.data.relational.core.sql.render; import java.util.List; import java.util.Set; import java.util.function.Function; -import org.springframework.data.relational.core.dialect.UpsertRenderContext; import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; import org.springframework.util.Assert; /** - * Standard SQL {@code MERGE} upsert for dialects that support it (H2, HSQLDB, SQL Server, DB2). + * Standard SQL {@code MERGE} upsert for dialects that support it (like H2, HSQLDB, SQL Server, DB2). *

* Uses a table value constructor {@code (VALUES (?, ?)) AS s (col1, col2)} as the source so that - * no SELECT is used. This avoids H2 interpreting {@code ? AS "ID"} as a type cast (unknown data - * type "ID"), and avoids HSQLDB requiring a FROM clause on SELECT. + * no SELECT is used. * * @since 4.x */ -public enum MergeUpsertRenderContext implements UpsertRenderContext { +public enum StandardSqlUpsertRenderContext implements UpsertRenderContext { INSTANCE; @Override - public boolean supportsUpsert() { - return true; - } + public String renderUpsert(Table table, Columns merge, Function bindMarkerFn) { - @Override - public String renderUpsert(Table table, List insertColumns, List conflictColumns, - Function bindMarkerFn, IdentifierProcessing identifierProcessing) { + List insertColumns = merge.insertColumns(); + List conflictColumns = merge.filterColumns(); + IdentifierProcessing identifierProcessing = merge.identifierProcessing(); Assert.notEmpty(insertColumns, "Insert columns must not be empty"); Assert.notEmpty(conflictColumns, "Conflict columns must not be empty"); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/UpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertRenderContext.java similarity index 71% rename from spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/UpsertRenderContext.java rename to spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertRenderContext.java index bd109959c9..dc80c7ed8e 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/UpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertRenderContext.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.relational.core.dialect; +package org.springframework.data.relational.core.sql.render; import java.util.List; import java.util.function.Function; @@ -23,8 +23,8 @@ import org.springframework.data.relational.core.sql.Table; /** - * Encapsulates dialect-specific rendering of a single-statement upsert (insert or update by id). - * Implementations produce vendor-specific SQL such as {@code INSERT ... ON CONFLICT ... DO UPDATE}, + * Encapsulates dialect-specific rendering of a single-statement upsert (insert or update by id). Implementations + * produce vendor-specific SQL such as {@code INSERT ... ON CONFLICT ... DO UPDATE}, * {@code INSERT ... ON DUPLICATE KEY UPDATE}, or standard {@code MERGE}. * * @since 4.x @@ -32,22 +32,21 @@ public interface UpsertRenderContext { /** - * Whether this dialect supports a single-statement upsert. + * Render a full upsert statement. * - * @return {@literal true} if upsert is supported. + * @param table the target table. + * @param columns the merge operation. + * @param bindMarkerFn function from column name to bind marker placeholder (e.g. {@code "id" -> ":id"}). + * @return the full upsert SQL statement. */ - boolean supportsUpsert(); + String renderUpsert(Table table, Columns columns, Function bindMarkerFn); /** - * Render a full upsert statement. - * - * @param table the target table. * @param insertColumns column names for INSERT (order preserved for VALUES clause). - * @param conflictColumns columns that define the conflict (e.g. primary key). - * @param bindMarkerFn function from column name to bind marker placeholder (e.g. {@code "id" -> ":id"}). + * @param filterColumns columns that define the query for existing records (e.g. primary key). * @param identifierProcessing identifier processing for rendering table and column names to SQL. - * @return the full upsert SQL statement. */ - String renderUpsert(Table table, List insertColumns, List conflictColumns, - Function bindMarkerFn, IdentifierProcessing identifierProcessing); + record Columns(List insertColumns, List filterColumns, + IdentifierProcessing identifierProcessing) { + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java new file mode 100644 index 0000000000..29ce551aff --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java @@ -0,0 +1,120 @@ +/* + * Copyright 2026-present 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 java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; +import org.springframework.data.relational.core.sql.AssignValue; +import org.springframework.data.relational.core.sql.Condition; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.Visitable; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext.Columns; +import org.springframework.util.Assert; + +/** + * {@link PartRenderer} for {@link org.springframework.data.relational.core.sql.Upsert} statements. + * Traverses the Upsert AST (table, where/conflict condition, assignments), collects insert and conflict columns, + * and delegates dialect-specific rendering to {@link UpsertRenderContext}. + * + * @since 4.x + */ +public class UpsertStatementVisitor extends DelegatingVisitor implements PartRenderer { + + private final StringBuilder builder = new StringBuilder(); + private final RenderContext context; + private final List insertColumns = new ArrayList<>(); + private final List conflictColumns = new ArrayList<>(); + + private @Nullable Table table; + + UpsertStatementVisitor(RenderContext context) { + + Assert.notNull(context, "RenderContext must not be null"); + this.context = context; + } + + @Override + public @Nullable Delegation doEnter(Visitable segment) { + + if (segment instanceof Table t) { + this.table = t; + return Delegation.retain(); + } + + if (segment instanceof Condition condition) { + ConflictColumnCollector collector = new ConflictColumnCollector(); + condition.visit(collector); + this.conflictColumns.addAll(collector.getConflictColumns()); + return Delegation.retain(); + } + + if (segment instanceof AssignValue assignValue) { + this.insertColumns.add(assignValue.getColumn().getName()); + return Delegation.retain(); + } + + return Delegation.retain(); + } + + @Override + public Delegation doLeave(Visitable segment) { + + if (segment instanceof org.springframework.data.relational.core.sql.Upsert) { + + UpsertRenderContext upsertContext = context.getUpsertRenderContext(); + if (upsertContext == null) { + throw new UnsupportedOperationException( + "Upsert is not supported by the current render context; no UpsertRenderContext available."); + } + if (table == null) { + throw new IllegalStateException("Upsert statement has no table."); + } + + Function bindMarkerFn = cn -> ":" + + sanitizeBindMarkerName(cn.getReference()); + + + String sql = upsertContext.renderUpsert(table, new Columns(new ArrayList<>(insertColumns), + new ArrayList<>(conflictColumns), context.getIdentifierProcessing()), bindMarkerFn); + builder.append(sql); + + return Delegation.leave(); + } + + return Delegation.retain(); + } + + @Override + public CharSequence getRenderedPart() { + return builder; + } + + private static String sanitizeBindMarkerName(String rawName) { + + StringBuilder sb = new StringBuilder(rawName.length()); + 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 == '_') { + sb.append(c); + } + } + return sb.length() > 0 ? sb.toString() : rawName; + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java index 3d3772bd43..2c34912996 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java @@ -19,6 +19,7 @@ import org.junit.jupiter.api.Test; import org.springframework.data.relational.core.dialect.RenderContextFactory; import org.springframework.data.relational.core.sql.render.SelectRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import com.tngtech.archunit.base.DescribedPredicate; import com.tngtech.archunit.core.domain.JavaClass; @@ -47,6 +48,7 @@ void cycleFree() { .importPackages("org.springframework.data.relational") // .that(onlySpringData()) // .that(ignore(SelectRenderContext.class)) // + .that(ignore(UpsertRenderContext.class)) // .that(ignore(RenderContextFactory.class)); ArchRule rule = SlicesRuleDefinition.slices() // diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/UpsertBuilderUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/UpsertBuilderUnitTests.java new file mode 100644 index 0000000000..2ef235795a --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/UpsertBuilderUnitTests.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026-present 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.AssertionsForInterfaceTypes.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * @author Christoph Strobl + */ +public class UpsertBuilderUnitTests { + + @Test // GH-493 + public void toStringShouldRenderPseudoMergeStatement() { + + Table table = SQL.table("users"); + Column idColumn = table.column("id"); + Column usernameColumn = table.column("username"); + + Upsert update = StatementBuilder.upsert().table(table).columnValue(usernameColumn.set(SQL.bindMarker())) + .where(idColumn.isEqualTo(Expressions.just("id-1"))).build(); + + String mergeStatement = update.toString(); + + assertThat(mergeStatement).startsWith("MERGE INTO users").containsSubsequence( // + "ON users.id = id-1", // + "WHEN MATCHED THEN UPDATE SET", // + "users.username = ?", // + "WHEN NOT MATCHED THEN INSERT (users.username) VALUES (?)"); + } + +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java new file mode 100644 index 0000000000..7e0f79b665 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2026-present 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 java.util.List; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; +import org.springframework.data.relational.core.sql.IdentifierProcessing; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext.Columns; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link StandardSqlUpsertRenderContext}. + */ +class StandardSqlUpsertRenderContextUnitTests { + + private static final Table TABLE = Table.create(SqlIdentifier.unquoted("my_table")); + private static final List INSERT_COLUMNS = List.of(SqlIdentifier.unquoted("id"), + SqlIdentifier.unquoted("name")); + private static final List CONFLICT_COLUMNS = List.of(SqlIdentifier.unquoted("id")); + private static final Function BIND_MARKER = id -> ":" + id.getReference(); + private static final IdentifierProcessing IDENTIFIER_PROCESSING = IdentifierProcessing.ANSI; + + @Test // GH-493 + void mergeUpsertRendersMergeInto() { + + String sql = StandardSqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, + IDENTIFIER_PROCESSING), BIND_MARKER); + + assertThat(sql).isEqualTo( + "MERGE INTO my_table t USING (VALUES (:id, :name)) AS s (id, name) ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.name = s.name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name)"); + } + + @Test // GH-493 + void mergeUpsertWithMultipleConflictColumnsBuildsFilterClauseWithAllColumns() { + + List insertColumns = List.of(SqlIdentifier.unquoted("tenant_id"), SqlIdentifier.unquoted("id"), + SqlIdentifier.unquoted("name")); + List conflictColumns = List.of(SqlIdentifier.unquoted("tenant_id"), SqlIdentifier.unquoted("id")); + Columns columns = new Columns(insertColumns, conflictColumns, IDENTIFIER_PROCESSING); + + String sql = StandardSqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, columns, BIND_MARKER); + + assertThat(sql).contains("ON t.tenant_id = s.tenant_id AND t.id = s.id"); + assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET t.name = s.name"); + assertThat(sql).contains("WHEN NOT MATCHED THEN INSERT (tenant_id, id, name) VALUES (s.tenant_id, s.id, s.name)"); + } +} From 9343d5c9a936f5e7a4364751c4d3a17c1a58ba7b Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Tue, 17 Mar 2026 09:18:26 +0100 Subject: [PATCH 04/12] Hacking - simplify code flow. --- .../convert/CascadingDataAccessStrategy.java | 2 +- .../jdbc/core/convert/DataAccessStrategy.java | 4 +- .../convert/DefaultDataAccessStrategy.java | 9 +-- .../convert/DelegatingDataAccessStrategy.java | 2 +- .../data/jdbc/core/convert/SqlGenerator.java | 60 +++++++++++---- .../jdbc/core/dialect/JdbcDb2Dialect.java | 7 -- .../data/jdbc/core/dialect/JdbcH2Dialect.java | 7 -- .../jdbc/core/dialect/JdbcHsqlDbDialect.java | 7 -- .../jdbc/core/dialect/JdbcMariaDbDialect.java | 6 -- .../jdbc/core/dialect/JdbcMySqlDialect.java | 6 -- .../jdbc/core/dialect/JdbcOracleDialect.java | 6 -- .../core/dialect/JdbcPostgresDialect.java | 6 -- .../core/dialect/JdbcSqlServerDialect.java | 6 -- .../dialect/MySqlUpsertRenderContext.java | 69 ----------------- .../dialect/PostgresUpsertRenderContext.java | 77 ------------------- .../mybatis/MyBatisDataAccessStrategy.java | 2 +- .../core/convert/SqlGeneratorUnitTests.java | 10 ++- .../sql/render/UpsertRendererUnitTests.java | 50 +++++------- .../relational/core/dialect/Db2Dialect.java | 7 ++ .../data/relational/core/dialect/Dialect.java | 20 +++-- .../relational/core/dialect/H2Dialect.java | 7 ++ .../core/dialect/HsqlDbDialect.java | 7 ++ .../relational/core/dialect/MySqlDialect.java | 7 ++ .../core/dialect/OracleDialect.java | 7 ++ .../core/dialect/PostgresDialect.java | 7 ++ .../core/dialect/RenderContextFactory.java | 2 +- .../core/dialect/SqlServerDialect.java | 7 ++ .../sql/render/MySqlUpsertRenderContext.java | 67 ++++++++++++++++ .../render}/OracleUpsertRenderContext.java | 3 +- .../render/PostgresUpsertRenderContext.java | 68 ++++++++++++++++ .../core/sql/render/RenderContext.java | 5 +- .../render}/SqlServerUpsertRenderContext.java | 4 +- .../StandardSqlUpsertRenderContext.java | 73 ++++++++---------- .../core/sql/render/UpsertRenderContext.java | 32 ++++++++ .../data/relational/DependencyTests.java | 10 +++ ...andardSqlUpsertRenderContextUnitTests.java | 21 ++--- .../render}/UpsertRenderContextUnitTests.java | 55 +++++++++---- 37 files changed, 405 insertions(+), 340 deletions(-) delete mode 100644 spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MySqlUpsertRenderContext.java delete mode 100644 spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/PostgresUpsertRenderContext.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/MySqlUpsertRenderContext.java rename {spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect => spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render}/OracleUpsertRenderContext.java (96%) create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostgresUpsertRenderContext.java rename {spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect => spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render}/SqlServerUpsertRenderContext.java (85%) rename {spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/dialect => spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render}/UpsertRenderContextUnitTests.java (60%) diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java index 9a62a0a2ee..565579802b 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java @@ -88,7 +88,7 @@ public NamedParameterJdbcOperations getJdbcOperations() { } @Override - public @Nullable Object upsert(T instance, Class domainType, Identifier identifier) { + public int upsert(T instance, Class domainType, Identifier identifier) { return collect(das -> das.upsert(instance, domainType, identifier)); } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java index 734f3aed5d..4820e961c3 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java @@ -126,11 +126,11 @@ public interface DataAccessStrategy extends ReadingDataAccessStrategy, RelationR * @param domainType the type of the instance. Must not be {@code null}. * @param identifier information about data that needs to be considered (e.g. back-references). May be empty for root. * @param the type of the instance. - * @return the id generated by the database if any (typically {@code null} when id is provided). + * @return the number of rows affected by the upsert. * @throws UnsupportedOperationException if the dialect does not support upsert. * @since 4.x */ - @Nullable Object upsert(T instance, Class domainType, Identifier identifier); + int upsert(T instance, Class domainType, Identifier identifier); /** * Deletes a single row identified by the id, from the table identified by the domainType. Does not handle cascading 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 bbfb5835e6..be99cbc640 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 @@ -184,19 +184,18 @@ public boolean updateWithVersion(S instance, Class domainType, Number pre } @Override - public @Nullable Object upsert(T instance, Class domainType, Identifier identifier) { + public int upsert(T instance, Class domainType, Identifier identifier) { SqlIdentifierParameterSource parameterSource = sqlParametersFactory.forInsert(instance, domainType, identifier, IdValueSource.PROVIDED); - String upsertSql = sql(domainType).getUpsert(parameterSource.getIdentifiers()); + String statement = sql(domainType).getUpsert(parameterSource.getIdentifiers()); if(logger.isTraceEnabled()) { - logger.trace("Upsert SQL: " + upsertSql); + logger.trace("Upsert: [%s]".formatted(statement)); } - operations.update(upsertSql, parameterSource); - return null; + return operations.update(statement, parameterSource); } @Override diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java index 1c4358bbd0..a271e60dcc 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java @@ -81,7 +81,7 @@ public NamedParameterJdbcOperations getJdbcOperations() { } @Override - public @Nullable Object upsert(T instance, Class domainType, Identifier identifier) { + public int upsert(T instance, Class domainType, Identifier identifier) { return delegate.upsert(instance, domainType, identifier); } 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 3305214859..769e7e9c9e 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 @@ -39,14 +39,41 @@ import org.springframework.data.mapping.context.InvalidPersistentPropertyPath; import org.springframework.data.relational.core.dialect.Dialect; import org.springframework.data.relational.core.dialect.RenderContextFactory; -import org.springframework.data.relational.core.sql.render.RenderContext; 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.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.AssignValue; +import org.springframework.data.relational.core.sql.Assignment; +import org.springframework.data.relational.core.sql.Assignments; +import org.springframework.data.relational.core.sql.BindMarker; +import org.springframework.data.relational.core.sql.Column; +import org.springframework.data.relational.core.sql.Comparison; +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; +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.In; +import org.springframework.data.relational.core.sql.Insert; +import org.springframework.data.relational.core.sql.InsertBuilder; +import org.springframework.data.relational.core.sql.LockMode; +import org.springframework.data.relational.core.sql.OrderByField; +import org.springframework.data.relational.core.sql.SQL; +import org.springframework.data.relational.core.sql.Select; +import org.springframework.data.relational.core.sql.SelectBuilder; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.StatementBuilder; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.TupleExpression; +import org.springframework.data.relational.core.sql.Update; +import org.springframework.data.relational.core.sql.UpdateBuilder; +import org.springframework.data.relational.core.sql.Upsert; +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.Predicates; @@ -71,6 +98,7 @@ * @author Hari Ohm Prasath * @author Viktor Ardelean * @author Kurt Niemi + * @author Christoph Strobl */ public class SqlGenerator { @@ -398,22 +426,22 @@ String getInsert(Set additionalColumns) { } /** - * Create a dialect-specific upsert statement (insert or update by id). Requires the dialect to support upsert via - * {@link RenderContext#getUpsertRenderContext()}. + * Create a dialect-specific upsert statement (insert or update by id). * * @param additionalColumns additional column names to include in the insert (e.g. back-references). * @return the upsert SQL statement. * @throws UnsupportedOperationException if the dialect does not support upsert. + * @since 4.x */ String getUpsert(Set additionalColumns) { - - if (renderContext.getUpsertRenderContext() == null) { - throw new UnsupportedOperationException( - "Upsert is not supported by dialect " + dialect.getClass().getName()); - } return render(createUpsertSql(additionalColumns)); } + /** + * @param additionalColumns + * @return + * @since 4.x + */ private Upsert createUpsertSql(Set additionalColumns) { Table table = getTable(); @@ -421,12 +449,12 @@ private Upsert createUpsertSql(Set additionalColumns) { Set columnNamesForInsert = new TreeSet<>(Comparator.comparing(SqlIdentifier::getReference)); columnNamesForInsert.addAll(columns.getInsertableColumns()); columnNamesForInsert.addAll(additionalColumns); + List conflictColumns = getIdColumns().stream().map(Column::getName).toList(); columnNamesForInsert.addAll(conflictColumns); - List insertColumns = new ArrayList<>(columnNamesForInsert); - List assignments = insertColumns.stream() - .map(cn -> table.column(cn).set(getBindMarker(cn))) + List assignments = columnNamesForInsert.stream() // + .map(this::assignColumnValue) // .collect(Collectors.toList()); return StatementBuilder.upsert() // @@ -980,9 +1008,7 @@ private UpdateBuilder.UpdateWhereAndOr createBaseUpdate() { List assignments = columns.getUpdatableColumns() // .stream() // - .map(columnName -> Assignments.value( // - table.column(columnName), // - getBindMarker(columnName))) // + .map(this::assignColumnValue) // .collect(Collectors.toList()); return Update.builder() // @@ -991,6 +1017,10 @@ private UpdateBuilder.UpdateWhereAndOr createBaseUpdate() { .where(equalityIdWhereCondition()); } + private AssignValue assignColumnValue(SqlIdentifier columnName) { + return Assignments.value(getTable().column(columnName), getBindMarker(columnName)); + } + private String createDeleteByIdSql() { return render(createBaseDeleteById(getTable()).build()); } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcDb2Dialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcDb2Dialect.java index 2adc11b095..a88bdb9a89 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcDb2Dialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcDb2Dialect.java @@ -25,8 +25,6 @@ import org.springframework.data.convert.WritingConverter; import org.springframework.data.jdbc.core.convert.Jsr310TimestampBasedConverters; import org.springframework.data.relational.core.dialect.Db2Dialect; -import org.springframework.data.relational.core.sql.render.StandardSqlUpsertRenderContext; -import org.springframework.data.relational.core.sql.render.UpsertRenderContext; /** * {@link Db2Dialect} that registers JDBC specific converters. @@ -41,11 +39,6 @@ public class JdbcDb2Dialect extends Db2Dialect implements JdbcDialect { protected JdbcDb2Dialect() {} - @Override - public UpsertRenderContext getUpsertRenderContext() { - return StandardSqlUpsertRenderContext.INSTANCE; - } - @Override public Collection getConverters() { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcH2Dialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcH2Dialect.java index c249ffc6c7..83806c5b0e 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcH2Dialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcH2Dialect.java @@ -16,8 +16,6 @@ package org.springframework.data.jdbc.core.dialect; import org.springframework.data.relational.core.dialect.H2Dialect; -import org.springframework.data.relational.core.sql.render.StandardSqlUpsertRenderContext; -import org.springframework.data.relational.core.sql.render.UpsertRenderContext; /** * JDBC-specific H2 Dialect. @@ -36,9 +34,4 @@ public JdbcArrayColumns getArraySupport() { return ARRAY_COLUMNS; } - @Override - public UpsertRenderContext getUpsertRenderContext() { - return StandardSqlUpsertRenderContext.INSTANCE; - } - } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcHsqlDbDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcHsqlDbDialect.java index e69b9bccb3..affd91707b 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcHsqlDbDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcHsqlDbDialect.java @@ -16,8 +16,6 @@ package org.springframework.data.jdbc.core.dialect; import org.springframework.data.relational.core.dialect.HsqlDbDialect; -import org.springframework.data.relational.core.sql.render.StandardSqlUpsertRenderContext; -import org.springframework.data.relational.core.sql.render.UpsertRenderContext; /** * JDBC-specific HsqlDB Dialect. @@ -34,9 +32,4 @@ public JdbcArrayColumns getArraySupport() { return JdbcArrayColumns.DefaultSupport.INSTANCE; } - @Override - public UpsertRenderContext getUpsertRenderContext() { - return StandardSqlUpsertRenderContext.INSTANCE; - } - } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMariaDbDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMariaDbDialect.java index 540eacb95c..c6cd8f9902 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMariaDbDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMariaDbDialect.java @@ -16,7 +16,6 @@ package org.springframework.data.jdbc.core.dialect; import org.springframework.data.relational.core.dialect.MariaDbDialect; -import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.data.relational.core.sql.IdentifierProcessing; /** @@ -31,9 +30,4 @@ public JdbcMariaDbDialect(IdentifierProcessing identifierProcessing) { super(identifierProcessing); } - @Override - public UpsertRenderContext getUpsertRenderContext() { - return MySqlUpsertRenderContext.INSTANCE; - } - } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMySqlDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMySqlDialect.java index 8c4fc3e353..62929628da 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMySqlDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMySqlDialect.java @@ -28,7 +28,6 @@ import org.springframework.data.convert.WritingConverter; import org.springframework.data.jdbc.core.mapping.JdbcValue; import org.springframework.data.relational.core.dialect.MySqlDialect; -import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.lang.NonNull; @@ -60,11 +59,6 @@ public JdbcMySqlDialect(IdentifierProcessing identifierProcessing) { protected JdbcMySqlDialect() {} - @Override - public UpsertRenderContext getUpsertRenderContext() { - return MySqlUpsertRenderContext.INSTANCE; - } - @Override public Collection getConverters() { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcOracleDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcOracleDialect.java index 52c9d6178c..a1886b13d9 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcOracleDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcOracleDialect.java @@ -18,7 +18,6 @@ import org.springframework.data.relational.core.dialect.ObjectArrayColumns; import org.springframework.data.relational.core.dialect.OracleDialect; -import org.springframework.data.relational.core.sql.render.UpsertRenderContext; /** * JDBC-specific Oracle Dialect. @@ -36,9 +35,4 @@ public JdbcArrayColumns getArraySupport() { return ARRAY_COLUMNS; } - @Override - public UpsertRenderContext getUpsertRenderContext() { - return OracleUpsertRenderContext.INSTANCE; - } - } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcPostgresDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcPostgresDialect.java index eae753e97e..6072ccdc5d 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcPostgresDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcPostgresDialect.java @@ -34,7 +34,6 @@ import org.postgresql.core.Oid; import org.postgresql.jdbc.TypeInfoCache; import org.springframework.data.relational.core.dialect.PostgresDialect; -import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.util.ClassUtils; /** @@ -79,11 +78,6 @@ public JdbcArrayColumns getArraySupport() { return ARRAY_COLUMNS; } - @Override - public UpsertRenderContext getUpsertRenderContext() { - return PostgresUpsertRenderContext.INSTANCE; - } - /** * Creates a Postgres {@link SQLType} for the given name and vendor type number. * diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcSqlServerDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcSqlServerDialect.java index d3691ca0e7..86a2ddf916 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcSqlServerDialect.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcSqlServerDialect.java @@ -29,7 +29,6 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.ReadingConverter; import org.springframework.data.relational.core.dialect.SqlServerDialect; -import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.data.util.ClassUtils; /** @@ -66,11 +65,6 @@ public Set> simpleTypes() { return SIMPLE_TYPES; } - @Override - public UpsertRenderContext getUpsertRenderContext() { - return SqlServerUpsertRenderContext.INSTANCE; - } - @Override public Collection getConverters() { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MySqlUpsertRenderContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MySqlUpsertRenderContext.java deleted file mode 100644 index ad6f057e6f..0000000000 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/MySqlUpsertRenderContext.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2026-present 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.dialect; - -import java.util.List; -import java.util.Set; -import java.util.function.Function; - -import org.springframework.data.relational.core.sql.IdentifierProcessing; -import org.springframework.data.relational.core.sql.SqlIdentifier; -import org.springframework.data.relational.core.sql.Table; -import org.springframework.data.relational.core.sql.render.UpsertRenderContext; -import org.springframework.util.Assert; - -/** - * MySQL / MariaDB upsert using {@code INSERT ... ON DUPLICATE KEY UPDATE}. - * - * @since 4.x - */ -public enum MySqlUpsertRenderContext implements UpsertRenderContext { - - INSTANCE; - - @Override - public String renderUpsert(Table table, Columns merge, Function bindMarkerFn) { - - List insertColumns = merge.insertColumns(); - List conflictColumns = merge.filterColumns(); - IdentifierProcessing identifierProcessing = merge.identifierProcessing(); - - Assert.notEmpty(insertColumns, "Insert columns must not be empty"); - Assert.notEmpty(conflictColumns, "Conflict columns must not be empty"); - - Set conflictSet = Set.copyOf(conflictColumns); - String tableSql = table.getName().toSql(identifierProcessing); - - String columnsSql = String.join(", ", insertColumns.stream().map(col -> col.toSql(identifierProcessing)).toList()); - - String valuesSql = String.join(", ", insertColumns.stream().map(bindMarkerFn).toList()); - - List updateColumns = insertColumns.stream().filter(col -> !conflictSet.contains(col)).toList(); - - String setClause; - if (updateColumns.isEmpty()) { - SqlIdentifier firstConflict = conflictColumns.get(0); - setClause = firstConflict.toSql(identifierProcessing) + " = VALUES(" + firstConflict.toSql(identifierProcessing) - + ")"; - } else { - setClause = String.join(", ", updateColumns.stream() - .map(col -> col.toSql(identifierProcessing) + " = VALUES(" + col.toSql(identifierProcessing) + ")").toList()); - } - - return "INSERT INTO " + tableSql + " (" + columnsSql + ") VALUES (" + valuesSql + ")" + " ON DUPLICATE KEY UPDATE " - + setClause; - } -} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/PostgresUpsertRenderContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/PostgresUpsertRenderContext.java deleted file mode 100644 index 8443b46a3f..0000000000 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/PostgresUpsertRenderContext.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2026-present 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.dialect; - -import java.util.List; -import java.util.Set; -import java.util.function.Function; - -import org.springframework.data.relational.core.sql.render.UpsertRenderContext; -import org.springframework.data.relational.core.sql.IdentifierProcessing; -import org.springframework.data.relational.core.sql.SqlIdentifier; -import org.springframework.data.relational.core.sql.Table; -import org.springframework.util.Assert; - -/** - * PostgreSQL upsert using {@code INSERT ... ON CONFLICT ... DO UPDATE SET}. - * - * @since 4.x - */ -public enum PostgresUpsertRenderContext implements UpsertRenderContext { - - INSTANCE; - - @Override - public String renderUpsert(Table table, Columns merge, Function bindMarkerFn) { - - List insertColumns = merge.insertColumns(); - List conflictColumns = merge.filterColumns(); - IdentifierProcessing identifierProcessing = merge.identifierProcessing(); - - Assert.notEmpty(insertColumns, "Insert columns must not be empty"); - Assert.notEmpty(conflictColumns, "Conflict columns must not be empty"); - - Set conflictSet = Set.copyOf(conflictColumns); - String tableSql = table.getName().toSql(identifierProcessing); - - String columnsSql = String.join(", ", - insertColumns.stream().map(col -> col.toSql(identifierProcessing)).toList()); - - String valuesSql = String.join(", ", insertColumns.stream().map(bindMarkerFn).toList()); - - String conflictSql = String.join(", ", - conflictColumns.stream().map(col -> col.toSql(identifierProcessing)).toList()); - - List updateColumns = insertColumns.stream() - .filter(col -> !conflictSet.contains(col)) - .toList(); - - String setClause; - if (updateColumns.isEmpty()) { - // PostgreSQL requires at least one SET; use conflict column as no-op - SqlIdentifier firstConflict = conflictColumns.get(0); - setClause = firstConflict.toSql(identifierProcessing) + " = EXCLUDED." - + firstConflict.toSql(identifierProcessing); - } else { - setClause = String.join(", ", updateColumns.stream() - .map(col -> col.toSql(identifierProcessing) + " = EXCLUDED." + col.toSql(identifierProcessing)) - .toList()); - } - - return "INSERT INTO " + tableSql + " (" + columnsSql + ") VALUES (" + valuesSql + ")" - + " ON CONFLICT (" + conflictSql + ") DO UPDATE SET " + setClause; - } -} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java index 1a38c1ca48..6d542615bb 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java @@ -185,7 +185,7 @@ public void setNamespaceStrategy(NamespaceStrategy namespaceStrategy) { } @Override - public @Nullable Object upsert(T instance, Class domainType, Identifier identifier) { + public int upsert(T instance, Class domainType, Identifier identifier) { throw new UnsupportedOperationException("Upsert is not supported by MyBatisDataAccessStrategy"); } 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 075607f0ef..59bb8fd485 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 @@ -26,6 +26,7 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.data.annotation.Id; @@ -603,10 +604,11 @@ void getInsertForQuotedColumnName() { void getUpsertThrowsWhenDialectDoesNotSupportUpsert() { SqlGenerator sqlGenerator = createSqlGenerator(DummyEntity.class); - - assertThatThrownBy(() -> sqlGenerator.getUpsert(emptySet())) // - .isInstanceOf(UnsupportedOperationException.class) // - .hasMessageContaining("Upsert is not supported"); + String upsert = sqlGenerator.getUpsert(emptySet()); + assertThat(upsert) // + .startsWith("MERGE INTO dummy_entity") // + .contains("WHEN MATCHED THEN UPDATE") // + .contains("WHEN NOT MATCHED THEN INSERT"); } @Test // GH-493 diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java index e114487a75..63abaf8a33 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java @@ -16,7 +16,6 @@ package org.springframework.data.jdbc.core.sql.render; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.junit.jupiter.api.Test; import org.springframework.data.relational.core.dialect.RenderContextFactory; @@ -33,21 +32,20 @@ class UpsertRendererUnitTests { @Test // GH-493 - void renderUpsertThrowsWhenContextHasNoUpsertSupport() { + void standardSqlUpsertUsesMerge() { Table table = SQL.table("my_table"); Upsert upsert = StatementBuilder.upsert().table(table) - .columnValue(table.column("id").set(SQL.bindMarker("id")), - table.column("name").set(SQL.bindMarker("name"))) - .where(table.column("id").isEqualTo(SQL.bindMarker("id"))) - .build(); + .columnValue(table.column("id").set(SQL.bindMarker("id")), table.column("name").set(SQL.bindMarker("name"))) + .where(table.column("id").isEqualTo(SQL.bindMarker("id"))).build(); // Use a dialect that returns null for getUpsertRenderContext() var context = new RenderContextFactory(org.springframework.data.jdbc.core.convert.NonQuotingDialect.INSTANCE) .createRenderContext(); - assertThatThrownBy(() -> SqlRenderer.create(context).render(upsert)) - .isInstanceOf(UnsupportedOperationException.class) - .hasMessageContaining("Upsert is not supported"); + String sql = SqlRenderer.create(context).render(upsert); + + assertThat(sql).startsWith("MERGE INTO my_table") // + .containsSubsequence("WHEN MATCHED THEN UPDATE SET", "WHEN NOT MATCHED THEN INSERT"); } @Test // GH-493 @@ -55,16 +53,14 @@ void postgresRendersInsertOnConflictDoUpdate() { Table table = SQL.table("my_table"); Upsert upsert = StatementBuilder.upsert().table(table) - .columnValue(table.column("id").set(SQL.bindMarker("id")), - table.column("name").set(SQL.bindMarker("name"))) - .where(table.column("id").isEqualTo(SQL.bindMarker("id"))) - .build(); + .columnValue(table.column("id").set(SQL.bindMarker("id")), table.column("name").set(SQL.bindMarker("name"))) + .where(table.column("id").isEqualTo(SQL.bindMarker("id"))).build(); var context = new RenderContextFactory(org.springframework.data.jdbc.core.dialect.JdbcPostgresDialect.INSTANCE) .createRenderContext(); String sql = SqlRenderer.create(context).render(upsert); - assertThat(sql).startsWith("INSERT INTO"); + assertThat(sql).startsWith("INSERT INTO my_table"); assertThat(sql).contains("my_table"); assertThat(sql).contains("id"); assertThat(sql).contains("name"); @@ -80,13 +76,11 @@ void mySqlRendersOnDuplicateKeyUpdate() { Table table = SQL.table("my_table"); Upsert upsert = StatementBuilder.upsert().table(table) - .columnValue(table.column("id").set(SQL.bindMarker("id")), - table.column("name").set(SQL.bindMarker("name"))) - .where(table.column("id").isEqualTo(SQL.bindMarker("id"))) - .build(); + .columnValue(table.column("id").set(SQL.bindMarker("id")), table.column("name").set(SQL.bindMarker("name"))) + .where(table.column("id").isEqualTo(SQL.bindMarker("id"))).build(); - var context = new RenderContextFactory( - org.springframework.data.jdbc.core.dialect.JdbcMySqlDialect.INSTANCE).createRenderContext(); + var context = new RenderContextFactory(org.springframework.data.jdbc.core.dialect.JdbcMySqlDialect.INSTANCE) + .createRenderContext(); String sql = SqlRenderer.create(context).render(upsert); assertThat(sql).startsWith("INSERT INTO"); @@ -99,13 +93,11 @@ void sqlServerRendersMergeWithSemicolon() { Table table = SQL.table("my_table"); Upsert upsert = StatementBuilder.upsert().table(table) - .columnValue(table.column("id").set(SQL.bindMarker("id")), - table.column("name").set(SQL.bindMarker("name"))) - .where(table.column("id").isEqualTo(SQL.bindMarker("id"))) - .build(); + .columnValue(table.column("id").set(SQL.bindMarker("id")), table.column("name").set(SQL.bindMarker("name"))) + .where(table.column("id").isEqualTo(SQL.bindMarker("id"))).build(); - var context = new RenderContextFactory( - org.springframework.data.jdbc.core.dialect.JdbcSqlServerDialect.INSTANCE).createRenderContext(); + var context = new RenderContextFactory(org.springframework.data.jdbc.core.dialect.JdbcSqlServerDialect.INSTANCE) + .createRenderContext(); String sql = SqlRenderer.create(context).render(upsert); assertThat(sql).contains("MERGE INTO"); @@ -120,10 +112,8 @@ void h2RendersMerge() { Table table = SQL.table("my_table"); Upsert upsert = StatementBuilder.upsert().table(table) - .columnValue(table.column("id").set(SQL.bindMarker("id")), - table.column("name").set(SQL.bindMarker("name"))) - .where(table.column("id").isEqualTo(SQL.bindMarker("id"))) - .build(); + .columnValue(table.column("id").set(SQL.bindMarker("id")), table.column("name").set(SQL.bindMarker("name"))) + .where(table.column("id").isEqualTo(SQL.bindMarker("id"))).build(); var context = new RenderContextFactory(org.springframework.data.jdbc.core.dialect.JdbcH2Dialect.INSTANCE) .createRenderContext(); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Db2Dialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Db2Dialect.java index 6ab4952567..96e7760182 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Db2Dialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Db2Dialect.java @@ -20,6 +20,8 @@ import org.springframework.data.relational.core.sql.LockOptions; import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.render.StandardSqlUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; /** * An SQL dialect for DB2. @@ -113,4 +115,9 @@ public Position getClausePosition() { public Collection getConverters() { return Collections.singletonList(TimestampAtUtcToOffsetDateTimeConverter.INSTANCE); } + + @Override + public UpsertRenderContext getUpsertRenderContext() { + return StandardSqlUpsertRenderContext.INSTANCE; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java index d90bcc6b46..9b072412d9 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java @@ -19,14 +19,15 @@ import java.util.Collections; import java.util.Set; -import org.jspecify.annotations.Nullable; import org.springframework.data.relational.core.sql.Functions; import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SQL; import org.springframework.data.relational.core.sql.SimpleFunction; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.render.SelectRenderContext; +import org.springframework.data.relational.core.sql.render.StandardSqlUpsertRenderContext; import org.springframework.data.relational.core.sql.render.UpsertRenderContext; +import org.springframework.util.ClassUtils; /** * Represents a dialect that is implemented by a particular database. Please note that not all features are supported by @@ -43,6 +44,14 @@ */ public interface Dialect { + /** + * @return the name of the dialect. + * @since 4.x + */ + default String getName() { + return ClassUtils.getShortName(getClass()); + } + /** * Return the {@link LimitClause} used by this dialect. * @@ -151,12 +160,13 @@ default boolean supportsSingleQueryLoading() { } /** - * Returns an {@link UpsertRenderContext} for single-statement upsert if supported by this dialect. + * Returns an {@link UpsertRenderContext} for single-statement upsert. * - * @return the upsert render context, or {@literal null} if upsert is not supported. + * @return the upsert render context. {@link StandardSqlUpsertRenderContext} by default. + * @throws UnsupportedOperationException if the dialect does not support single-statement upsert. * @since 4.x */ - default @Nullable UpsertRenderContext getUpsertRenderContext() { - return null; + default UpsertRenderContext getUpsertRenderContext() { + return StandardSqlUpsertRenderContext.INSTANCE; } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/H2Dialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/H2Dialect.java index 5523a066f1..41569276fc 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/H2Dialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/H2Dialect.java @@ -18,6 +18,8 @@ import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.IdentifierProcessing.LetterCasing; import org.springframework.data.relational.core.sql.IdentifierProcessing.Quoting; +import org.springframework.data.relational.core.sql.render.StandardSqlUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -118,4 +120,9 @@ public boolean supportsSingleQueryLoading() { public IdGeneration getIdGeneration() { return ID_GENERATION; } + + @Override + public UpsertRenderContext getUpsertRenderContext() { + return StandardSqlUpsertRenderContext.INSTANCE; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/HsqlDbDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/HsqlDbDialect.java index 9f6a265c5a..af2c6a52ed 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/HsqlDbDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/HsqlDbDialect.java @@ -16,6 +16,8 @@ package org.springframework.data.relational.core.dialect; import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.render.StandardSqlUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; /** * A {@link Dialect} for HsqlDb. @@ -90,4 +92,9 @@ public String createSequenceQuery(SqlIdentifier sequenceName) { } }; } + + @Override + public UpsertRenderContext getUpsertRenderContext() { + return StandardSqlUpsertRenderContext.INSTANCE; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MySqlDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MySqlDialect.java index 7c6157aa49..ae93edaa77 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MySqlDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MySqlDialect.java @@ -23,6 +23,8 @@ import org.springframework.data.relational.core.sql.IdentifierProcessing.Quoting; import org.springframework.data.relational.core.sql.LockOptions; import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.render.MySqlUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.util.Assert; /** @@ -170,4 +172,9 @@ public String createSequenceQuery(SqlIdentifier sequenceName) { } }; } + + @Override + public UpsertRenderContext getUpsertRenderContext() { + return MySqlUpsertRenderContext.INSTANCE; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java index 17847c66bd..44bee3d41d 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java @@ -22,6 +22,8 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.WritingConverter; import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.render.OracleUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; /** * An SQL dialect for Oracle. @@ -66,6 +68,11 @@ public IdGeneration getIdGeneration() { return ID_GENERATION; } + @Override + public UpsertRenderContext getUpsertRenderContext() { + return OracleUpsertRenderContext.INSTANCE; + } + @Override public Collection getConverters() { return asList(TimestampAtUtcToOffsetDateTimeConverter.INSTANCE, NumberToBooleanConverter.INSTANCE, diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java index 836c10fb23..adbd644fc6 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java @@ -34,6 +34,8 @@ import org.springframework.data.relational.core.sql.SimpleFunction; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.TableLike; +import org.springframework.data.relational.core.sql.render.PostgresUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; /** * An SQL dialect for Postgres. @@ -170,4 +172,9 @@ public SimpleFunction getExistsFunction() { public IdGeneration getIdGeneration() { return idGeneration; } + + @Override + public UpsertRenderContext getUpsertRenderContext() { + return PostgresUpsertRenderContext.INSTANCE; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/RenderContextFactory.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/RenderContextFactory.java index 0c2c1a4051..29f93bb7ff 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/RenderContextFactory.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/RenderContextFactory.java @@ -83,7 +83,7 @@ static class DialectRenderContext implements RenderContext { private final Dialect renderingDialect; private final SelectRenderContext selectRenderContext; private final InsertRenderContext insertRenderContext; - private final @Nullable UpsertRenderContext upsertRenderContext; + private final UpsertRenderContext upsertRenderContext; DialectRenderContext(RenderNamingStrategy renderNamingStrategy, Dialect renderingDialect, SelectRenderContext selectRenderContext) { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/SqlServerDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/SqlServerDialect.java index d2eb013e43..2e0c98d036 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/SqlServerDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/SqlServerDialect.java @@ -19,6 +19,8 @@ import org.springframework.data.relational.core.sql.LockOptions; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.render.SelectRenderContext; +import org.springframework.data.relational.core.sql.render.SqlServerUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.data.util.Lazy; /** @@ -141,4 +143,9 @@ public InsertRenderContext getInsertRenderContext() { public OrderByNullPrecedence orderByNullHandling() { return OrderByNullPrecedence.NONE; } + + @Override + public UpsertRenderContext getUpsertRenderContext() { + return SqlServerUpsertRenderContext.INSTANCE; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/MySqlUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/MySqlUpsertRenderContext.java new file mode 100644 index 0000000000..ab2fa1b47a --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/MySqlUpsertRenderContext.java @@ -0,0 +1,67 @@ +/* + * Copyright 2026-present 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 java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.util.Assert; + +/** + * MySQL / MariaDB upsert using {@code INSERT ... ON DUPLICATE KEY UPDATE}. + * + * @author Christoph Strobl + * @since 4.x + */ +public enum MySqlUpsertRenderContext implements UpsertRenderContext { + + INSTANCE; + + @Override + public String renderUpsert(Table table, Columns columns, Function bindMarkerFn) { + + Assert.notEmpty(columns.insertColumns(), "Insert columns must not be empty"); + Assert.notEmpty(columns.filterColumns(), "Filter columns must not be empty"); + + String tableName = columns.tableName(table); + String columnNames = String.join(", ", columns.insertColumnNames()); + String bindMarkers = String.join(", ", columns.insertColumnBindMarkers(bindMarkerFn)); + String setValues = setValuesSnippet(columns); + + return "INSERT INTO %s (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s".formatted( // + tableName, // + columnNames, // + bindMarkers, // + setValues); + } + + private static String setValuesSnippet(Columns columns) { + + List updateColumns = columns.updateColumns(); + + if (updateColumns.isEmpty()) { + updateColumns = columns.filterColumns(); + } + + return updateColumns.stream().map(col -> { + String colName = col.toSql(columns.identifierProcessing()); + return "%s = VALUES(%s)".formatted(colName, colName); + }).collect(Collectors.joining(", ")); + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/OracleUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OracleUpsertRenderContext.java similarity index 96% rename from spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/OracleUpsertRenderContext.java rename to spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OracleUpsertRenderContext.java index ca5ec6aea2..316ecc587f 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/OracleUpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OracleUpsertRenderContext.java @@ -13,13 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.jdbc.core.dialect; +package org.springframework.data.relational.core.sql.render; import java.util.List; import java.util.Set; import java.util.function.Function; -import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostgresUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostgresUpsertRenderContext.java new file mode 100644 index 0000000000..975a91644b --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostgresUpsertRenderContext.java @@ -0,0 +1,68 @@ +/* + * Copyright 2026-present 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 java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.util.Assert; + +/** + * PostgreSQL upsert using {@code INSERT ... ON CONFLICT ... DO UPDATE SET}. + * + * @since 4.x + */ +public enum PostgresUpsertRenderContext implements UpsertRenderContext { + + INSTANCE; + + @Override + public String renderUpsert(Table table, Columns columns, Function bindMarkerFn) { + + Assert.notEmpty(columns.insertColumns(), "Insert columns must not be empty"); + Assert.notEmpty(columns.filterColumns(), "Filter columns must not be empty"); + + String tableName = columns.tableName(table); + String insertColumnNames = String.join(", ", columns.insertColumnNames()); + String bindMarkers = String.join(", ", columns.insertColumnBindMarkers(bindMarkerFn)); + String filterColumnNames = String.join(", ", columns.filterColumnNames()); + String setValues = setValuesSnippet(columns); + + return "INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s".formatted(// + tableName, // + insertColumnNames, // + bindMarkers, // + filterColumnNames, // + setValues); + } + + private static String setValuesSnippet(Columns columns) { + + List updateColumns = columns.updateColumns(); + + if (updateColumns.isEmpty()) { + updateColumns = columns.filterColumns(); + } + + return updateColumns.stream().map(col -> { + String colName = col.toSql(columns.identifierProcessing()); + return "%s = EXCLUDED.%s".formatted(colName, colName); + }).collect(Collectors.joining(", ")); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/RenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/RenderContext.java index 72b255c62d..d2bcb9ea38 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/RenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/RenderContext.java @@ -15,7 +15,6 @@ */ package org.springframework.data.relational.core.sql.render; -import org.jspecify.annotations.Nullable; import org.springframework.data.relational.core.dialect.InsertRenderContext; import org.springframework.data.relational.core.sql.IdentifierProcessing; @@ -25,6 +24,7 @@ * @author Mark Paluch * @author Mikhail Polivakha * @author Jens Schauder + * @author Christoph Strobl * @since 1.1 */ public interface RenderContext { @@ -55,9 +55,8 @@ public interface RenderContext { InsertRenderContext getInsertRenderContext(); /** - * @return the {@link UpsertRenderContext}, or {@literal null} if upsert is not supported. + * @return the {@link UpsertRenderContext}. * @since 4.x */ - @Nullable UpsertRenderContext getUpsertRenderContext(); } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/SqlServerUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlServerUpsertRenderContext.java similarity index 85% rename from spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/SqlServerUpsertRenderContext.java rename to spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlServerUpsertRenderContext.java index b819ed5e57..906b673d50 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/SqlServerUpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlServerUpsertRenderContext.java @@ -13,13 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.jdbc.core.dialect; +package org.springframework.data.relational.core.sql.render; import java.util.function.Function; import org.springframework.data.relational.core.sql.SqlIdentifier; -import org.springframework.data.relational.core.sql.render.StandardSqlUpsertRenderContext; -import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.data.relational.core.sql.Table; /** diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java index b48d65d028..b4d4f3a0e9 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java @@ -16,10 +16,8 @@ package org.springframework.data.relational.core.sql.render; import java.util.List; -import java.util.Set; import java.util.function.Function; -import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; import org.springframework.util.Assert; @@ -27,8 +25,7 @@ /** * Standard SQL {@code MERGE} upsert for dialects that support it (like H2, HSQLDB, SQL Server, DB2). *

- * Uses a table value constructor {@code (VALUES (?, ?)) AS s (col1, col2)} as the source so that - * no SELECT is used. + * Uses a table value constructor {@code (VALUES (?, ?)) AS s (col1, col2)} as the source so that no SELECT is used. * * @since 4.x */ @@ -36,51 +33,47 @@ public enum StandardSqlUpsertRenderContext implements UpsertRenderContext { INSTANCE; - @Override - public String renderUpsert(Table table, Columns merge, Function bindMarkerFn) { - - List insertColumns = merge.insertColumns(); - List conflictColumns = merge.filterColumns(); - IdentifierProcessing identifierProcessing = merge.identifierProcessing(); + private static final String targetTableAlias = "_t"; + private static final String sourceTableAlias = "_s"; - Assert.notEmpty(insertColumns, "Insert columns must not be empty"); - Assert.notEmpty(conflictColumns, "Conflict columns must not be empty"); + @Override + public String renderUpsert(Table table, Columns columns, Function bindMarkerFn) { - Set conflictSet = Set.copyOf(conflictColumns); - String tableSql = table.getName().toSql(identifierProcessing); + Assert.notEmpty(columns.insertColumns(), "Insert columns must not be empty"); + Assert.notEmpty(columns.filterColumns(), "Filter columns must not be empty"); - String valuesList = String.join(", ", insertColumns.stream().map(bindMarkerFn).toList()); - String sourceColumnsSql = String.join(", ", - insertColumns.stream().map(col -> col.toSql(identifierProcessing)).toList()); + String targetTableAlias = columns.identifierProcessing().quote(StandardSqlUpsertRenderContext.targetTableAlias); + String sourceTableAlias = columns.identifierProcessing().quote(StandardSqlUpsertRenderContext.sourceTableAlias); - String onCondition = String.join(" AND ", conflictColumns.stream() - .map(col -> "t." + col.toSql(identifierProcessing) + " = s." + col.toSql(identifierProcessing)) - .toList()); + String tableName = columns.tableName(table); + String insertColumnNames = String.join(", ", columns.insertColumnNames()); + String bindMarkers = String.join(", ", columns.insertColumnBindMarkers(bindMarkerFn)); - List updateColumns = insertColumns.stream() - .filter(col -> !conflictSet.contains(col)) - .toList(); + String onCondition = String.join(" AND ", columns.filterColumns().stream().map(col -> { + String colName = columns.column(col); + return "%s.%s = %s.%s".formatted(targetTableAlias, colName, sourceTableAlias, colName); + }).toList()); - String updateSetClause; - if (updateColumns.isEmpty()) { - SqlIdentifier firstConflict = conflictColumns.get(0); - updateSetClause = "t." + firstConflict.toSql(identifierProcessing) + " = s." - + firstConflict.toSql(identifierProcessing); - } else { - updateSetClause = String.join(", ", updateColumns.stream() - .map(col -> "t." + col.toSql(identifierProcessing) + " = s." + col.toSql(identifierProcessing)) - .toList()); - } + List updateColumns = columns.updateColumns(); - String insertColumnsSql = String.join(", ", - insertColumns.stream().map(col -> col.toSql(identifierProcessing)).toList()); + String updateSetClause = String.join(", ", updateColumns.stream().map(col -> { + String colName = columns.column(col); + return "%s.%s = %s.%s".formatted(targetTableAlias, colName, sourceTableAlias, colName); + }).toList()); String insertValuesSql = String.join(", ", - insertColumns.stream().map(col -> "s." + col.toSql(identifierProcessing)).toList()); + columns.insertColumns().stream().map(col -> columns.column(sourceTableAlias, col)).toList()); - return "MERGE INTO " + tableSql + " t USING (VALUES (" + valuesList + ")) AS s (" + sourceColumnsSql + ") ON " - + onCondition - + " WHEN MATCHED THEN UPDATE SET " + updateSetClause - + " WHEN NOT MATCHED THEN INSERT (" + insertColumnsSql + ") VALUES (" + insertValuesSql + ")"; + return "MERGE INTO %s %s USING (VALUES (%s)) AS %s (%s) ON %s WHEN MATCHED THEN UPDATE SET %s WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s)" + .formatted( // + tableName, // + targetTableAlias, // + bindMarkers, // + sourceTableAlias, // + insertColumnNames, // + onCondition, // + updateSetClause, // + insertColumnNames, // + insertValuesSql); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertRenderContext.java index dc80c7ed8e..172b783f62 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertRenderContext.java @@ -48,5 +48,37 @@ public interface UpsertRenderContext { */ record Columns(List insertColumns, List filterColumns, IdentifierProcessing identifierProcessing) { + + String tableName(Table table) { + return table.getName().toSql(identifierProcessing); + } + + List insertColumnNames() { + return insertColumns.stream().map(this::column).toList(); + } + + List filterColumnNames(String tableAlias) { + return filterColumns.stream().map(col -> tableAlias + "." + column(col)).toList(); + } + + List filterColumnNames() { + return filterColumns.stream().map(this::column).toList(); + } + + List insertColumnBindMarkers(Function bindMarkerFn) { + return insertColumns.stream().map(bindMarkerFn).toList(); + } + + List updateColumns() { + return insertColumns.stream().filter(col -> !filterColumns.contains(col)).toList(); + } + + String column(String tableAlias, SqlIdentifier column) { + return tableAlias + "." + column(column); + } + + String column(SqlIdentifier column) { + return column.toSql(identifierProcessing); + } } } diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java index 2c34912996..f608c88377 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java @@ -18,7 +18,12 @@ import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.Test; import org.springframework.data.relational.core.dialect.RenderContextFactory; +import org.springframework.data.relational.core.sql.render.MySqlUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.OracleUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.PostgresUpsertRenderContext; import org.springframework.data.relational.core.sql.render.SelectRenderContext; +import org.springframework.data.relational.core.sql.render.SqlServerUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.StandardSqlUpsertRenderContext; import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import com.tngtech.archunit.base.DescribedPredicate; @@ -49,6 +54,11 @@ void cycleFree() { .that(onlySpringData()) // .that(ignore(SelectRenderContext.class)) // .that(ignore(UpsertRenderContext.class)) // + .that(ignore(PostgresUpsertRenderContext.class)) // + .that(ignore(MySqlUpsertRenderContext.class)) // + .that(ignore(OracleUpsertRenderContext.class)) // + .that(ignore(SqlServerUpsertRenderContext.class)) // + .that(ignore(StandardSqlUpsertRenderContext.class)) // .that(ignore(RenderContextFactory.class)); ArchRule rule = SlicesRuleDefinition.slices() // diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java index 7e0f79b665..a23884f1ee 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java @@ -15,6 +15,8 @@ */ package org.springframework.data.relational.core.sql.render; +import static org.assertj.core.api.Assertions.assertThat; + import java.util.List; import java.util.function.Function; @@ -24,8 +26,6 @@ import org.springframework.data.relational.core.sql.Table; import org.springframework.data.relational.core.sql.render.UpsertRenderContext.Columns; -import static org.assertj.core.api.Assertions.assertThat; - /** * Unit tests for {@link StandardSqlUpsertRenderContext}. */ @@ -38,16 +38,6 @@ class StandardSqlUpsertRenderContextUnitTests { private static final Function BIND_MARKER = id -> ":" + id.getReference(); private static final IdentifierProcessing IDENTIFIER_PROCESSING = IdentifierProcessing.ANSI; - @Test // GH-493 - void mergeUpsertRendersMergeInto() { - - String sql = StandardSqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, - IDENTIFIER_PROCESSING), BIND_MARKER); - - assertThat(sql).isEqualTo( - "MERGE INTO my_table t USING (VALUES (:id, :name)) AS s (id, name) ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.name = s.name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name)"); - } - @Test // GH-493 void mergeUpsertWithMultipleConflictColumnsBuildsFilterClauseWithAllColumns() { @@ -58,8 +48,9 @@ void mergeUpsertWithMultipleConflictColumnsBuildsFilterClauseWithAllColumns() { String sql = StandardSqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, columns, BIND_MARKER); - assertThat(sql).contains("ON t.tenant_id = s.tenant_id AND t.id = s.id"); - assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET t.name = s.name"); - assertThat(sql).contains("WHEN NOT MATCHED THEN INSERT (tenant_id, id, name) VALUES (s.tenant_id, s.id, s.name)"); + assertThat(sql).contains("ON \"_t\".tenant_id = \"_s\".tenant_id AND \"_t\".id = \"_s\".id"); + assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name"); + assertThat(sql).contains( + "WHEN NOT MATCHED THEN INSERT (tenant_id, id, name) VALUES (\"_s\".tenant_id, \"_s\".id, \"_s\".name)"); } } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/dialect/UpsertRenderContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java similarity index 60% rename from spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/dialect/UpsertRenderContextUnitTests.java rename to spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java index 26d70dd9f8..a0edcd6b6c 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/dialect/UpsertRenderContextUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.jdbc.core.dialect; +package org.springframework.data.relational.core.sql.render; import static org.assertj.core.api.Assertions.assertThat; @@ -24,7 +24,6 @@ import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; -import org.springframework.data.relational.core.sql.render.UpsertRenderContext; import org.springframework.data.relational.core.sql.render.UpsertRenderContext.Columns; /** @@ -39,21 +38,24 @@ class UpsertRenderContextUnitTests { private static final Function BIND_MARKER = id -> ":" + id.getReference(); private static final IdentifierProcessing IDENTIFIER_PROCESSING = IdentifierProcessing.ANSI; + @Test // GH-493 + void standardUpsertRendersMergeInto() { + + String sql = StandardSqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, + new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); + + assertThat(sql).isEqualTo( + "MERGE INTO my_table \"_t\" USING (VALUES (:id, :name)) AS \"_s\" (id, name) ON \"_t\".id = \"_s\".id WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name)"); + } + @Test // GH-493 void postgresUpsertRendersInsertOnConflictDoUpdate() { String sql = PostgresUpsertRenderContext.INSTANCE.renderUpsert(TABLE, new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); - assertThat(sql).startsWith("INSERT INTO"); - assertThat(sql).contains("my_table"); - assertThat(sql).contains("id"); - assertThat(sql).contains("name"); - assertThat(sql).contains(":id"); - assertThat(sql).contains(":name"); - assertThat(sql).contains("ON CONFLICT ("); - assertThat(sql).contains("DO UPDATE SET"); - assertThat(sql).contains("EXCLUDED"); + assertThat(sql).isEqualToIgnoringWhitespace( + "INSERT INTO my_table (id, name) VALUES (:id, :name) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name"); } @Test // GH-493 @@ -62,10 +64,19 @@ void mySqlUpsertRendersOnDuplicateKeyUpdate() { String sql = MySqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); - assertThat(sql).startsWith("INSERT INTO"); - assertThat(sql).contains("my_table"); - assertThat(sql).contains("ON DUPLICATE KEY UPDATE"); - assertThat(sql).contains("VALUES("); + assertThat(sql).isEqualToIgnoringWhitespace( + "INSERT INTO my_table (id, name) VALUES (:id, :name) ON DUPLICATE KEY UPDATE name = VALUES(name)"); + } + + @Test // GH-493 + // TODO: should we have all values in the update or just a single one in this case. + void mySqlUpsertRendersCorrectlyWhenUpdateCoversEntireKey() { + + String sql = MySqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, + new Columns(INSERT_COLUMNS, INSERT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); + + assertThat(sql).isEqualToIgnoringWhitespace( + "INSERT INTO my_table (id, name) VALUES (:id, :name) ON DUPLICATE KEY UPDATE id = VALUES(id), name = VALUES(name)"); } @Test // GH-493 @@ -74,6 +85,7 @@ void oracleMergeUpsertRendersOnConditionInParentheses() { String sql = OracleUpsertRenderContext.INSTANCE.renderUpsert(TABLE, new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); + System.out.println("sql: " + sql); assertThat(sql).startsWith("MERGE INTO"); assertThat(sql).contains("USING (SELECT"); assertThat(sql).contains("FROM DUAL"); @@ -82,4 +94,17 @@ void oracleMergeUpsertRendersOnConditionInParentheses() { assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET"); assertThat(sql).contains("WHEN NOT MATCHED THEN INSERT"); } + + @Test // GH-493 + void sqlServerUpsertRendersMergeWithSemicolon() { + + String sql = SqlServerUpsertRenderContext.INSTANCE.renderUpsert(TABLE, + new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); + + assertThat(sql).contains("MERGE INTO"); + assertThat(sql).contains("my_table"); + assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET"); + assertThat(sql).contains("WHEN NOT MATCHED THEN INSERT"); + assertThat(sql.trim()).endsWith(";"); + } } From 5a9e87b7855c802925c06c297e315371cbfbd55e Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Wed, 18 Mar 2026 08:24:05 +0100 Subject: [PATCH 05/12] Update tests and optimize conflict strategy. still need to figure out mssql idenity insert alternative and mysql update when insert matches conflict. --- .../jdbc/core/dialect/DialectResolver.java | 5 + ...JdbcAggregateTemplateIntegrationTests.java | 112 +++++++++++++++--- .../sql/render/UpsertRendererUnitTests.java | 16 +++ .../sql/render/OracleUpsertRenderContext.java | 82 ++++++------- .../render/PostgresUpsertRenderContext.java | 10 +- .../StandardSqlUpsertRenderContext.java | 45 ++++--- .../render/UpsertRenderContextUnitTests.java | 59 +++++++-- 7 files changed, 244 insertions(+), 85 deletions(-) diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/DialectResolver.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/DialectResolver.java index 0ae794babc..2dbda1b0a7 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/DialectResolver.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/DialectResolver.java @@ -205,6 +205,11 @@ public JdbcDialectAdapter(Dialect delegate) { this.arrayColumns = new JdbcArrayColumnsAdapter(delegate.getArraySupport()); } + @Override + public String getName() { + return delegate.getName(); + } + @Override public LimitClause limit() { return delegate.limit(); diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java index 139de48c39..ddddeb2c7e 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java @@ -189,34 +189,108 @@ private static LegoSet createLegoSet(String name) { return entity; } + private void withSqlServerIdentityInsertOn(JdbcAggregateOperations jdbcAggregateTemplate, String tableName, + Runnable action) { + + if (jdbcAggregateTemplate.getDataAccessStrategy().getDialect() instanceof SqlServerDialect) { + jdbc.getJdbcOperations().execute("SET IDENTITY_INSERT " + tableName + " ON"); + try { + action.run(); + } finally { + jdbc.getJdbcOperations().execute("SET IDENTITY_INSERT " + tableName + " OFF"); + } + } else { + action.run(); + } + } + @Test // GH-493 void upsertInsertsWhenIdDoesNotExistAndUpdatesWhenItExists() { - assumeThat(template).isInstanceOf(JdbcAggregateTemplate.class); - JdbcAggregateTemplate jdbcTemplate = (JdbcAggregateTemplate) template; - assumeThat(jdbcTemplate.getDataAccessStrategy().getDialect().getUpsertRenderContext()).isNotNull(); + withSqlServerIdentityInsertOn(template, "with_insert_only", () -> { - if(template.getDataAccessStrategy().getDialect() instanceof SqlServerDialect) { - String tableName = "with_insert_only"; - jdbc.getJdbcOperations().execute("SET IDENTITY_INSERT " + tableName + " ON"); - } + WithInsertOnly entity = new WithInsertOnly(); + entity.id = 8888L; + entity.insertOnly = "upserted"; + template.upsert(entity); - WithInsertOnly entity = new WithInsertOnly(); - entity.id = 8888L; - entity.insertOnly = "upserted"; - template.upsert(entity); + assertThat(template.findById(8888L, WithInsertOnly.class).insertOnly).isEqualTo("upserted"); + + entity.insertOnly = "updated"; + template.upsert(entity); - assertThat(template.findById(8888L, WithInsertOnly.class).insertOnly).isEqualTo("upserted"); + assertThat(template.findById(8888L, WithInsertOnly.class).insertOnly).isEqualTo("updated"); + }); + } - entity.insertOnly = "updated"; - template.upsert(entity); + @Test // GH-493 + void upsertWhenMatchedAndUpdateAssignmentsEqualConflictKeyOnly() { - assertThat(template.findById(8888L, WithInsertOnly.class).insertOnly).isEqualTo("updated"); + long id = 8889L; + withSqlServerIdentityInsertOn(template, "with_id_only", () -> { - if(template.getDataAccessStrategy().getDialect() instanceof SqlServerDialect) { - String tableName = "with_insert_only"; - jdbc.getJdbcOperations().execute("SET IDENTITY_INSERT " + tableName + " OFF"); - } + WithIdOnly first = new WithIdOnly(); + first.id = id; + template.upsert(first); + + WithIdOnly second = new WithIdOnly(); + second.id = id; + template.upsert(second); + + assertThat(template.findById(id, WithIdOnly.class).id).isEqualTo(id); + }); + } + + @Test // GH-493 + void upsertNoOpWhenNonKeyColumnsAlreadyMatch() { + + long id = 8890L; + withSqlServerIdentityInsertOn(template, "LEGO_SET", () -> { + + LegoSet lego = new LegoSet(); + lego.id = id; + lego.name = "millennium"; + template.upsert(lego); + template.upsert(lego); + + assertThat(template.findById(id, LegoSet.class).name).isEqualTo("millennium"); + }); + } + + @Test // GH-493 + void upsertAfterDeleteInsertsAgain() { + + long id = 8891L; + withSqlServerIdentityInsertOn(template, "LEGO_SET", () -> { + + LegoSet lego = new LegoSet(); + lego.id = id; + lego.name = "first"; + template.upsert(lego); + + template.deleteById(id, LegoSet.class); + + lego.name = "second"; + template.upsert(lego); + + assertThat(template.findById(id, LegoSet.class).name).isEqualTo("second"); + }); + } + + @Test // GH-493 + void upsertExistingRowWithSameInsertOnlyValue() { + + withSqlServerIdentityInsertOn(template, "with_insert_only", () -> { + + long id = 8892L; + WithInsertOnly entity = new WithInsertOnly(); + entity.id = id; + entity.insertOnly = "unchanged"; + template.upsert(entity); + template.upsert(entity); + + assertThat(template.findById(id, WithInsertOnly.class).insertOnly).isEqualTo("unchanged"); + }); } @Test // GH-1446 diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java index 63abaf8a33..f8e63c527e 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; +import org.springframework.data.jdbc.core.dialect.JdbcOracleDialect; import org.springframework.data.relational.core.dialect.RenderContextFactory; import org.springframework.data.relational.core.sql.SQL; import org.springframework.data.relational.core.sql.StatementBuilder; @@ -124,4 +125,19 @@ void h2RendersMerge() { assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET"); assertThat(sql).contains("WHEN NOT MATCHED THEN INSERT"); } + + @Test // GH-493 + void oracleIdOnlyMergeOmitsWhenMatchedUpdate() { + + Table table = SQL.table("ent"); + Upsert upsert = StatementBuilder.upsert().table(table).columnValue(table.column("id").set(SQL.bindMarker("id"))) + .where(table.column("id").isEqualTo(SQL.bindMarker("id"))).build(); + + var context = new RenderContextFactory(JdbcOracleDialect.INSTANCE).createRenderContext(); + String sql = SqlRenderer.create(context).render(upsert); + + assertThat(sql).contains("MERGE INTO"); + assertThat(sql).contains("WHEN NOT MATCHED THEN INSERT"); + assertThat(sql).doesNotContain("WHEN MATCHED THEN UPDATE SET"); + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OracleUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OracleUpsertRenderContext.java index 316ecc587f..3e218e0412 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OracleUpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OracleUpsertRenderContext.java @@ -16,17 +16,14 @@ package org.springframework.data.relational.core.sql.render; import java.util.List; -import java.util.Set; import java.util.function.Function; -import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; import org.springframework.util.Assert; /** - * Oracle MERGE upsert. Uses {@code SELECT ... FROM DUAL} as the source, which Oracle requires for - * literal/bind-marker-only selects. + * Oracle MERGE upsert. Uses {@code SELECT ... FROM DUAL} for source values. * * @since 4.x */ @@ -34,53 +31,56 @@ public enum OracleUpsertRenderContext implements UpsertRenderContext { INSTANCE; - private static final String SOURCE_FROM_CLAUSE = " FROM DUAL"; - @Override - public String renderUpsert(Table table, Columns merge, Function bindMarkerFn) { + public String renderUpsert(Table table, Columns columns, Function bindMarkerFn) { - List insertColumns = merge.insertColumns(); - List conflictColumns = merge.filterColumns(); - IdentifierProcessing identifierProcessing = merge.identifierProcessing(); + Assert.notEmpty(columns.insertColumns(), "Insert columns must not be empty"); + Assert.notEmpty(columns.filterColumns(), "Filter columns must not be empty"); - Assert.notEmpty(insertColumns, "Insert columns must not be empty"); - Assert.notEmpty(conflictColumns, "Conflict columns must not be empty"); + String targetTableAlias = columns.identifierProcessing().quote(StandardSqlUpsertRenderContext.targetTableAlias); + String sourceTableAlias = columns.identifierProcessing().quote(StandardSqlUpsertRenderContext.sourceTableAlias); - Set conflictSet = Set.copyOf(conflictColumns); - String tableSql = table.getName().toSql(identifierProcessing); + String tableName = columns.tableName(table); + String insertColumnNames = String.join(", ", columns.insertColumnNames()); + String sourceSelectList = String.join(", ", + columns.insertColumns().stream().map(col -> bindMarkerFn.apply(col) + " AS " + columns.column(col)).toList()); - String sourceSelectList = String.join(", ", insertColumns.stream() - .map(col -> bindMarkerFn.apply(col) + " AS " + col.toSql(identifierProcessing)) - .toList()); + String onCondition = String.join(" AND ", columns.filterColumns().stream().map(col -> { + String colName = columns.column(col); + return "%s.%s = %s.%s".formatted(targetTableAlias, colName, sourceTableAlias, colName); + }).toList()); - String onCondition = "(" + String.join(" AND ", conflictColumns.stream() - .map(col -> "t." + col.toSql(identifierProcessing) + " = s." + col.toSql(identifierProcessing)) - .toList()) + ")"; + String insertValuesSql = String.join(", ", + columns.insertColumns().stream().map(col -> columns.column(sourceTableAlias, col)).toList()); - List updateColumns = insertColumns.stream() - .filter(col -> !conflictSet.contains(col)) - .toList(); + String insertClause = "WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s)".formatted(insertColumnNames, + insertValuesSql); - String updateSetClause; + List updateColumns = columns.updateColumns(); if (updateColumns.isEmpty()) { - SqlIdentifier firstConflict = conflictColumns.get(0); - updateSetClause = "t." + firstConflict.toSql(identifierProcessing) + " = s." - + firstConflict.toSql(identifierProcessing); - } else { - updateSetClause = String.join(", ", updateColumns.stream() - .map(col -> "t." + col.toSql(identifierProcessing) + " = s." + col.toSql(identifierProcessing)) - .toList()); + // ORA-38104: columns referenced in ON cannot be updated; omit WHEN MATCHED so existing rows are left + // unchanged (same as a no-op update of key-only columns). + return "MERGE INTO %s %s USING (SELECT %s FROM DUAL) %s ON (%s) %s".formatted( // + tableName, // + targetTableAlias, // + sourceSelectList, // + sourceTableAlias, // + onCondition, // + insertClause); } - String insertColumnsSql = String.join(", ", - insertColumns.stream().map(col -> col.toSql(identifierProcessing)).toList()); - - String insertValuesSql = String.join(", ", - insertColumns.stream().map(col -> "s." + col.toSql(identifierProcessing)).toList()); - - return "MERGE INTO " + tableSql + " t USING (SELECT " + sourceSelectList + SOURCE_FROM_CLAUSE + ") s ON " - + onCondition - + " WHEN MATCHED THEN UPDATE SET " + updateSetClause - + " WHEN NOT MATCHED THEN INSERT (" + insertColumnsSql + ") VALUES (" + insertValuesSql + ")"; + String updateSetClause = String.join(", ", updateColumns.stream().map(col -> { + String colName = columns.column(col); + return "%s.%s = %s.%s".formatted(targetTableAlias, colName, sourceTableAlias, colName); + }).toList()); + + return "MERGE INTO %s %s USING (SELECT %s FROM DUAL) %s ON (%s) WHEN MATCHED THEN UPDATE SET %s %s".formatted( // + tableName, // + targetTableAlias, // + sourceSelectList, // + sourceTableAlias, // + onCondition, // + updateSetClause, // + insertClause); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostgresUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostgresUpsertRenderContext.java index 975a91644b..33cd00644c 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostgresUpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostgresUpsertRenderContext.java @@ -42,8 +42,16 @@ public String renderUpsert(Table table, Columns columns, Function bindMarkerFn) { @@ -54,26 +54,39 @@ public String renderUpsert(Table table, Columns columns, Function columns.column(sourceTableAlias, col)).toList()); + + String insertClause = "WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s)".formatted(insertColumnNames, + insertValuesSql); + List updateColumns = columns.updateColumns(); + if (updateColumns.isEmpty()) { + // Matched rows are left unchanged. Updating only key columns is invalid on SQL Server (identity) and Oracle + // (ORA-38104). + return "MERGE INTO %s %s USING (VALUES (%s)) AS %s (%s) ON %s %s".formatted( // + tableName, // + targetTableAlias, // + bindMarkers, // + sourceTableAlias, // + insertColumnNames, // + onCondition, // + insertClause); + } String updateSetClause = String.join(", ", updateColumns.stream().map(col -> { String colName = columns.column(col); return "%s.%s = %s.%s".formatted(targetTableAlias, colName, sourceTableAlias, colName); }).toList()); - String insertValuesSql = String.join(", ", - columns.insertColumns().stream().map(col -> columns.column(sourceTableAlias, col)).toList()); - - return "MERGE INTO %s %s USING (VALUES (%s)) AS %s (%s) ON %s WHEN MATCHED THEN UPDATE SET %s WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s)" - .formatted( // - tableName, // - targetTableAlias, // - bindMarkers, // - sourceTableAlias, // - insertColumnNames, // - onCondition, // - updateSetClause, // - insertColumnNames, // - insertValuesSql); + return "MERGE INTO %s %s USING (VALUES (%s)) AS %s (%s) ON %s WHEN MATCHED THEN UPDATE SET %s %s".formatted( // + tableName, // + targetTableAlias, // + bindMarkers, // + sourceTableAlias, // + insertColumnNames, // + onCondition, // + updateSetClause, // + insertClause); } } diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java index a0edcd6b6c..b8012539bb 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java @@ -48,6 +48,23 @@ void standardUpsertRendersMergeInto() { "MERGE INTO my_table \"_t\" USING (VALUES (:id, :name)) AS \"_s\" (id, name) ON \"_t\".id = \"_s\".id WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name)"); } + @Test // GH-493 + void mergeUpsertWithMultipleConflictColumnsBuildsFilterClauseWithAllColumns() { + + List insertColumns = List.of(SqlIdentifier.unquoted("tenant_id"), SqlIdentifier.unquoted("id"), + SqlIdentifier.unquoted("name")); + List conflictColumns = List.of(SqlIdentifier.unquoted("tenant_id"), SqlIdentifier.unquoted("id")); + Columns columns = new Columns(insertColumns, conflictColumns, IDENTIFIER_PROCESSING); + + String sql = StandardSqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, columns, BIND_MARKER); + + assertThat(sql).isEqualToIgnoringWhitespace( + "MERGE INTO my_table \"_t\" USING (VALUES (:tenant_id, :id, :name)) AS \"_s\" (tenant_id, id, name) " + + "ON \"_t\".tenant_id = \"_s\".tenant_id AND \"_t\".id = \"_s\".id " + + "WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name " + + "WHEN NOT MATCHED THEN INSERT (tenant_id, id, name) VALUES (\"_s\".tenant_id, \"_s\".id, \"_s\".name)"); + } + @Test // GH-493 void postgresUpsertRendersInsertOnConflictDoUpdate() { @@ -58,6 +75,16 @@ void postgresUpsertRendersInsertOnConflictDoUpdate() { "INSERT INTO my_table (id, name) VALUES (:id, :name) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name"); } + @Test // GH-493 + void postgresUpsertRendersInsertOnConflictDoNothing() { + + String sql = PostgresUpsertRenderContext.INSTANCE.renderUpsert(TABLE, + new Columns(INSERT_COLUMNS, INSERT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); + + assertThat(sql).isEqualToIgnoringWhitespace( + "INSERT INTO my_table (id, name) VALUES (:id, :name) ON CONFLICT (id, name) DO NOTHING"); + } + @Test // GH-493 void mySqlUpsertRendersOnDuplicateKeyUpdate() { @@ -85,14 +112,30 @@ void oracleMergeUpsertRendersOnConditionInParentheses() { String sql = OracleUpsertRenderContext.INSTANCE.renderUpsert(TABLE, new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); - System.out.println("sql: " + sql); - assertThat(sql).startsWith("MERGE INTO"); - assertThat(sql).contains("USING (SELECT"); - assertThat(sql).contains("FROM DUAL"); - // Oracle requires ON condition in parentheses (ORA-00969 otherwise). - assertThat(sql).contains("ON (t.id = s.id)"); - assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET"); - assertThat(sql).contains("WHEN NOT MATCHED THEN INSERT"); + assertThat(sql).isEqualToIgnoringWhitespace( + "MERGE INTO my_table \"_t\" USING (SELECT :id AS id, :name AS name FROM DUAL) \"_s\" ON (\"_t\".id = \"_s\".id) WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name)"); + } + + @Test // GH-493 + void standardMergeIdOnlyOmitsWhenMatchedUpdate() { + + List idOnly = List.of(SqlIdentifier.unquoted("id")); + String sql = StandardSqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, new Columns(idOnly, idOnly, + IDENTIFIER_PROCESSING), BIND_MARKER); + + assertThat(sql).isEqualTo( + "MERGE INTO my_table \"_t\" USING (VALUES (:id)) AS \"_s\" (id) ON \"_t\".id = \"_s\".id WHEN NOT MATCHED THEN INSERT (id) VALUES (\"_s\".id)"); + } + + @Test // GH-493 + void oracleMergeIdOnlyOmitsWhenMatchedUpdate() { + + List idOnly = List.of(SqlIdentifier.unquoted("id")); + String sql = OracleUpsertRenderContext.INSTANCE.renderUpsert(TABLE, new Columns(idOnly, idOnly, + IDENTIFIER_PROCESSING), BIND_MARKER); + + assertThat(sql).isEqualToIgnoringWhitespace( + "MERGE INTO my_table \"_t\" USING (SELECT :id AS id FROM DUAL) \"_s\" ON (\"_t\".id = \"_s\".id) WHEN NOT MATCHED THEN INSERT (id) VALUES (\"_s\".id)"); } @Test // GH-493 From 5048592efda7a9bec669e5d74ede7423b528cfd6 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Thu, 19 Mar 2026 09:08:35 +0100 Subject: [PATCH 06/12] Move over to UpsertRenderer. --- .../data/jdbc/core/JdbcAggregateTemplate.java | 2 +- .../convert/CascadingDataAccessStrategy.java | 8 +- .../jdbc/core/convert/DataAccessStrategy.java | 8 +- .../convert/DefaultDataAccessStrategy.java | 9 +- .../convert/DelegatingDataAccessStrategy.java | 4 +- .../mybatis/MyBatisDataAccessStrategy.java | 2 +- .../sql/render/ConflictColumnCollector.java | 16 +- .../sql/render/MySqlUpsertRenderContext.java | 39 +-- .../sql/render/OracleUpsertRenderContext.java | 59 +---- .../render/PostgresUpsertRenderContext.java | 49 +--- .../core/sql/render/SqlRenderer.java | 1 + .../render/SqlServerUpsertRenderContext.java | 14 +- .../StandardSqlUpsertRenderContext.java | 63 +---- .../core/sql/render/UpsertRenderContext.java | 61 +---- .../sql/render/UpsertStatementRenderer.java | 227 +++++++++++++++++ .../sql/render/UpsertStatementRenderers.java | 230 ++++++++++++++++++ .../sql/render/UpsertStatementVisitor.java | 47 +--- .../data/relational/DependencyTests.java | 1 + ...andardSqlUpsertRenderContextUnitTests.java | 20 +- .../render/UpsertRenderContextUnitTests.java | 66 ++--- 20 files changed, 556 insertions(+), 370 deletions(-) create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderer.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderers.java diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java index b4ee10bd32..984fc5a6a0 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java @@ -274,7 +274,7 @@ public T upsert(T instance) { Assert.notNull(instance, "Aggregate instance must not be null"); Class entityType = (Class) ClassUtils.getUserClass(instance); - accessStrategy.upsert(instance, entityType, Identifier.empty()); + accessStrategy.upsert(instance, entityType); return instance; } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java index 565579802b..e274c96715 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java @@ -15,7 +15,7 @@ */ package org.springframework.data.jdbc.core.convert; -import static java.lang.Boolean.*; +import static java.lang.Boolean.TRUE; import java.util.ArrayList; import java.util.List; @@ -25,7 +25,6 @@ import java.util.stream.Stream; import org.jspecify.annotations.Nullable; - import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.mapping.PersistentPropertyPath; @@ -49,6 +48,7 @@ * @author Chirag Tailor * @author Diego Krupitza * @author Sergey Korotaev + * @author Christoph Strobl * @since 1.1 */ public class CascadingDataAccessStrategy implements DataAccessStrategy { @@ -88,8 +88,8 @@ public NamedParameterJdbcOperations getJdbcOperations() { } @Override - public int upsert(T instance, Class domainType, Identifier identifier) { - return collect(das -> das.upsert(instance, domainType, identifier)); + public int upsert(T instance, Class domainType) { + return collect(das -> das.upsert(instance, domainType)); } @Override diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java index 4820e961c3..8c12176661 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java @@ -45,6 +45,7 @@ * @author Chirag Tailor * @author Diego Krupitza * @author Sergey Korotaev + * @author Christoph Strobl */ public interface DataAccessStrategy extends ReadingDataAccessStrategy, RelationResolver { @@ -119,18 +120,17 @@ public interface DataAccessStrategy extends ReadingDataAccessStrategy, RelationR boolean updateWithVersion(T instance, Class domainType, Number previousVersion); /** - * Upserts the data of a single entity (insert if row for id does not exist, update if it exists). Requires a - * provided id. Only supported when the dialect supports single-statement upsert. + * Upserts the data of a single entity (insert if row for id does not exist, update if it exists). Requires the + * instance to hold an id. Only supported when the dialect supports single-statement upsert. * * @param instance the instance to upsert. Must not be {@code null}. Must have an id set. * @param domainType the type of the instance. Must not be {@code null}. - * @param identifier information about data that needs to be considered (e.g. back-references). May be empty for root. * @param the type of the instance. * @return the number of rows affected by the upsert. * @throws UnsupportedOperationException if the dialect does not support upsert. * @since 4.x */ - int upsert(T instance, Class domainType, Identifier identifier); + int upsert(T instance, Class domainType); /** * Deletes a single row identified by the id, from the table identified by the domainType. Does not handle cascading 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 be99cbc640..dd171b54b5 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 @@ -15,7 +15,7 @@ */ package org.springframework.data.jdbc.core.convert; -import static org.springframework.data.jdbc.core.convert.SqlGenerator.*; +import static org.springframework.data.jdbc.core.convert.SqlGenerator.VERSION_SQL_PARAMETER; import java.sql.ResultSet; import java.sql.SQLException; @@ -27,7 +27,6 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; - import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -184,14 +183,14 @@ public boolean updateWithVersion(S instance, Class domainType, Number pre } @Override - public int upsert(T instance, Class domainType, Identifier identifier) { + public int upsert(T instance, Class domainType) { - SqlIdentifierParameterSource parameterSource = sqlParametersFactory.forInsert(instance, domainType, identifier, + SqlIdentifierParameterSource parameterSource = sqlParametersFactory.forInsert(instance, domainType, Identifier.empty(), IdValueSource.PROVIDED); String statement = sql(domainType).getUpsert(parameterSource.getIdentifiers()); - if(logger.isTraceEnabled()) { + if (logger.isTraceEnabled()) { logger.trace("Upsert: [%s]".formatted(statement)); } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java index a271e60dcc..9abf9f7d46 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java @@ -81,8 +81,8 @@ public NamedParameterJdbcOperations getJdbcOperations() { } @Override - public int upsert(T instance, Class domainType, Identifier identifier) { - return delegate.upsert(instance, domainType, identifier); + public int upsert(T instance, Class domainType) { + return delegate.upsert(instance, domainType); } @Override diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java index 6d542615bb..921b0f1f80 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java @@ -185,7 +185,7 @@ public void setNamespaceStrategy(NamespaceStrategy namespaceStrategy) { } @Override - public int upsert(T instance, Class domainType, Identifier identifier) { + public int upsert(T instance, Class domainType) { throw new UnsupportedOperationException("Upsert is not supported by MyBatisDataAccessStrategy"); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConflictColumnCollector.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConflictColumnCollector.java index a6f7de610c..306b1b4e8b 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConflictColumnCollector.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConflictColumnCollector.java @@ -22,27 +22,25 @@ import org.springframework.data.relational.core.sql.Comparison; import org.springframework.data.relational.core.sql.Condition; import org.springframework.data.relational.core.sql.MultipleCondition; -import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Visitable; import org.springframework.data.relational.core.sql.Visitor; /** - * Collects conflict columns from a {@link Condition} by traversing equality comparisons. - * For {@link Comparison} with {@code =} and a {@link Column} on the left, the column name is collected. - * For {@link MultipleCondition} (e.g. AND), recurses into child conditions. + * Collects conflict columns from a {@link Condition} by traversing equality comparisons. For {@link Comparison} with + * {@code =} and a {@link Column} on the left, the column name is collected. For {@link MultipleCondition} (e.g. AND), + * recurses into child conditions. * * @since 4.x */ final class ConflictColumnCollector implements Visitor { - private final List conflictColumns = new ArrayList<>(); + private final List conflictColumns = new ArrayList<>(); @Override public void enter(Visitable segment) { - if (segment instanceof Comparison comparison && "=".equals(comparison.getComparator()) - && comparison.getLeft() instanceof Column column) { - conflictColumns.add(column.getName()); + if (segment instanceof Comparison comparison && comparison.getLeft() instanceof Column column) { + conflictColumns.add(column); } if (segment instanceof MultipleCondition multiple) { @@ -52,7 +50,7 @@ public void enter(Visitable segment) { } } - List getConflictColumns() { + List getConflictColumns() { return conflictColumns; } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/MySqlUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/MySqlUpsertRenderContext.java index ab2fa1b47a..3b2fd1de72 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/MySqlUpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/MySqlUpsertRenderContext.java @@ -15,14 +15,6 @@ */ package org.springframework.data.relational.core.sql.render; -import java.util.List; -import java.util.function.Function; -import java.util.stream.Collectors; - -import org.springframework.data.relational.core.sql.SqlIdentifier; -import org.springframework.data.relational.core.sql.Table; -import org.springframework.util.Assert; - /** * MySQL / MariaDB upsert using {@code INSERT ... ON DUPLICATE KEY UPDATE}. * @@ -34,34 +26,7 @@ public enum MySqlUpsertRenderContext implements UpsertRenderContext { INSTANCE; @Override - public String renderUpsert(Table table, Columns columns, Function bindMarkerFn) { - - Assert.notEmpty(columns.insertColumns(), "Insert columns must not be empty"); - Assert.notEmpty(columns.filterColumns(), "Filter columns must not be empty"); - - String tableName = columns.tableName(table); - String columnNames = String.join(", ", columns.insertColumnNames()); - String bindMarkers = String.join(", ", columns.insertColumnBindMarkers(bindMarkerFn)); - String setValues = setValuesSnippet(columns); - - return "INSERT INTO %s (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s".formatted( // - tableName, // - columnNames, // - bindMarkers, // - setValues); - } - - private static String setValuesSnippet(Columns columns) { - - List updateColumns = columns.updateColumns(); - - if (updateColumns.isEmpty()) { - updateColumns = columns.filterColumns(); - } - - return updateColumns.stream().map(col -> { - String colName = col.toSql(columns.identifierProcessing()); - return "%s = VALUES(%s)".formatted(colName, colName); - }).collect(Collectors.joining(", ")); + public UpsertStatementRenderer renderer() { + return UpsertStatementRenderer.mySql(); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OracleUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OracleUpsertRenderContext.java index 3e218e0412..79d3d18070 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OracleUpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OracleUpsertRenderContext.java @@ -15,13 +15,6 @@ */ package org.springframework.data.relational.core.sql.render; -import java.util.List; -import java.util.function.Function; - -import org.springframework.data.relational.core.sql.SqlIdentifier; -import org.springframework.data.relational.core.sql.Table; -import org.springframework.util.Assert; - /** * Oracle MERGE upsert. Uses {@code SELECT ... FROM DUAL} for source values. * @@ -32,55 +25,7 @@ public enum OracleUpsertRenderContext implements UpsertRenderContext { INSTANCE; @Override - public String renderUpsert(Table table, Columns columns, Function bindMarkerFn) { - - Assert.notEmpty(columns.insertColumns(), "Insert columns must not be empty"); - Assert.notEmpty(columns.filterColumns(), "Filter columns must not be empty"); - - String targetTableAlias = columns.identifierProcessing().quote(StandardSqlUpsertRenderContext.targetTableAlias); - String sourceTableAlias = columns.identifierProcessing().quote(StandardSqlUpsertRenderContext.sourceTableAlias); - - String tableName = columns.tableName(table); - String insertColumnNames = String.join(", ", columns.insertColumnNames()); - String sourceSelectList = String.join(", ", - columns.insertColumns().stream().map(col -> bindMarkerFn.apply(col) + " AS " + columns.column(col)).toList()); - - String onCondition = String.join(" AND ", columns.filterColumns().stream().map(col -> { - String colName = columns.column(col); - return "%s.%s = %s.%s".formatted(targetTableAlias, colName, sourceTableAlias, colName); - }).toList()); - - String insertValuesSql = String.join(", ", - columns.insertColumns().stream().map(col -> columns.column(sourceTableAlias, col)).toList()); - - String insertClause = "WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s)".formatted(insertColumnNames, - insertValuesSql); - - List updateColumns = columns.updateColumns(); - if (updateColumns.isEmpty()) { - // ORA-38104: columns referenced in ON cannot be updated; omit WHEN MATCHED so existing rows are left - // unchanged (same as a no-op update of key-only columns). - return "MERGE INTO %s %s USING (SELECT %s FROM DUAL) %s ON (%s) %s".formatted( // - tableName, // - targetTableAlias, // - sourceSelectList, // - sourceTableAlias, // - onCondition, // - insertClause); - } - - String updateSetClause = String.join(", ", updateColumns.stream().map(col -> { - String colName = columns.column(col); - return "%s.%s = %s.%s".formatted(targetTableAlias, colName, sourceTableAlias, colName); - }).toList()); - - return "MERGE INTO %s %s USING (SELECT %s FROM DUAL) %s ON (%s) WHEN MATCHED THEN UPDATE SET %s %s".formatted( // - tableName, // - targetTableAlias, // - sourceSelectList, // - sourceTableAlias, // - onCondition, // - updateSetClause, // - insertClause); + public UpsertStatementRenderer renderer() { + return UpsertStatementRenderer.oracle(); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostgresUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostgresUpsertRenderContext.java index 33cd00644c..9349a975b5 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostgresUpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostgresUpsertRenderContext.java @@ -15,14 +15,6 @@ */ package org.springframework.data.relational.core.sql.render; -import java.util.List; -import java.util.function.Function; -import java.util.stream.Collectors; - -import org.springframework.data.relational.core.sql.SqlIdentifier; -import org.springframework.data.relational.core.sql.Table; -import org.springframework.util.Assert; - /** * PostgreSQL upsert using {@code INSERT ... ON CONFLICT ... DO UPDATE SET}. * @@ -33,44 +25,7 @@ public enum PostgresUpsertRenderContext implements UpsertRenderContext { INSTANCE; @Override - public String renderUpsert(Table table, Columns columns, Function bindMarkerFn) { - - Assert.notEmpty(columns.insertColumns(), "Insert columns must not be empty"); - Assert.notEmpty(columns.filterColumns(), "Filter columns must not be empty"); - - String tableName = columns.tableName(table); - String insertColumnNames = String.join(", ", columns.insertColumnNames()); - String bindMarkers = String.join(", ", columns.insertColumnBindMarkers(bindMarkerFn)); - String filterColumnNames = String.join(", ", columns.filterColumnNames()); - - if(columns.updateColumns().isEmpty()) { - return "INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO NOTHING".formatted(// - tableName, // - insertColumnNames, // - bindMarkers, // - filterColumnNames); - } - - String setValues = setValuesSnippet(columns); - return "INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s".formatted(// - tableName, // - insertColumnNames, // - bindMarkers, // - filterColumnNames, // - setValues); - } - - private static String setValuesSnippet(Columns columns) { - - List updateColumns = columns.updateColumns(); - - if (updateColumns.isEmpty()) { - updateColumns = columns.filterColumns(); - } - - return updateColumns.stream().map(col -> { - String colName = col.toSql(columns.identifierProcessing()); - return "%s = EXCLUDED.%s".formatted(colName, colName); - }).collect(Collectors.joining(", ")); + public UpsertStatementRenderer renderer() { + return UpsertStatementRenderer.postgres(); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlRenderer.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlRenderer.java index 097dd61bb8..d143bbd570 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlRenderer.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlRenderer.java @@ -27,6 +27,7 @@ * * @author Mark Paluch * @author Jens Schauder + * @author Christoph Strobl * @since 1.1 * @see RenderContext */ diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlServerUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlServerUpsertRenderContext.java index 906b673d50..a035d47b57 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlServerUpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlServerUpsertRenderContext.java @@ -15,13 +15,8 @@ */ package org.springframework.data.relational.core.sql.render; -import java.util.function.Function; - -import org.springframework.data.relational.core.sql.SqlIdentifier; -import org.springframework.data.relational.core.sql.Table; - /** - * SQL Server MERGE upsert. Delegates to {@link StandardSqlUpsertRenderContext} and appends a required semicolon. + * SQL Server MERGE upsert. Delegates to {@link UpsertStatementRenderers.StandardSql} and appends a required semicolon. * * @since 4.x */ @@ -29,11 +24,8 @@ public enum SqlServerUpsertRenderContext implements UpsertRenderContext { INSTANCE; - private static final String STATEMENT_TERMINATOR = ";"; - @Override - public String renderUpsert(Table table, Columns merge, Function bindMarkerFn) { - return StandardSqlUpsertRenderContext.INSTANCE.renderUpsert(table, merge, - bindMarkerFn) + STATEMENT_TERMINATOR; + public UpsertStatementRenderer renderer() { + return UpsertStatementRenderer.sqlServer(); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java index c54d8e007b..09d4567d82 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java @@ -15,13 +15,6 @@ */ package org.springframework.data.relational.core.sql.render; -import java.util.List; -import java.util.function.Function; - -import org.springframework.data.relational.core.sql.SqlIdentifier; -import org.springframework.data.relational.core.sql.Table; -import org.springframework.util.Assert; - /** * Standard SQL {@code MERGE} upsert for dialects that support it (like H2, HSQLDB, SQL Server, DB2). *

@@ -33,60 +26,8 @@ public enum StandardSqlUpsertRenderContext implements UpsertRenderContext { INSTANCE; - static final String targetTableAlias = "_t"; - static final String sourceTableAlias = "_s"; - @Override - public String renderUpsert(Table table, Columns columns, Function bindMarkerFn) { - - Assert.notEmpty(columns.insertColumns(), "Insert columns must not be empty"); - Assert.notEmpty(columns.filterColumns(), "Filter columns must not be empty"); - - String targetTableAlias = columns.identifierProcessing().quote(StandardSqlUpsertRenderContext.targetTableAlias); - String sourceTableAlias = columns.identifierProcessing().quote(StandardSqlUpsertRenderContext.sourceTableAlias); - - String tableName = columns.tableName(table); - String insertColumnNames = String.join(", ", columns.insertColumnNames()); - String bindMarkers = String.join(", ", columns.insertColumnBindMarkers(bindMarkerFn)); - - String onCondition = String.join(" AND ", columns.filterColumns().stream().map(col -> { - String colName = columns.column(col); - return "%s.%s = %s.%s".formatted(targetTableAlias, colName, sourceTableAlias, colName); - }).toList()); - - String insertValuesSql = String.join(", ", - columns.insertColumns().stream().map(col -> columns.column(sourceTableAlias, col)).toList()); - - String insertClause = "WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s)".formatted(insertColumnNames, - insertValuesSql); - - List updateColumns = columns.updateColumns(); - if (updateColumns.isEmpty()) { - // Matched rows are left unchanged. Updating only key columns is invalid on SQL Server (identity) and Oracle - // (ORA-38104). - return "MERGE INTO %s %s USING (VALUES (%s)) AS %s (%s) ON %s %s".formatted( // - tableName, // - targetTableAlias, // - bindMarkers, // - sourceTableAlias, // - insertColumnNames, // - onCondition, // - insertClause); - } - - String updateSetClause = String.join(", ", updateColumns.stream().map(col -> { - String colName = columns.column(col); - return "%s.%s = %s.%s".formatted(targetTableAlias, colName, sourceTableAlias, colName); - }).toList()); - - return "MERGE INTO %s %s USING (VALUES (%s)) AS %s (%s) ON %s WHEN MATCHED THEN UPDATE SET %s %s".formatted( // - tableName, // - targetTableAlias, // - bindMarkers, // - sourceTableAlias, // - insertColumnNames, // - onCondition, // - updateSetClause, // - insertClause); + public UpsertStatementRenderer renderer() { + return new UpsertStatementRenderers.StandardSql(); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertRenderContext.java index 172b783f62..5613861aa6 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertRenderContext.java @@ -15,70 +15,13 @@ */ package org.springframework.data.relational.core.sql.render; -import java.util.List; -import java.util.function.Function; - -import org.springframework.data.relational.core.sql.IdentifierProcessing; -import org.springframework.data.relational.core.sql.SqlIdentifier; -import org.springframework.data.relational.core.sql.Table; - /** - * Encapsulates dialect-specific rendering of a single-statement upsert (insert or update by id). Implementations - * produce vendor-specific SQL such as {@code INSERT ... ON CONFLICT ... DO UPDATE}, - * {@code INSERT ... ON DUPLICATE KEY UPDATE}, or standard {@code MERGE}. + * {@link UpsertStatementRenderers}. * * @since 4.x */ public interface UpsertRenderContext { - /** - * Render a full upsert statement. - * - * @param table the target table. - * @param columns the merge operation. - * @param bindMarkerFn function from column name to bind marker placeholder (e.g. {@code "id" -> ":id"}). - * @return the full upsert SQL statement. - */ - String renderUpsert(Table table, Columns columns, Function bindMarkerFn); - - /** - * @param insertColumns column names for INSERT (order preserved for VALUES clause). - * @param filterColumns columns that define the query for existing records (e.g. primary key). - * @param identifierProcessing identifier processing for rendering table and column names to SQL. - */ - record Columns(List insertColumns, List filterColumns, - IdentifierProcessing identifierProcessing) { - - String tableName(Table table) { - return table.getName().toSql(identifierProcessing); - } - - List insertColumnNames() { - return insertColumns.stream().map(this::column).toList(); - } - - List filterColumnNames(String tableAlias) { - return filterColumns.stream().map(col -> tableAlias + "." + column(col)).toList(); - } - - List filterColumnNames() { - return filterColumns.stream().map(this::column).toList(); - } - - List insertColumnBindMarkers(Function bindMarkerFn) { - return insertColumns.stream().map(bindMarkerFn).toList(); - } - - List updateColumns() { - return insertColumns.stream().filter(col -> !filterColumns.contains(col)).toList(); - } - - String column(String tableAlias, SqlIdentifier column) { - return tableAlias + "." + column(column); - } + UpsertStatementRenderer renderer(); - String column(SqlIdentifier column) { - return column.toSql(identifierProcessing); - } - } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderer.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderer.java new file mode 100644 index 0000000000..f4e104423c --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderer.java @@ -0,0 +1,227 @@ +/* + * Copyright 2026-present 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 java.util.List; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Collector; + +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.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.render.UpsertStatementRenderers.MySql; +import org.springframework.data.relational.core.sql.render.UpsertStatementRenderers.Oracle; +import org.springframework.data.relational.core.sql.render.UpsertStatementRenderers.Postgres; +import org.springframework.data.relational.core.sql.render.UpsertStatementRenderers.SqlServer; +import org.springframework.data.relational.core.sql.render.UpsertStatementRenderers.StandardSql; + +/** + * Dialect-specific upsert SQL as a single statement string (e.g. {@code MERGE}, {@code INSERT ... ON CONFLICT}, + * {@code INSERT ... ON DUPLICATE KEY UPDATE}). Callers resolve {@link Column}s and {@link Table}; implementations only + * assemble syntax and use {@link UpsertRenderingContext} so names and bind markers match the enclosing + * {@link RenderContext}. Concrete renderers are defined in {@link UpsertStatementRenderers}. + * + * @author Christoph Strobl + * @since 4.x + */ +public interface UpsertStatementRenderer { + + static UpsertStatementRenderer standardSql() { + return StandardSql.INSTANCE; + } + + static UpsertStatementRenderer mySql() { + return MySql.INSTANCE; + } + + static UpsertStatementRenderer oracle() { + return Oracle.INSTANCE; + } + + static UpsertStatementRenderer postgres() { + return Postgres.INSTANCE; + } + + static UpsertStatementRenderer sqlServer() { + return SqlServer.INSTANCE; + } + + /** + * Render the full upsert statement for {@code table}. + * + * @param table target table + * @param columns {@link Columns#insertColumns()} values to insert; {@link Columns#conflictColumns()} keys that + * identify an existing row for the dialect's conflict/merge semantics + * @param ctx rendering hooks (quoting, bind markers) tied to the current {@link RenderContext} + * @return executable upsert SQL text (parameter placeholders as produced by {@code ctx}) + */ + String render(Table table, Columns columns, UpsertRenderingContext ctx); + + /** + * Building blocks for {@link UpsertStatementRenderer}. + */ + interface UpsertRenderingContext { + + /** + * Backs upsert rendering with {@code renderContext} (quoting, bind marker style). + * + * @param renderContext active SQL render context + * @return context passed to {@link UpsertStatementRenderer#render} + */ + static UpsertRenderingContext of(RenderContext renderContext) { + return () -> renderContext; + } + + /** @return render context */ + RenderContext renderContext(); + + /** @return rendered table reference */ + default CharSequence tableName(Table table) { + return NameRenderer.render(renderContext(), table); + } + + /** @return rendered column reference without a table qualifier */ + default CharSequence columnName(Column column) { + return columnName(SqlIdentifier.EMPTY, column); + } + + /** @return {@code column} rendered with {@link Aliased#getAlias()} as qualifier */ + default CharSequence columnName(Aliased table, Column column) { + return columnName(table.getAlias(), column); + } + + /** + * @param tableAlias table or empty; if empty, column only, else {@code alias.column} + * @return rendered column reference + */ + default CharSequence columnName(SqlIdentifier tableAlias, Column column) { + if (tableAlias.equals(SqlIdentifier.EMPTY)) { + return NameRenderer.render(renderContext(), column); + } + return "%s.%s".formatted(NameRenderer.render(renderContext(), tableAlias), + NameRenderer.render(renderContext(), column)); + } + + /** @return each column name rendered (unqualified) and collected (e.g. comma-separated) */ + default CharSequence columnNames(List columns, + Collector collector) { + return columnNames(SqlIdentifier.EMPTY, columns, collector); + } + + /** @return like {@link #columnNames(List, Collector)} but with {@code tableAlias} on each column */ + default CharSequence columnNames(SqlIdentifier tableAlias, List columns, + Collector collector) { + return columns.stream().map(column -> columnName(tableAlias, column)).collect(collector); + } + + /** @return {@code :reference} bind marker from {@link Column#getName()} */ + default CharSequence bindMarker(Column column) { + return bindMarker(column, (columnName, bindMarker) -> bindMarker); + } + + /** + * @param bindMarkerFn receives rendered column name and default {@code :reference} marker; returns fragment to + * embed + * @return result of {@code bindMarkerFn} + */ + default CharSequence bindMarker(Column column, BiFunction bindMarkerFn) { + return bindMarkerFn.apply(columnName(column), ":%s".formatted(column.getName().getReference())); + } + + /** @return bind marker per column, collected */ + default CharSequence bindMarkers(List columns, + Collector collector) { + return columns.stream().map(column -> bindMarker(column, (columnName, bindMarker) -> bindMarker)) + .collect(collector); + } + + /** @return bind markers using {@code bindMarkerFn} per column, collected */ + default CharSequence bindMarkers(List columns, + BiFunction bindMarkerFn, + Collector collector) { + return columns.stream().map(column -> bindMarker(column, bindMarkerFn)).collect(collector); + } + + /** @return {@code targetColumn = sourceColumn} for the given aliases */ + default CharSequence assignment(SqlIdentifier targetTableAlias, Column column, SqlIdentifier sourceTableAlias) { + return assignment(targetTableAlias, column, sourceTableAlias, Function.identity()); + } + + /** + * @param sourceValueFn transforms the rendered source column reference (e.g. wrap in a function call) + * @return {@code targetColumn =} {@code sourceValueFn(sourceColumn)} + */ + default CharSequence assignment(SqlIdentifier targetTableAlias, Column column, SqlIdentifier sourceTableAlias, + Function sourceValueFn) { + + CharSequence targetColumn = columnName(targetTableAlias, column); + CharSequence sourceColumn = columnName(sourceTableAlias, column); + return "%s = %s".formatted(targetColumn, sourceValueFn.apply(sourceColumn)); + } + + /** @return one assignment per column, collected */ + default CharSequence assignments(SqlIdentifier targetTableAlias, List columns, + SqlIdentifier sourceTableAlias, Collector collector) { + return assignments(targetTableAlias, columns, sourceTableAlias, Function.identity(), collector); + } + + /** @return assignments with {@code sourceValueFn} applied to each source side, collected */ + default CharSequence assignments(SqlIdentifier targetTableAlias, List columns, + SqlIdentifier sourceTableAlias, Function sourceValueFn, + Collector collector) { + return columns.stream().map(column -> assignment(targetTableAlias, column, sourceTableAlias, sourceValueFn)) + .collect(collector); + } + } + + final class Columns { + + private final List insertColumns; + private final List conflictColumns; + private final List updateColumns; + + public Columns(List insertColumns, List conflictColumns) { + + this.insertColumns = insertColumns; + this.conflictColumns = conflictColumns; + this.updateColumns = insertColumns.stream() + .filter(col -> conflictColumns.stream().noneMatch(it -> it.getName().equals(col.getName()))).toList(); + } + + /** + * Columns to assign on update. + */ + List updateColumns() { + return updateColumns; + } + + /** + * Columns insert. + */ + public List insertColumns() { + return insertColumns; + } + + /** + * Columns defining the conflict condition. + */ + public List conflictColumns() { + return conflictColumns; + } + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderers.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderers.java new file mode 100644 index 0000000000..bd92401e92 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderers.java @@ -0,0 +1,230 @@ +/* + * Copyright 2026-present 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 java.util.List; +import java.util.stream.Collectors; + +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.util.Assert; + +/** + * Concrete {@link UpsertStatementRenderer} implementations. + * + * @author Christoph Strobl + * @since 4.x + */ +final class UpsertStatementRenderers { + + /** Target table alias in {@code MERGE} statements. */ + static final SqlIdentifier MERGE_TARGET_TABLE_ALIAS = SqlIdentifier.quoted("_t"); + + /** Source (values) alias in {@code MERGE} statements. */ + static final SqlIdentifier MERGE_SOURCE_TABLE_ALIAS = SqlIdentifier.quoted("_s"); + + private UpsertStatementRenderers() {} + + /** + * Standard SQL {@code MERGE} using a table value constructor {@code (VALUES (?, ?)) AS s (col1, col2)} (H2, HSQLDB, + * DB2, etc.). + */ + static class StandardSql implements UpsertStatementRenderer { + + static final StandardSql INSTANCE = new StandardSql(); + + @Override + public String render(Table table, Columns columns, UpsertRenderingContext ctx) { + + Assert.notEmpty(columns.insertColumns(), "Insert columns must not be empty"); + Assert.notEmpty(columns.conflictColumns(), "Conflict columns must not be empty"); + + CharSequence tableName = ctx.tableName(table); + CharSequence insertColumnNames = ctx.columnNames(columns.insertColumns(), Collectors.joining(", ")); + CharSequence bindMarkers = ctx.bindMarkers(columns.insertColumns(), Collectors.joining(", ")); + CharSequence onCondition = ctx.assignments(MERGE_TARGET_TABLE_ALIAS, columns.conflictColumns(), + MERGE_SOURCE_TABLE_ALIAS, Collectors.joining(" AND ")); + CharSequence insertValuesSql = ctx.columnNames(MERGE_SOURCE_TABLE_ALIAS, columns.insertColumns(), + Collectors.joining(", ")); + + String insertClause = "WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s)".formatted(insertColumnNames, + insertValuesSql); + + List updateColumns = columns.updateColumns(); + if (updateColumns.isEmpty()) { + return "MERGE INTO %s %s USING (VALUES (%s)) AS %s (%s) ON %s %s".formatted( // + tableName, // + MERGE_TARGET_TABLE_ALIAS, // + bindMarkers, // + MERGE_SOURCE_TABLE_ALIAS, // + insertColumnNames, // + onCondition, // + insertClause); + } + + CharSequence updateSetClause = ctx.assignments(MERGE_TARGET_TABLE_ALIAS, columns.updateColumns(), + MERGE_SOURCE_TABLE_ALIAS, Collectors.joining(", ")); + + return "MERGE INTO %s %s USING (VALUES (%s)) AS %s (%s) ON %s WHEN MATCHED THEN UPDATE SET %s %s".formatted( // + tableName, // + MERGE_TARGET_TABLE_ALIAS, // + bindMarkers, // + MERGE_SOURCE_TABLE_ALIAS, // + insertColumnNames, // + onCondition, // + updateSetClause, // + insertClause); + } + } + + /** PostgreSQL {@code INSERT ... ON CONFLICT ... DO UPDATE SET} / {@code DO NOTHING}. */ + static class Postgres implements UpsertStatementRenderer { + + static final Postgres INSTANCE = new Postgres(); + + @Override + public String render(Table table, Columns columns, UpsertRenderingContext ctx) { + + Assert.notEmpty(columns.insertColumns(), "Insert columns must not be empty"); + Assert.notEmpty(columns.conflictColumns(), "Conflict columns must not be empty"); + + CharSequence tableName = ctx.tableName(table); + CharSequence insertColumnNames = ctx.columnNames(columns.insertColumns(), Collectors.joining(", ")); + CharSequence conflictColumnNames = ctx.columnNames(columns.conflictColumns(), Collectors.joining(", ")); + CharSequence bindMarkers = ctx.bindMarkers(columns.insertColumns(), Collectors.joining(", ")); + + List updateColumns = columns.updateColumns(); + + if (updateColumns.isEmpty()) { + updateColumns = columns.conflictColumns(); + } + + CharSequence setValues = ctx.assignments(SqlIdentifier.EMPTY, updateColumns, SqlIdentifier.EMPTY, + "EXCLUDED.%s"::formatted, Collectors.joining(", ")); + + if (columns.updateColumns().isEmpty()) { + return "INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO NOTHING".formatted(// + tableName, // + insertColumnNames, // + bindMarkers, // + conflictColumnNames); + } + + return "INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s".formatted(// + tableName, // + insertColumnNames, // + bindMarkers, // + conflictColumnNames, // + setValues); + + } + } + + /** MySQL / MariaDB {@code INSERT ... ON DUPLICATE KEY UPDATE}. */ + static class MySql implements UpsertStatementRenderer { + + static final MySql INSTANCE = new MySql(); + + @Override + public String render(Table table, Columns columns, UpsertRenderingContext ctx) { + + Assert.notEmpty(columns.insertColumns(), "Insert columns must not be empty"); + Assert.notEmpty(columns.conflictColumns(), "Conflict columns must not be empty"); + + CharSequence tableName = ctx.tableName(table); + CharSequence columnNames = ctx.columnNames(columns.insertColumns(), Collectors.joining(", ")); + CharSequence bindMarkers = ctx.bindMarkers(columns.insertColumns(), Collectors.joining(", ")); + + List updateColumns = columns.updateColumns(); + + if (updateColumns.isEmpty()) { + updateColumns = columns.conflictColumns(); + } + + CharSequence setValues = ctx.assignments(SqlIdentifier.EMPTY, updateColumns, SqlIdentifier.EMPTY, + "VALUES(%s)"::formatted, Collectors.joining(", ")); + + return "INSERT INTO %s (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s".formatted( // + tableName, // + columnNames, // + bindMarkers, // + setValues); + } + } + + /** Oracle {@code MERGE} with {@code SELECT ... FROM DUAL} as source. */ + static class Oracle implements UpsertStatementRenderer { + + static final Oracle INSTANCE = new Oracle(); + + @Override + public String render(Table table, Columns columns, UpsertRenderingContext ctx) { + + Assert.notEmpty(columns.insertColumns(), "Insert columns must not be empty"); + Assert.notEmpty(columns.conflictColumns(), "Conflict columns must not be empty"); + + CharSequence tableName = ctx.tableName(table); + CharSequence insertColumnNames = ctx.columnNames(columns.insertColumns(), Collectors.joining(", ")); + CharSequence sourceSelectList = ctx.bindMarkers(columns.insertColumns(), + (columnName, bindMarker) -> "%s AS %s".formatted(bindMarker, columnName), Collectors.joining(", ")); + CharSequence onCondition = ctx.assignments(MERGE_TARGET_TABLE_ALIAS, columns.conflictColumns(), + MERGE_SOURCE_TABLE_ALIAS, Collectors.joining(" AND ")); + CharSequence insertValuesSql = ctx.columnNames(MERGE_SOURCE_TABLE_ALIAS, columns.insertColumns(), + Collectors.joining(", ")); + + String insertClause = "WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s)".formatted(insertColumnNames, + insertValuesSql); + + List updateColumns = columns.updateColumns(); + if (updateColumns.isEmpty()) { + return "MERGE INTO %s %s USING (SELECT %s FROM DUAL) %s ON (%s) %s".formatted( // + tableName, // + MERGE_TARGET_TABLE_ALIAS, // + sourceSelectList, // + MERGE_SOURCE_TABLE_ALIAS, // + onCondition, // + insertClause); + } + + CharSequence updateSetClause = ctx.assignments(MERGE_TARGET_TABLE_ALIAS, columns.updateColumns(), + MERGE_SOURCE_TABLE_ALIAS, Collectors.joining(", ")); + + return "MERGE INTO %s %s USING (SELECT %s FROM DUAL) %s ON (%s) WHEN MATCHED THEN UPDATE SET %s %s".formatted( // + tableName, // + MERGE_TARGET_TABLE_ALIAS, // + sourceSelectList, // + MERGE_SOURCE_TABLE_ALIAS, // + onCondition, // + updateSetClause, // + insertClause); + } + } + + /** + * SQL Server {@code MERGE}: same body as {@link StandardSql} with a trailing semicolon (batch separator). + */ + static class SqlServer extends StandardSql { + + private static final String STATEMENT_TERMINATOR = ";"; + static final SqlServer INSTANCE = new SqlServer(); + + @Override + public String render(Table table, Columns columns, UpsertRenderingContext ctx) { + return super.render(table, columns, ctx) + STATEMENT_TERMINATOR; + } + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java index 29ce551aff..4538f4571c 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java @@ -17,30 +17,30 @@ import java.util.ArrayList; import java.util.List; -import java.util.function.Function; import org.jspecify.annotations.Nullable; import org.springframework.data.relational.core.sql.AssignValue; +import org.springframework.data.relational.core.sql.Column; import org.springframework.data.relational.core.sql.Condition; -import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; import org.springframework.data.relational.core.sql.Visitable; -import org.springframework.data.relational.core.sql.render.UpsertRenderContext.Columns; +import org.springframework.data.relational.core.sql.render.UpsertStatementRenderer.UpsertRenderingContext; import org.springframework.util.Assert; /** - * {@link PartRenderer} for {@link org.springframework.data.relational.core.sql.Upsert} statements. - * Traverses the Upsert AST (table, where/conflict condition, assignments), collects insert and conflict columns, - * and delegates dialect-specific rendering to {@link UpsertRenderContext}. + * {@link PartRenderer} for {@link org.springframework.data.relational.core.sql.Upsert} statements. Traverses the Upsert + * AST (table, where/conflict condition, assignments), collects insert and conflict columns, and delegates + * dialect-specific rendering via {@link UpsertRenderContext#renderer()}. * + * @author Christoph Strobl * @since 4.x */ public class UpsertStatementVisitor extends DelegatingVisitor implements PartRenderer { private final StringBuilder builder = new StringBuilder(); private final RenderContext context; - private final List insertColumns = new ArrayList<>(); - private final List conflictColumns = new ArrayList<>(); + private final List insertColumns = new ArrayList<>(); + private final List conflictColumns = new ArrayList<>(5); private @Nullable Table table; @@ -66,7 +66,7 @@ public class UpsertStatementVisitor extends DelegatingVisitor implements PartRen } if (segment instanceof AssignValue assignValue) { - this.insertColumns.add(assignValue.getColumn().getName()); + this.insertColumns.add(assignValue.getColumn()); return Delegation.retain(); } @@ -78,21 +78,12 @@ public Delegation doLeave(Visitable segment) { if (segment instanceof org.springframework.data.relational.core.sql.Upsert) { + Assert.state(table != null, "Upsert requires a table"); UpsertRenderContext upsertContext = context.getUpsertRenderContext(); - if (upsertContext == null) { - throw new UnsupportedOperationException( - "Upsert is not supported by the current render context; no UpsertRenderContext available."); - } - if (table == null) { - throw new IllegalStateException("Upsert statement has no table."); - } - Function bindMarkerFn = cn -> ":" - + sanitizeBindMarkerName(cn.getReference()); - - - String sql = upsertContext.renderUpsert(table, new Columns(new ArrayList<>(insertColumns), - new ArrayList<>(conflictColumns), context.getIdentifierProcessing()), bindMarkerFn); + UpsertRenderingContext renderingContext = UpsertRenderingContext.of(context); + String sql = upsertContext.renderer().render(table, + new UpsertStatementRenderer.Columns(insertColumns, conflictColumns), renderingContext); builder.append(sql); return Delegation.leave(); @@ -105,16 +96,4 @@ public Delegation doLeave(Visitable segment) { public CharSequence getRenderedPart() { return builder; } - - private static String sanitizeBindMarkerName(String rawName) { - - StringBuilder sb = new StringBuilder(rawName.length()); - 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 == '_') { - sb.append(c); - } - } - return sb.length() > 0 ? sb.toString() : rawName; - } } diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java index f608c88377..08ff167d86 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java @@ -41,6 +41,7 @@ * * @author Jens Schauder * @author Mark Paluch + * @author Christoph Strobl */ public class DependencyTests { diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java index a23884f1ee..2254050ef3 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java @@ -18,13 +18,14 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.List; -import java.util.function.Function; import org.junit.jupiter.api.Test; -import org.springframework.data.relational.core.sql.IdentifierProcessing; +import org.springframework.data.relational.core.dialect.AnsiDialect; +import org.springframework.data.relational.core.dialect.RenderContextFactory; +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.data.relational.core.sql.render.UpsertRenderContext.Columns; +import org.springframework.data.relational.core.sql.render.UpsertStatementRenderer.UpsertRenderingContext; /** * Unit tests for {@link StandardSqlUpsertRenderContext}. @@ -32,11 +33,6 @@ class StandardSqlUpsertRenderContextUnitTests { private static final Table TABLE = Table.create(SqlIdentifier.unquoted("my_table")); - private static final List INSERT_COLUMNS = List.of(SqlIdentifier.unquoted("id"), - SqlIdentifier.unquoted("name")); - private static final List CONFLICT_COLUMNS = List.of(SqlIdentifier.unquoted("id")); - private static final Function BIND_MARKER = id -> ":" + id.getReference(); - private static final IdentifierProcessing IDENTIFIER_PROCESSING = IdentifierProcessing.ANSI; @Test // GH-493 void mergeUpsertWithMultipleConflictColumnsBuildsFilterClauseWithAllColumns() { @@ -44,9 +40,13 @@ void mergeUpsertWithMultipleConflictColumnsBuildsFilterClauseWithAllColumns() { List insertColumns = List.of(SqlIdentifier.unquoted("tenant_id"), SqlIdentifier.unquoted("id"), SqlIdentifier.unquoted("name")); List conflictColumns = List.of(SqlIdentifier.unquoted("tenant_id"), SqlIdentifier.unquoted("id")); - Columns columns = new Columns(insertColumns, conflictColumns, IDENTIFIER_PROCESSING); - String sql = StandardSqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, columns, BIND_MARKER); + UpsertRenderingContext ctx = UpsertRenderingContext.of(new RenderContextFactory(AnsiDialect.INSTANCE).createRenderContext()); + List insertCols = insertColumns.stream().map(id -> Column.create(id, TABLE)).toList(); + List conflictCols = conflictColumns.stream().map(id -> Column.create(id, TABLE)).toList(); + + String sql = StandardSqlUpsertRenderContext.INSTANCE.renderer().render(TABLE, + new UpsertStatementRenderer.Columns(insertCols, conflictCols), ctx); assertThat(sql).contains("ON \"_t\".tenant_id = \"_s\".tenant_id AND \"_t\".id = \"_s\".id"); assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name"); diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java index b8012539bb..3a06275233 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java @@ -18,16 +18,22 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.List; -import java.util.function.Function; import org.junit.jupiter.api.Test; -import org.springframework.data.relational.core.sql.IdentifierProcessing; +import org.springframework.data.relational.core.dialect.AnsiDialect; +import org.springframework.data.relational.core.dialect.Dialect; +import org.springframework.data.relational.core.dialect.MySqlDialect; +import org.springframework.data.relational.core.dialect.OracleDialect; +import org.springframework.data.relational.core.dialect.PostgresDialect; +import org.springframework.data.relational.core.dialect.RenderContextFactory; +import org.springframework.data.relational.core.dialect.SqlServerDialect; +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.data.relational.core.sql.render.UpsertRenderContext.Columns; +import org.springframework.data.relational.core.sql.render.UpsertStatementRenderer.UpsertRenderingContext; /** - * Unit tests for {@link UpsertRenderContext} implementations. + * Unit tests for {@link UpsertRenderContext} implementations via {@link UpsertStatementRenderer#render}. */ class UpsertRenderContextUnitTests { @@ -35,14 +41,21 @@ class UpsertRenderContextUnitTests { private static final List INSERT_COLUMNS = List.of(SqlIdentifier.unquoted("id"), SqlIdentifier.unquoted("name")); private static final List CONFLICT_COLUMNS = List.of(SqlIdentifier.unquoted("id")); - private static final Function BIND_MARKER = id -> ":" + id.getReference(); - private static final IdentifierProcessing IDENTIFIER_PROCESSING = IdentifierProcessing.ANSI; + + private static String render(UpsertRenderContext upsertContext, Dialect dialect, Table table, + List insertColumns, List conflictColumns) { + + UpsertRenderingContext ctx = UpsertRenderingContext.of(new RenderContextFactory(dialect).createRenderContext()); + List insertCols = insertColumns.stream().map(id -> Column.create(id, table)).toList(); + List conflictCols = conflictColumns.stream().map(id -> Column.create(id, table)).toList(); + return upsertContext.renderer().render(table, new UpsertStatementRenderer.Columns(insertCols, conflictCols), ctx); + } @Test // GH-493 void standardUpsertRendersMergeInto() { - String sql = StandardSqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, - new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); + String sql = render(StandardSqlUpsertRenderContext.INSTANCE, AnsiDialect.INSTANCE, TABLE, INSERT_COLUMNS, + CONFLICT_COLUMNS); assertThat(sql).isEqualTo( "MERGE INTO my_table \"_t\" USING (VALUES (:id, :name)) AS \"_s\" (id, name) ON \"_t\".id = \"_s\".id WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name)"); @@ -54,9 +67,9 @@ void mergeUpsertWithMultipleConflictColumnsBuildsFilterClauseWithAllColumns() { List insertColumns = List.of(SqlIdentifier.unquoted("tenant_id"), SqlIdentifier.unquoted("id"), SqlIdentifier.unquoted("name")); List conflictColumns = List.of(SqlIdentifier.unquoted("tenant_id"), SqlIdentifier.unquoted("id")); - Columns columns = new Columns(insertColumns, conflictColumns, IDENTIFIER_PROCESSING); - String sql = StandardSqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, columns, BIND_MARKER); + String sql = render(StandardSqlUpsertRenderContext.INSTANCE, AnsiDialect.INSTANCE, TABLE, insertColumns, + conflictColumns); assertThat(sql).isEqualToIgnoringWhitespace( "MERGE INTO my_table \"_t\" USING (VALUES (:tenant_id, :id, :name)) AS \"_s\" (tenant_id, id, name) " @@ -68,8 +81,8 @@ void mergeUpsertWithMultipleConflictColumnsBuildsFilterClauseWithAllColumns() { @Test // GH-493 void postgresUpsertRendersInsertOnConflictDoUpdate() { - String sql = PostgresUpsertRenderContext.INSTANCE.renderUpsert(TABLE, - new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); + String sql = render(PostgresUpsertRenderContext.INSTANCE, PostgresDialect.INSTANCE, TABLE, INSERT_COLUMNS, + CONFLICT_COLUMNS); assertThat(sql).isEqualToIgnoringWhitespace( "INSERT INTO my_table (id, name) VALUES (:id, :name) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name"); @@ -78,29 +91,28 @@ void postgresUpsertRendersInsertOnConflictDoUpdate() { @Test // GH-493 void postgresUpsertRendersInsertOnConflictDoNothing() { - String sql = PostgresUpsertRenderContext.INSTANCE.renderUpsert(TABLE, - new Columns(INSERT_COLUMNS, INSERT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); + String sql = render(PostgresUpsertRenderContext.INSTANCE, PostgresDialect.INSTANCE, TABLE, INSERT_COLUMNS, + INSERT_COLUMNS); assertThat(sql).isEqualToIgnoringWhitespace( - "INSERT INTO my_table (id, name) VALUES (:id, :name) ON CONFLICT (id, name) DO NOTHING"); + "INSERT INTO my_table (id, name) VALUES (:id, :name) ON CONFLICT (id, name) DO NOTHING"); } @Test // GH-493 void mySqlUpsertRendersOnDuplicateKeyUpdate() { - String sql = MySqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, - new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); + String sql = render(MySqlUpsertRenderContext.INSTANCE, MySqlDialect.INSTANCE, TABLE, INSERT_COLUMNS, + CONFLICT_COLUMNS); assertThat(sql).isEqualToIgnoringWhitespace( "INSERT INTO my_table (id, name) VALUES (:id, :name) ON DUPLICATE KEY UPDATE name = VALUES(name)"); } @Test // GH-493 - // TODO: should we have all values in the update or just a single one in this case. void mySqlUpsertRendersCorrectlyWhenUpdateCoversEntireKey() { - String sql = MySqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, - new Columns(INSERT_COLUMNS, INSERT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); + String sql = render(MySqlUpsertRenderContext.INSTANCE, MySqlDialect.INSTANCE, TABLE, INSERT_COLUMNS, + INSERT_COLUMNS); assertThat(sql).isEqualToIgnoringWhitespace( "INSERT INTO my_table (id, name) VALUES (:id, :name) ON DUPLICATE KEY UPDATE id = VALUES(id), name = VALUES(name)"); @@ -109,8 +121,8 @@ void mySqlUpsertRendersCorrectlyWhenUpdateCoversEntireKey() { @Test // GH-493 void oracleMergeUpsertRendersOnConditionInParentheses() { - String sql = OracleUpsertRenderContext.INSTANCE.renderUpsert(TABLE, - new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); + String sql = render(OracleUpsertRenderContext.INSTANCE, OracleDialect.INSTANCE, TABLE, INSERT_COLUMNS, + CONFLICT_COLUMNS); assertThat(sql).isEqualToIgnoringWhitespace( "MERGE INTO my_table \"_t\" USING (SELECT :id AS id, :name AS name FROM DUAL) \"_s\" ON (\"_t\".id = \"_s\".id) WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name)"); @@ -120,8 +132,7 @@ void oracleMergeUpsertRendersOnConditionInParentheses() { void standardMergeIdOnlyOmitsWhenMatchedUpdate() { List idOnly = List.of(SqlIdentifier.unquoted("id")); - String sql = StandardSqlUpsertRenderContext.INSTANCE.renderUpsert(TABLE, new Columns(idOnly, idOnly, - IDENTIFIER_PROCESSING), BIND_MARKER); + String sql = render(StandardSqlUpsertRenderContext.INSTANCE, AnsiDialect.INSTANCE, TABLE, idOnly, idOnly); assertThat(sql).isEqualTo( "MERGE INTO my_table \"_t\" USING (VALUES (:id)) AS \"_s\" (id) ON \"_t\".id = \"_s\".id WHEN NOT MATCHED THEN INSERT (id) VALUES (\"_s\".id)"); @@ -131,8 +142,7 @@ void standardMergeIdOnlyOmitsWhenMatchedUpdate() { void oracleMergeIdOnlyOmitsWhenMatchedUpdate() { List idOnly = List.of(SqlIdentifier.unquoted("id")); - String sql = OracleUpsertRenderContext.INSTANCE.renderUpsert(TABLE, new Columns(idOnly, idOnly, - IDENTIFIER_PROCESSING), BIND_MARKER); + String sql = render(OracleUpsertRenderContext.INSTANCE, OracleDialect.INSTANCE, TABLE, idOnly, idOnly); assertThat(sql).isEqualToIgnoringWhitespace( "MERGE INTO my_table \"_t\" USING (SELECT :id AS id FROM DUAL) \"_s\" ON (\"_t\".id = \"_s\".id) WHEN NOT MATCHED THEN INSERT (id) VALUES (\"_s\".id)"); @@ -141,8 +151,8 @@ void oracleMergeIdOnlyOmitsWhenMatchedUpdate() { @Test // GH-493 void sqlServerUpsertRendersMergeWithSemicolon() { - String sql = SqlServerUpsertRenderContext.INSTANCE.renderUpsert(TABLE, - new Columns(INSERT_COLUMNS, CONFLICT_COLUMNS, IDENTIFIER_PROCESSING), BIND_MARKER); + String sql = render(SqlServerUpsertRenderContext.INSTANCE, SqlServerDialect.INSTANCE, TABLE, INSERT_COLUMNS, + CONFLICT_COLUMNS); assertThat(sql).contains("MERGE INTO"); assertThat(sql).contains("my_table"); From b8fa3d3cf554730c39b5e2634ecdfb4d44dae070 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Tue, 24 Mar 2026 07:40:29 +0100 Subject: [PATCH 07/12] Initial Draft for upsert in r2dbc. Fixed a few nullablility issues along the way. Still need to fiture out which types to use Table/Column vs SqlIdentifier et.al. --- .../sql/render/UpsertRendererUnitTests.java | 4 +- .../r2dbc/core/DefaultStatementMapper.java | 67 ++++- .../r2dbc/core/FluentR2dbcOperations.java | 5 +- .../r2dbc/core/R2dbcEntityOperations.java | 18 +- .../data/r2dbc/core/R2dbcEntityTemplate.java | 64 ++++- .../r2dbc/core/ReactiveUpsertOperation.java | 105 ++++++++ .../core/ReactiveUpsertOperationSupport.java | 70 +++++ .../data/r2dbc/core/StatementMapper.java | 151 +++++++++++ .../data/r2dbc/mapping/ParameterAdapter.java | 103 ++++++++ .../data/r2dbc/query/BoundAssignments.java | 19 ++ ...cEntityTemplateUpsertIntegrationTests.java | 250 ++++++++++++++++++ ...cEntityTemplateUpsertIntegrationTests.java | 81 ++++++ ...cEntityTemplateUpsertIntegrationTests.java | 63 +++++ ...cEntityTemplateUpsertIntegrationTests.java | 63 +++++ ...cEntityTemplateUpsertIntegrationTests.java | 73 +++++ ...cEntityTemplateUpsertIntegrationTests.java | 63 +++++ .../ReactiveUpsertOperationUnitTests.java | 186 +++++++++++++ ...cEntityTemplateUpsertIntegrationTests.java | 63 +++++ .../data/r2dbc/testing/StatementRecorder.java | 7 + .../core/sql/DefaultUpsertBuilder.java | 1 + .../StandardSqlUpsertRenderContext.java | 2 +- .../sql/render/UpsertStatementRenderer.java | 30 ++- .../sql/render/UpsertStatementVisitor.java | 26 +- ...andardSqlUpsertRenderContextUnitTests.java | 11 +- .../render/UpsertRenderContextUnitTests.java | 8 +- 25 files changed, 1510 insertions(+), 23 deletions(-) create mode 100644 spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/ReactiveUpsertOperation.java create mode 100644 spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/ReactiveUpsertOperationSupport.java create mode 100644 spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/mapping/ParameterAdapter.java create mode 100644 spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/AbstractR2dbcEntityTemplateUpsertIntegrationTests.java create mode 100644 spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/H2R2dbcEntityTemplateUpsertIntegrationTests.java create mode 100644 spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/MariaDbR2dbcEntityTemplateUpsertIntegrationTests.java create mode 100644 spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/MySqlR2dbcEntityTemplateUpsertIntegrationTests.java create mode 100644 spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/OracleR2dbcEntityTemplateUpsertIntegrationTests.java create mode 100644 spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/PostgresR2dbcEntityTemplateUpsertIntegrationTests.java create mode 100644 spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/ReactiveUpsertOperationUnitTests.java create mode 100644 spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/SqlServerR2dbcEntityTemplateUpsertIntegrationTests.java diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java index f8e63c527e..cdd46b8562 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java @@ -54,8 +54,8 @@ void postgresRendersInsertOnConflictDoUpdate() { Table table = SQL.table("my_table"); Upsert upsert = StatementBuilder.upsert().table(table) - .columnValue(table.column("id").set(SQL.bindMarker("id")), table.column("name").set(SQL.bindMarker("name"))) - .where(table.column("id").isEqualTo(SQL.bindMarker("id"))).build(); + .columnValue(table.column("id").set(SQL.bindMarker(":id")), table.column("name").set(SQL.bindMarker(":name"))) + .where(table.column("id").isEqualTo(SQL.bindMarker(":id"))).build(); var context = new RenderContextFactory(org.springframework.data.jdbc.core.dialect.JdbcPostgresDialect.INSTANCE) .createRenderContext(); diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/DefaultStatementMapper.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/DefaultStatementMapper.java index 1bca7854b4..69dc058c07 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/DefaultStatementMapper.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/DefaultStatementMapper.java @@ -29,8 +29,25 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.query.CriteriaDefinition; -import org.springframework.data.relational.core.sql.*; +import org.springframework.data.relational.core.sql.AssignValue; +import org.springframework.data.relational.core.sql.Assignment; +import org.springframework.data.relational.core.sql.Condition; +import org.springframework.data.relational.core.sql.Delete; +import org.springframework.data.relational.core.sql.DeleteBuilder; +import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.Insert; +import org.springframework.data.relational.core.sql.InsertBuilder; import org.springframework.data.relational.core.sql.InsertBuilder.InsertValuesWithBuild; +import org.springframework.data.relational.core.sql.OrderByField; +import org.springframework.data.relational.core.sql.Select; +import org.springframework.data.relational.core.sql.SelectBuilder; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.core.sql.StatementBuilder; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.Update; +import org.springframework.data.relational.core.sql.UpdateBuilder; +import org.springframework.data.relational.core.sql.Upsert; +import org.springframework.data.relational.core.sql.UpsertBuilder; import org.springframework.data.relational.core.sql.render.RenderContext; import org.springframework.data.relational.core.sql.render.SqlRenderer; import org.springframework.r2dbc.core.PreparedOperation; @@ -46,6 +63,7 @@ * @author Roman Chigvintsev * @author Mingyuan Wu * @author Diego Krupitza + * @author Christoph Strobl */ class DefaultStatementMapper implements StatementMapper { @@ -227,6 +245,11 @@ public PreparedOperation getMappedObject(DeleteSpec deleteSpec) { return getMappedObject(deleteSpec, null); } + @Override + public PreparedOperation getMappedObject(UpsertSpec upsertSpec) { + return getMappedObject(upsertSpec, null); + } + @Override public RenderContext getRenderContext() { return renderContext; @@ -258,6 +281,39 @@ private PreparedOperation getMappedObject(DeleteSpec deleteSpec, return new DefaultPreparedOperation<>(delete, this.renderContext, bindings); } + private PreparedOperation getMappedObject(UpsertSpec upsertSpec, + @Nullable RelationalPersistentEntity entity) { + + BindMarkers bindMarkers = this.dialect.getBindMarkersFactory().create(); + Table table = Table.create(toSql(upsertSpec.getTable())); + + BoundAssignments boundAssignments = this.updateMapper.getMappedObject(bindMarkers, upsertSpec.getAssignments(), + table, entity); + Bindings bindings = boundAssignments.getBindings(); + UpsertBuilder.UpsertWhere upsertBuilder = StatementBuilder.upsert().table(table) + .columnValue(boundAssignments.getAssignments()); + + List conflictColumns = upsertSpec.getConflictColumns(); + Assert.notEmpty(conflictColumns, "Conflict columns must not be empty for upsert"); + + Condition result = null; + + for (SqlIdentifier conflictCol : conflictColumns) { + + Assignment assignment = boundAssignments.getAssignment(conflictCol); + if (assignment instanceof AssignValue av) { + Condition condition = table.column(conflictCol).isEqualTo(av.getValue()); + result = result == null ? condition : result.and(condition); + } + } + + Assert.state(result != null, "Conflict condition must not be null"); + Condition whereCondition = result; + Upsert upsert = upsertBuilder.where(whereCondition).build(); + + return new DefaultPreparedOperation<>(upsert, this.renderContext, bindings); + } + private String toSql(SqlIdentifier identifier) { Assert.notNull(identifier, "SqlIdentifier must not be null"); @@ -309,6 +365,10 @@ public String toQuery() { return sqlRenderer.render((Delete) this.source); } + if (this.source instanceof Upsert) { + return sqlRenderer.render((Upsert) this.source); + } + throw new IllegalStateException("Cannot render " + this.getSource()); } @@ -352,6 +412,11 @@ public PreparedOperation getMappedObject(DeleteSpec deleteSpec) { return DefaultStatementMapper.this.getMappedObject(deleteSpec, this.entity); } + @Override + public PreparedOperation getMappedObject(UpsertSpec upsertSpec) { + return DefaultStatementMapper.this.getMappedObject(upsertSpec, this.entity); + } + @Override public RenderContext getRenderContext() { return DefaultStatementMapper.this.getRenderContext(); diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/FluentR2dbcOperations.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/FluentR2dbcOperations.java index 740e45cea1..5ab6d6878f 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/FluentR2dbcOperations.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/FluentR2dbcOperations.java @@ -19,8 +19,9 @@ * Stripped down interface providing access to a fluent API that specifies a basic set of reactive R2DBC operations. * * @author Mark Paluch + * @author Christoph Strobl * @since 1.1 * @see R2dbcEntityOperations */ -public interface FluentR2dbcOperations - extends ReactiveSelectOperation, ReactiveInsertOperation, ReactiveUpdateOperation, ReactiveDeleteOperation {} +public interface FluentR2dbcOperations extends ReactiveSelectOperation, ReactiveInsertOperation, + ReactiveUpdateOperation, ReactiveDeleteOperation, ReactiveUpsertOperation {} diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityOperations.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityOperations.java index f6b4ecf51f..7ca2d5577f 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityOperations.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityOperations.java @@ -39,6 +39,7 @@ * mocked or stubbed. * * @author Mark Paluch + * @author Christoph Strobl * @since 1.1 * @see DatabaseClient */ @@ -165,9 +166,9 @@ public interface R2dbcEntityOperations extends FluentR2dbcOperations { * additional pre-processing such as named parameter expansion. Results of the query are mapped onto * {@code entityClass}. * - * @param operation the prepared operation wrapping a SQL query and bind parameters. + * @param operation the prepared operation wrapping a SQL query and bind parameters. * @param entityClass the entity type must not be {@literal null}. - * @param resultType the returned entity, type must not be {@literal null}. + * @param resultType the returned entity, type must not be {@literal null}. * @return a {@link RowsFetchSpec} ready to materialize. * @throws DataAccessException if there is any problem issuing the execution. * @since 3.2.1 @@ -266,6 +267,19 @@ RowsFetchSpec getRowsFetchSpec(DatabaseClient.GenericExecuteSpec executeS */ Mono insert(T entity) throws DataAccessException; + /** + * Upsert (insert-or-update) the given entity by its primary key and emit the entity afterwards. + *

+ * The upsert uses the entity's identifier columns as conflict keys: if a row with the same key already exists it is + * updated, otherwise a new row is inserted. + * + * @param entity the entity to upsert, must not be {@literal null}. + * @return the upserted entity. + * @throws DataAccessException if there is any problem issuing the execution. + * @since 4.x + */ + Mono upsert(T entity) throws DataAccessException; + /** * Update the given entity and emit the entity if the update was applied. * diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplate.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplate.java index b447bb3ff4..3323cef141 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplate.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplate.java @@ -35,7 +35,6 @@ import org.jspecify.annotations.Nullable; import org.reactivestreams.Publisher; - import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactoryAware; @@ -54,6 +53,7 @@ import org.springframework.data.r2dbc.dialect.DialectResolver; import org.springframework.data.r2dbc.dialect.R2dbcDialect; import org.springframework.data.r2dbc.mapping.OutboundRow; +import org.springframework.data.r2dbc.mapping.ParameterAdapter; import org.springframework.data.r2dbc.mapping.event.AfterConvertCallback; import org.springframework.data.r2dbc.mapping.event.AfterSaveCallback; import org.springframework.data.r2dbc.mapping.event.BeforeConvertCallback; @@ -255,6 +255,11 @@ public ReactiveInsert insert(Class domainType) { return new ReactiveInsertOperationSupport(this).insert(domainType); } + @Override + public ReactiveUpsert upsert(Class domainType) { + return new ReactiveUpsertOperationSupport(this).upsert(domainType); + } + @Override public ReactiveUpdate update(Class domainType) { return new ReactiveUpdateOperationSupport(this).update(domainType); @@ -575,6 +580,60 @@ private Mono doInsert(T entity, SqlIdentifier tableName, OutboundRow outb .last(entity).flatMap(saved -> maybeCallAfterSave(saved, outboundRow, tableName)); } + @Override + public Mono upsert(T entity) throws DataAccessException { + + Assert.notNull(entity, "Entity must not be null"); + + RelationalPersistentEntity persistentEntity = getRequiredEntity(entity); + return doUpsert(entity, persistentEntity, persistentEntity.getQualifiedTableName()); + } + + Mono doUpsert(T entity, SqlIdentifier tableName) { + + RelationalPersistentEntity persistentEntity = getRequiredEntity(entity); + return doUpsert(entity, persistentEntity, tableName); + } + + Mono doUpsert(T entity, RelationalPersistentEntity persistentEntity, SqlIdentifier tableName) { + + return maybeCallBeforeConvert(entity, tableName).flatMap(onBeforeConvert -> { + + OutboundRow outboundRow = dataAccessStrategy.getOutboundRow(onBeforeConvert); + + return maybeCallBeforeSave(onBeforeConvert, outboundRow, tableName) // + .flatMap(entityToSave -> doUpsert(entityToSave, tableName, outboundRow, persistentEntity)); + }); + } + + private Mono doUpsert(T entity, SqlIdentifier tableName, OutboundRow outboundRow, + RelationalPersistentEntity persistentEntity) { + + StatementMapper mapper = dataAccessStrategy.getStatementMapper(); + StatementMapper.UpsertSpec upsert = mapper.createUpsert(tableName); + + for (SqlIdentifier column : outboundRow.keySet()) { + io.r2dbc.spi.Parameter settableValue = ParameterAdapter.wrap(outboundRow.get(column)); + if (settableValue.getValue() != null) { + upsert = upsert.withColumn(column, settableValue); + } + } + + List identifierColumns = dataAccessStrategy.getIdentifierColumns(persistentEntity.getType()); + for (SqlIdentifier idColumn : identifierColumns) { + upsert = upsert.withConflictColumn(idColumn); + } + + PreparedOperation operation = mapper.getMappedObject(upsert); + + return this.databaseClient.sql(operation) // + .filter(statementFilterFunction) // + .fetch() // + .rowsUpdated() // + .thenReturn(entity) // + .flatMap(saved -> maybeCallAfterSave(saved, outboundRow, tableName)); + } + @SuppressWarnings("unchecked") private T setVersionIfNecessary(RelationalPersistentEntity persistentEntity, T entity) { @@ -667,8 +726,7 @@ private Mono doUpdate(T entity, SqlIdentifier tableName) { @SuppressWarnings({ "unchecked", "rawtypes" }) private Mono doUpdate(T entity, @Nullable Object version, SqlIdentifier tableName, - RelationalPersistentEntity persistentEntity, - Criteria criteria, OutboundRow outboundRow) { + RelationalPersistentEntity persistentEntity, Criteria criteria, OutboundRow outboundRow) { Update update = Update.from((Map) outboundRow); StatementMapper mapper = dataAccessStrategy.getStatementMapper(); diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/ReactiveUpsertOperation.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/ReactiveUpsertOperation.java new file mode 100644 index 0000000000..192762aceb --- /dev/null +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/ReactiveUpsertOperation.java @@ -0,0 +1,105 @@ +/* + * Copyright 2026-present 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.core; + +import reactor.core.publisher.Mono; + +import org.springframework.data.relational.core.sql.SqlIdentifier; + +/** + * The {@link ReactiveUpsertOperation} interface allows creation and execution of {@code UPSERT} (insert-or-update) + * operations in a fluent API style. + *

+ * By default, the table to operate on is derived from the initial {@link Class domainType} and can be defined there + * via {@link org.springframework.data.relational.core.mapping.Table} annotation. Using {@code inTable} allows + * overriding the table name for the execution. + * + *

+ *     
+ *         upsert(Jedi.class)
+ *             .inTable("star_wars")
+ *             .using(luke);
+ *     
+ * 
+ * + * @author Christoph Strobl + * @since 4.x + */ +public interface ReactiveUpsertOperation { + + /** + * Begin creating an {@code UPSERT} operation for given {@link Class domainType}. + * + * @param {@link Class type} of the application domain object. + * @param domainType {@link Class type} of the domain object to upsert; must not be {@literal null}. + * @return new instance of {@link ReactiveUpsert}. + * @throws IllegalArgumentException if {@link Class domainType} is {@literal null}. + * @see ReactiveUpsert + */ + ReactiveUpsert upsert(Class domainType); + + /** + * Table override (optional). + */ + interface UpsertWithTable extends TerminatingUpsert { + + /** + * Explicitly set the {@link String name} of the table. + *

+ * Skip this step to use the default table derived from the {@link Class domain type}. + * + * @param table {@link String name} of the table; must not be {@literal null} or empty. + * @return new instance of {@link TerminatingUpsert}. + * @throws IllegalArgumentException if {@link String table} is {@literal null} or empty. + */ + default TerminatingUpsert inTable(String table) { + return inTable(SqlIdentifier.unquoted(table)); + } + + /** + * Explicitly set the {@link SqlIdentifier name} of the table. + *

+ * Skip this step to use the default table derived from the {@link Class domain type}. + * + * @param table {@link SqlIdentifier name} of the table; must not be {@literal null}. + * @return new instance of {@link TerminatingUpsert}. + * @throws IllegalArgumentException if {@link SqlIdentifier table} is {@literal null}. + */ + TerminatingUpsert inTable(SqlIdentifier table); + } + + /** + * Trigger {@code UPSERT} execution by calling one of the terminating methods. + */ + interface TerminatingUpsert { + + /** + * Upsert exactly one {@link Object}. + * + * @param object {@link Object} to upsert; must not be {@literal null}. + * @return the upserted entity. + * @throws IllegalArgumentException if {@link Object} is {@literal null}. + * @see Mono + */ + Mono one(T object); + } + + /** + * The {@link ReactiveUpsert} interface provides methods for constructing {@code UPSERT} operations in a fluent way. + */ + interface ReactiveUpsert extends UpsertWithTable {} + +} diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/ReactiveUpsertOperationSupport.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/ReactiveUpsertOperationSupport.java new file mode 100644 index 0000000000..2a9114827d --- /dev/null +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/ReactiveUpsertOperationSupport.java @@ -0,0 +1,70 @@ +/* + * Copyright 2026-present 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.core; + +import reactor.core.publisher.Mono; + +import org.jspecify.annotations.Nullable; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.util.Assert; + +/** + * Implementation of {@link ReactiveUpsertOperation}. + * + * @author Christoph Strobl + * @since 4.x + */ +record ReactiveUpsertOperationSupport(R2dbcEntityTemplate template) implements ReactiveUpsertOperation { + + @Override + public ReactiveUpsert upsert(Class domainType) { + + Assert.notNull(domainType, "DomainType must not be null"); + return new ReactiveUpsertSupport<>(template, domainType, null); + } + + static class ReactiveUpsertSupport implements ReactiveUpsert { + + private final R2dbcEntityTemplate template; + private final Class domainType; + private final @Nullable SqlIdentifier tableName; + + ReactiveUpsertSupport(R2dbcEntityTemplate template, Class domainType, @Nullable SqlIdentifier tableName) { + + this.template = template; + this.domainType = domainType; + this.tableName = tableName; + } + + @Override + public TerminatingUpsert inTable(SqlIdentifier tableName) { + + Assert.notNull(tableName, "Table name must not be null"); + return new ReactiveUpsertSupport<>(template, domainType, tableName); + } + + @Override + public Mono one(T object) { + + Assert.notNull(object, "Object to upsert must not be null"); + return template.doUpsert(object, getTableName()); + } + + private SqlIdentifier getTableName() { + return tableName != null ? tableName : template.getTableName(domainType); + } + } +} diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/StatementMapper.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/StatementMapper.java index 9e9861f9bb..c9de4f4789 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/StatementMapper.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/StatementMapper.java @@ -31,8 +31,11 @@ import org.springframework.data.domain.Sort; import org.springframework.data.r2dbc.convert.R2dbcConverter; import org.springframework.data.r2dbc.dialect.R2dbcDialect; +import org.springframework.data.r2dbc.mapping.ParameterAdapter; import org.springframework.data.relational.core.query.Criteria; import org.springframework.data.relational.core.query.CriteriaDefinition; +import org.springframework.data.relational.core.sql.Assignment; +import org.springframework.data.relational.core.sql.AssignValue; import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.LockMode; import org.springframework.data.relational.core.sql.SqlIdentifier; @@ -55,6 +58,7 @@ * @author Roman Chigvintsev * @author Mingyuan Wu * @author Diego Krupitza + * @author Christoph Strobl */ public interface StatementMapper { @@ -206,6 +210,37 @@ default DeleteSpec createDelete(SqlIdentifier table) { return DeleteSpec.create(table); } + /** + * Create an {@code UPSERT} specification for {@code table}. + * + * @param table + * @return the {@link UpsertSpec}. + * @since 4.x + */ + default UpsertSpec createUpsert(String table) { + return UpsertSpec.create(table); + } + + /** + * Create an {@code UPSERT} specification for {@code table}. + * + * @param table + * @return the {@link UpsertSpec}. + * @since 4.x + */ + default UpsertSpec createUpsert(SqlIdentifier table) { + return UpsertSpec.create(table); + } + + /** + * Map an upsert specification to a {@link PreparedOperation}. + * + * @param upsertSpec the upsert operation definition, must not be {@literal null}. + * @return the {@link PreparedOperation} for {@link UpsertSpec}. + * @since 4.x + */ + PreparedOperation getMappedObject(UpsertSpec upsertSpec); + /** * Returns {@link RenderContext}. * @@ -645,4 +680,120 @@ public SqlIdentifier getTable() { return this.criteria; } } + + /** + * {@code UPSERT} specification. + * + * @author Christoph Strobl + * @since 4.x + */ + class UpsertSpec { + + private final SqlIdentifier table; + private final Map assignments; + private final List conflictColumns; + + protected UpsertSpec(SqlIdentifier table, Map assignments, + List conflictColumns) { + + this.table = table; + this.assignments = assignments; + this.conflictColumns = conflictColumns; + } + + /** + * Create an {@code UPSERT} specification for {@code table}. + * + * @param table + * @return the {@link UpsertSpec}. + */ + public static UpsertSpec create(String table) { + return create(SqlIdentifier.unquoted(table)); + } + + /** + * Create an {@code UPSERT} specification for {@code table}. + * + * @param table + * @return the {@link UpsertSpec}. + */ + public static UpsertSpec create(SqlIdentifier table) { + return new UpsertSpec(table, Collections.emptyMap(), Collections.emptyList()); + } + + /** + * Associate a column with a {@link Parameter} and create a new {@link UpsertSpec}. + * + * @param column + * @param value + * @return the {@link UpsertSpec}. + */ + @SuppressWarnings("deprecation") + public UpsertSpec withColumn(String column, Parameter value) { + return withColumn(column, ParameterAdapter.wrap(value)); + } + /** + * Associate a column with a {@link io.r2dbc.spi.Parameter} and create a new {@link UpsertSpec}. + * + * @param column + * @param value + * @return the {@link UpsertSpec}. + */ + public UpsertSpec withColumn(String column, io.r2dbc.spi.Parameter value) { + return withColumn(SqlIdentifier.unquoted(column), value); + } + + /** + * Associate a column with a {@link Parameter} and create a new {@link UpsertSpec}. + * + * @param column + * @param value + * @return the {@link UpsertSpec}. + */ + @SuppressWarnings("deprecation") + public UpsertSpec withColumn(SqlIdentifier column, Parameter value) { + return withColumn(column, ParameterAdapter.wrap(value)); + } + + /** + * Associate a column with a {@link io.r2dbc.spi.Parameter} and create a new {@link UpsertSpec}. + * + * @param column + * @param value + * @return the {@link UpsertSpec}. + */ + public UpsertSpec withColumn(SqlIdentifier column, io.r2dbc.spi.Parameter value) { + + Map values = new LinkedHashMap<>(this.assignments); + values.put(column, value); + + return new UpsertSpec(this.table, values, this.conflictColumns); + } + + /** + * Mark a column as a conflict key (typically the primary key) and create a new {@link UpsertSpec}. + * + * @param column the conflict column identifier. + * @return the {@link UpsertSpec}. + */ + public UpsertSpec withConflictColumn(SqlIdentifier column) { + + List conflict = new ArrayList<>(this.conflictColumns); + conflict.add(column); + + return new UpsertSpec(this.table, this.assignments, conflict); + } + + public SqlIdentifier getTable() { + return this.table; + } + + public Map getAssignments() { + return Collections.unmodifiableMap(this.assignments); + } + + public List getConflictColumns() { + return Collections.unmodifiableList(this.conflictColumns); + } + } } diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/mapping/ParameterAdapter.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/mapping/ParameterAdapter.java new file mode 100644 index 0000000000..368918e1d7 --- /dev/null +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/mapping/ParameterAdapter.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026-present 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.mapping; + +import io.r2dbc.spi.Type; + +import java.util.Objects; + +import org.jspecify.annotations.Nullable; +import org.springframework.r2dbc.core.Parameter; +import org.springframework.util.ObjectUtils; + +/** + * @author Christoph Strobl + * @since 4.x + */ +@SuppressWarnings("deprecation") +public class ParameterAdapter implements io.r2dbc.spi.Parameter { + + private final org.springframework.r2dbc.core.@Nullable Parameter delegate; + private final Type inferredType; + + public ParameterAdapter(@Nullable Parameter delegate) { + this.delegate = delegate; + this.inferredType = new Type.InferredType() { + + @Override + public Class getJavaType() { + return delegate != null ? delegate.getType() : Object.class; + } + + @Override + public String getName() { + return "(inferred)"; + } + }; + } + + /** + * Wraps a {@link Parameter} into an {@link io.r2dbc.spi.Parameter}. + * + * @param parameter can be {@literal null}. + * @return new instance of {@link ParameterAdapter}. + */ + public static io.r2dbc.spi.Parameter wrap(@Nullable Parameter parameter) { + return new ParameterAdapter(parameter); + } + + @Override + public Type getType() { + return inferredType; + } + + @Override + public @Nullable Object getValue() { + return delegate != null ? delegate.getValue() : null; + } + + @Override + public boolean equals(Object o) { + + if (o == this) { + return true; + } + if (o == null) { + return false; + } + if (o instanceof Parameter p) { + return equals(p); + } + if (!(o instanceof ParameterAdapter that)) { + return false; + } + return Objects.equals(delegate, that.delegate) && inferredType.equals(that.inferredType); + } + + private boolean equals(Parameter that) { + return ObjectUtils.nullSafeEquals(delegate, that); + } + + @Override + public int hashCode() { + return delegate != null ? delegate.hashCode() : -1; + } + + @Override + public String toString() { + return "ParameterAdapter[value=" + this.getValue() + ",type=" + this.getType().getName() + "]"; + } +} diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/BoundAssignments.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/BoundAssignments.java index 98d5601f9e..ccf0dbf405 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/BoundAssignments.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/BoundAssignments.java @@ -17,7 +17,10 @@ import java.util.List; +import org.springframework.data.relational.core.sql.AssignValue; import org.springframework.data.relational.core.sql.Assignment; +import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.r2dbc.core.binding.Bindings; import org.springframework.util.Assert; @@ -25,6 +28,7 @@ * Value object representing {@link Assignment}s with their {@link Bindings}. * * @author Mark Paluch + * @author Christoph Strobl */ public class BoundAssignments { @@ -48,4 +52,19 @@ public Bindings getBindings() { public List getAssignments() { return assignments; } + + /** + * Resolve the bound {@link Assignment} for the given {@code indentifier}.. + * + * @param identifier the column to look up. + * @return the bind marker {@link Expression} for {@code column}. + * @throws IllegalStateException if no assignment for {@code column} is found. + */ + public Assignment getAssignment(SqlIdentifier identifier) { + return assignments.stream().filter(AssignValue.class::isInstance) // + .map(AssignValue.class::cast) // + .filter(av -> av.getColumn().getName().equals(identifier)) // + .findFirst() // + .orElseThrow(() -> new IllegalStateException("No assignment found for: " + identifier)); + } } diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/AbstractR2dbcEntityTemplateUpsertIntegrationTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/AbstractR2dbcEntityTemplateUpsertIntegrationTests.java new file mode 100644 index 0000000000..275c993f79 --- /dev/null +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/AbstractR2dbcEntityTemplateUpsertIntegrationTests.java @@ -0,0 +1,250 @@ +/* + * Copyright 2026-present 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.core; + +import static org.assertj.core.api.Assertions.*; + +import io.r2dbc.spi.ConnectionFactory; +import reactor.test.StepVerifier; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessException; +import org.springframework.data.annotation.Id; +import org.springframework.data.r2dbc.testing.R2dbcIntegrationTestSupport; +import org.springframework.data.relational.core.mapping.InsertOnlyProperty; +import org.springframework.data.relational.core.mapping.Table; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.r2dbc.core.DatabaseClient; + +/** + * Abstract base class for {@link R2dbcEntityTemplate} upsert integration tests across different databases. + * + * @author Christoph Strobl + */ +public abstract class AbstractR2dbcEntityTemplateUpsertIntegrationTests extends R2dbcIntegrationTestSupport { + + protected JdbcTemplate jdbc; + protected DatabaseClient client; + protected R2dbcEntityTemplate entityTemplate; + + @BeforeEach + void setUp() { + + jdbc = createJdbcTemplate(createDataSource()); + ConnectionFactory connectionFactory = createConnectionFactory(); + client = DatabaseClient.create(connectionFactory); + entityTemplate = createEntityTemplate(connectionFactory); + + try { + jdbc.execute(getDropLegosetStatement()); + } catch (DataAccessException ignore) {} + + jdbc.execute(getCreateLegosetStatement()); + + try { + jdbc.execute(getDropWithInsertOnlyStatement()); + } catch (DataAccessException ignore) {} + + jdbc.execute(getCreateWithInsertOnlyStatement()); + } + + /** + * @return the {@link DataSource} for JDBC-based test setup. + */ + protected abstract DataSource createDataSource(); + + /** + * @return the {@link ConnectionFactory} for R2DBC operations. + */ + protected abstract ConnectionFactory createConnectionFactory(); + + /** + * Creates the {@link R2dbcEntityTemplate} for the given {@link ConnectionFactory}. Subclasses may override to + * customize, e.g., to disable identifier quoting via {@link R2dbcMappingContext#setForceQuote(boolean)}. + */ + protected R2dbcEntityTemplate createEntityTemplate(ConnectionFactory connectionFactory) { + return new R2dbcEntityTemplate(connectionFactory); + } + + /** + * @return the CREATE TABLE statement for {@code legoset} with explicit (non-generated) integer primary key, name and + * manual columns. + */ + protected abstract String getCreateLegosetStatement(); + + /** + * @return the CREATE TABLE statement for {@code with_insert_only} with explicit (non-generated) integer primary key + * and an {@code insert_only} varchar column. + */ + protected abstract String getCreateWithInsertOnlyStatement(); + + /** + * @return the DROP TABLE statement for {@code legoset}. Subclasses may override when the table identifier requires + * explicit quoting (e.g. Oracle). + */ + protected String getDropLegosetStatement() { + return "DROP TABLE legoset"; + } + + /** + * @return the DROP TABLE statement for {@code with_insert_only}. Subclasses may override when the table identifier + * requires explicit quoting (e.g. Oracle). + */ + protected String getDropWithInsertOnlyStatement() { + return "DROP TABLE with_insert_only"; + } + + @Test // GH-493 + void upsertInsertsWhenIdDoesNotExist() { + + LegoSet lego = new LegoSet(8888L, "star-wars", 10); + + entityTemplate.upsert(lego) // + .as(StepVerifier::create) // + .assertNext(actual -> assertThat(actual.id).isEqualTo(8888L)) // + .verifyComplete(); + + entityTemplate.select(LegoSet.class).matching(org.springframework.data.relational.core.query.Query.empty()).all() // + .as(StepVerifier::create) // + .assertNext(actual -> { + assertThat(actual.id).isEqualTo(8888L); + assertThat(actual.name).isEqualTo("star-wars"); + }) // + .verifyComplete(); + } + + @Test // GH-493 + void upsertUpdatesWhenIdExists() { + + LegoSet first = new LegoSet(8888L, "first", 10); + LegoSet second = new LegoSet(8888L, "second", 20); + + entityTemplate.upsert(first) // + .then(entityTemplate.upsert(second)) // + .as(StepVerifier::create) // + .assertNext(actual -> assertThat(actual.name).isEqualTo("second")) // + .verifyComplete(); + + entityTemplate.select(LegoSet.class).matching(org.springframework.data.relational.core.query.Query.empty()).all() // + .as(StepVerifier::create) // + .assertNext(actual -> { + assertThat(actual.id).isEqualTo(8888L); + assertThat(actual.name).isEqualTo("second"); + }) // + .verifyComplete(); + } + + @Test // GH-493 + void upsertAfterDeleteInsertsAgain() { + + LegoSet first = new LegoSet(8888L, "first", 10); + LegoSet second = new LegoSet(8888L, "second", 20); + + entityTemplate.upsert(first) // + .then(entityTemplate.delete(first)) // + .then(entityTemplate.upsert(second)) // + .as(StepVerifier::create) // + .assertNext(actual -> assertThat(actual.name).isEqualTo("second")) // + .verifyComplete(); + + entityTemplate.select(LegoSet.class).matching(org.springframework.data.relational.core.query.Query.empty()).all() // + .as(StepVerifier::create) // + .assertNext(actual -> assertThat(actual.name).isEqualTo("second")) // + .verifyComplete(); + } + + @Test // GH-493 + void upsertNoOpWhenNonKeyColumnsAlreadyMatch() { + + LegoSet lego = new LegoSet(8888L, "millennium", 5); + + entityTemplate.upsert(lego) // + .then(entityTemplate.upsert(lego)) // + .as(StepVerifier::create) // + .assertNext(actual -> assertThat(actual.name).isEqualTo("millennium")) // + .verifyComplete(); + + entityTemplate.select(LegoSet.class).matching(org.springframework.data.relational.core.query.Query.empty()).all() // + .as(StepVerifier::create) // + .assertNext(actual -> { + assertThat(actual.name).isEqualTo("millennium"); + }) // + .verifyComplete(); + } + + @Test // GH-493 + void upsertIncludesInsertOnlyColumnOnInsert() { + + WithInsertOnly entity = new WithInsertOnly(8888L, "initial"); + + entityTemplate.upsert(entity) // + .as(StepVerifier::create) // + .assertNext(actual -> assertThat(actual.insertOnly).isEqualTo("initial")) // + .verifyComplete(); + + entityTemplate.select(WithInsertOnly.class).matching(org.springframework.data.relational.core.query.Query.empty()) + .all() // + .as(StepVerifier::create) // + .assertNext(actual -> assertThat(actual.insertOnly).isEqualTo("initial")) // + .verifyComplete(); + } + + @Test // GH-493 + void upsertViaFluentApiInTable() { + + LegoSet lego = new LegoSet(8888L, "test", 1); + + entityTemplate.upsert(LegoSet.class) // + .one(lego) // + .as(StepVerifier::create) // + .assertNext(actual -> assertThat(actual.id).isEqualTo(8888L)) // + .verifyComplete(); + } + + @Table("legoset") + static class LegoSet { + + @Id Long id; + String name; + Integer manual; + + LegoSet() {} + + LegoSet(Long id, String name, Integer manual) { + this.id = id; + this.name = name; + this.manual = manual; + } + } + + @Table("with_insert_only") + static class WithInsertOnly { + + @Id Long id; + + @InsertOnlyProperty String insertOnly; + + WithInsertOnly() {} + + WithInsertOnly(Long id, String insertOnly) { + this.id = id; + this.insertOnly = insertOnly; + } + } +} diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/H2R2dbcEntityTemplateUpsertIntegrationTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/H2R2dbcEntityTemplateUpsertIntegrationTests.java new file mode 100644 index 0000000000..3a58ee5958 --- /dev/null +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/H2R2dbcEntityTemplateUpsertIntegrationTests.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026-present 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.core; + +import io.r2dbc.spi.ConnectionFactory; + +import java.util.Collections; + +import javax.sql.DataSource; + +import org.springframework.data.r2dbc.convert.MappingR2dbcConverter; +import org.springframework.data.r2dbc.convert.R2dbcCustomConversions; +import org.springframework.data.r2dbc.dialect.DialectResolver; +import org.springframework.data.r2dbc.dialect.R2dbcDialect; +import org.springframework.data.r2dbc.mapping.R2dbcMappingContext; +import org.springframework.data.r2dbc.testing.H2TestSupport; +import org.springframework.r2dbc.core.DatabaseClient; + +/** + * H2-specific integration tests for {@link R2dbcEntityTemplate} upsert. + * + * @author Christoph Strobl + */ +public class H2R2dbcEntityTemplateUpsertIntegrationTests extends AbstractR2dbcEntityTemplateUpsertIntegrationTests { + + @Override + protected DataSource createDataSource() { + return H2TestSupport.createDataSource(); + } + + @Override + protected ConnectionFactory createConnectionFactory() { + return H2TestSupport.createConnectionFactory(); + } + + @Override + protected String getCreateLegosetStatement() { + return "CREATE TABLE legoset (" // + + " id bigint CONSTRAINT legoset_pk PRIMARY KEY," // + + " name varchar(255) NOT NULL," // + + " manual integer NULL" // + + ")"; + } + + @Override + protected String getCreateWithInsertOnlyStatement() { + return "CREATE TABLE with_insert_only (" // + + " id bigint CONSTRAINT with_insert_only_pk PRIMARY KEY," // + + " insert_only varchar(255) NULL" // + + ")"; + } + + @Override + protected R2dbcEntityTemplate createEntityTemplate(ConnectionFactory connectionFactory) { + + R2dbcDialect dialect = DialectResolver.getDialect(connectionFactory); + R2dbcCustomConversions customConversions = R2dbcCustomConversions.of(dialect, Collections.emptyList()); + + R2dbcMappingContext context = new R2dbcMappingContext(); + context.setForceQuote(false); + context.setSimpleTypeHolder(customConversions.getSimpleTypeHolder()); + + MappingR2dbcConverter converter = new MappingR2dbcConverter(context, customConversions); + DefaultReactiveDataAccessStrategy strategy = new DefaultReactiveDataAccessStrategy(dialect, converter); + + return new R2dbcEntityTemplate(DatabaseClient.create(connectionFactory), strategy); + } +} diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/MariaDbR2dbcEntityTemplateUpsertIntegrationTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/MariaDbR2dbcEntityTemplateUpsertIntegrationTests.java new file mode 100644 index 0000000000..77d5ab18cf --- /dev/null +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/MariaDbR2dbcEntityTemplateUpsertIntegrationTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026-present 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.core; + +import io.r2dbc.spi.ConnectionFactory; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.data.r2dbc.testing.ExternalDatabase; +import org.springframework.data.r2dbc.testing.MariaDbTestSupport; + +/** + * MariaDB-specific integration tests for {@link R2dbcEntityTemplate} upsert. + * + * @author Christoph Strobl + */ +public class MariaDbR2dbcEntityTemplateUpsertIntegrationTests + extends AbstractR2dbcEntityTemplateUpsertIntegrationTests { + + @RegisterExtension + public static final ExternalDatabase database = MariaDbTestSupport.database(); + + @Override + protected DataSource createDataSource() { + return MariaDbTestSupport.createDataSource(database); + } + + @Override + protected ConnectionFactory createConnectionFactory() { + return MariaDbTestSupport.createConnectionFactory(database); + } + + @Override + protected String getCreateLegosetStatement() { + return "CREATE TABLE legoset (" // + + " id bigint PRIMARY KEY," // + + " name varchar(255) NOT NULL," // + + " manual integer NULL" // + + ") ENGINE=InnoDB"; + } + + @Override + protected String getCreateWithInsertOnlyStatement() { + return "CREATE TABLE with_insert_only (" // + + " id bigint PRIMARY KEY," // + + " insert_only varchar(255) NULL" // + + ") ENGINE=InnoDB"; + } +} diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/MySqlR2dbcEntityTemplateUpsertIntegrationTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/MySqlR2dbcEntityTemplateUpsertIntegrationTests.java new file mode 100644 index 0000000000..6b61b3db7e --- /dev/null +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/MySqlR2dbcEntityTemplateUpsertIntegrationTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026-present 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.core; + +import io.r2dbc.spi.ConnectionFactory; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.data.r2dbc.testing.ExternalDatabase; +import org.springframework.data.r2dbc.testing.MySqlDbTestSupport; + +/** + * MySQL-specific integration tests for {@link R2dbcEntityTemplate} upsert. + * + * @author Christoph Strobl + */ +public class MySqlR2dbcEntityTemplateUpsertIntegrationTests + extends AbstractR2dbcEntityTemplateUpsertIntegrationTests { + + @RegisterExtension + public static final ExternalDatabase database = MySqlDbTestSupport.database(); + + @Override + protected DataSource createDataSource() { + return MySqlDbTestSupport.createDataSource(database); + } + + @Override + protected ConnectionFactory createConnectionFactory() { + return MySqlDbTestSupport.createConnectionFactory(database); + } + + @Override + protected String getCreateLegosetStatement() { + return "CREATE TABLE legoset (" // + + " id bigint PRIMARY KEY," // + + " name varchar(255) NOT NULL," // + + " manual integer NULL" // + + ") ENGINE=InnoDB"; + } + + @Override + protected String getCreateWithInsertOnlyStatement() { + return "CREATE TABLE with_insert_only (" // + + " id bigint PRIMARY KEY," // + + " insert_only varchar(255) NULL" // + + ") ENGINE=InnoDB"; + } +} diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/OracleR2dbcEntityTemplateUpsertIntegrationTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/OracleR2dbcEntityTemplateUpsertIntegrationTests.java new file mode 100644 index 0000000000..bc4ee62cbe --- /dev/null +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/OracleR2dbcEntityTemplateUpsertIntegrationTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026-present 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.core; + +import io.r2dbc.spi.ConnectionFactory; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.data.r2dbc.testing.ExternalDatabase; +import org.springframework.data.r2dbc.testing.OracleTestSupport; + +/** + * Oracle-specific integration tests for {@link R2dbcEntityTemplate} upsert. + * + * @author Christoph Strobl + */ +public class OracleR2dbcEntityTemplateUpsertIntegrationTests + extends AbstractR2dbcEntityTemplateUpsertIntegrationTests { + + @RegisterExtension + public static final ExternalDatabase database = OracleTestSupport.database(); + + @Override + protected DataSource createDataSource() { + return OracleTestSupport.createDataSource(database); + } + + @Override + protected ConnectionFactory createConnectionFactory() { + return OracleTestSupport.createConnectionFactory(database); + } + + @Override + protected String getCreateLegosetStatement() { + return "CREATE TABLE \"legoset\" (" // + + " id NUMBER(19) CONSTRAINT legoset_pk PRIMARY KEY," // + + " name VARCHAR2(255) NOT NULL," // + + " manual NUMBER(10) NULL" // + + ")"; + } + + @Override + protected String getCreateWithInsertOnlyStatement() { + return "CREATE TABLE \"with_insert_only\" (" // + + " id NUMBER(19) CONSTRAINT with_insert_only_pk PRIMARY KEY," // + + " insert_only VARCHAR2(255) NULL" // + + ")"; + } + + @Override + protected String getDropLegosetStatement() { + return "DROP TABLE \"legoset\""; + } + + @Override + protected String getDropWithInsertOnlyStatement() { + return "DROP TABLE \"with_insert_only\""; + } +} diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/PostgresR2dbcEntityTemplateUpsertIntegrationTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/PostgresR2dbcEntityTemplateUpsertIntegrationTests.java new file mode 100644 index 0000000000..c2c5569f50 --- /dev/null +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/PostgresR2dbcEntityTemplateUpsertIntegrationTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026-present 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.core; + +import io.r2dbc.spi.ConnectionFactory; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.data.r2dbc.testing.ExternalDatabase; +import org.springframework.data.r2dbc.testing.PostgresTestSupport; + +/** + * PostgreSQL-specific integration tests for {@link R2dbcEntityTemplate} upsert. + * + * @author Christoph Strobl + */ +public class PostgresR2dbcEntityTemplateUpsertIntegrationTests + extends AbstractR2dbcEntityTemplateUpsertIntegrationTests { + + @RegisterExtension + public static final ExternalDatabase database = PostgresTestSupport.database(); + + @Override + protected DataSource createDataSource() { + return PostgresTestSupport.createDataSource(database); + } + + @Override + protected ConnectionFactory createConnectionFactory() { + return PostgresTestSupport.createConnectionFactory(database); + } + + @Override + protected String getCreateLegosetStatement() { + return "CREATE TABLE legoset (" // + + " id bigint CONSTRAINT legoset_pk PRIMARY KEY," // + + " name varchar(255) NOT NULL," // + + " manual integer NULL" // + + ")"; + } + + @Override + protected String getCreateWithInsertOnlyStatement() { + return "CREATE TABLE with_insert_only (" // + + " id bigint CONSTRAINT with_insert_only_pk PRIMARY KEY," // + + " insert_only varchar(255) NULL" // + + ")"; + } +} diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/ReactiveUpsertOperationUnitTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/ReactiveUpsertOperationUnitTests.java new file mode 100644 index 0000000000..55a8c612c1 --- /dev/null +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/ReactiveUpsertOperationUnitTests.java @@ -0,0 +1,186 @@ +/* + * Copyright 2026-present 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.core; + +import static org.assertj.core.api.Assertions.*; + +import io.r2dbc.spi.test.MockResult; +import io.r2dbc.spi.test.MockRowMetadata; +import reactor.test.StepVerifier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.annotation.Id; +import org.springframework.data.r2dbc.dialect.PostgresDialect; +import org.springframework.data.r2dbc.mapping.R2dbcMappingContext; +import org.springframework.data.r2dbc.testing.StatementRecorder; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.r2dbc.core.Parameter; + +/** + * Unit tests for {@link ReactiveUpsertOperation}. + * + * @author Christoph Strobl + */ +public class ReactiveUpsertOperationUnitTests { + + private DatabaseClient client; + private R2dbcEntityTemplate entityTemplate; + private StatementRecorder recorder; + + @BeforeEach + void before() { + + recorder = StatementRecorder.newInstance(); + client = DatabaseClient.builder().connectionFactory(recorder) + .bindMarkers(PostgresDialect.INSTANCE.getBindMarkersFactory()).build(); + entityTemplate = new R2dbcEntityTemplate(client, new DefaultReactiveDataAccessStrategy(PostgresDialect.INSTANCE)); + ((R2dbcMappingContext) entityTemplate.getDataAccessStrategy().getConverter().getMappingContext()) + .setForceQuote(false); + } + + @Test // GH-493 + void shouldUpsert() { + + MockRowMetadata metadata = MockRowMetadata.builder().build(); + MockResult result = MockResult.builder().rowMetadata(metadata).rowsUpdated(1).build(); + + recorder.addStubbing(s -> s.startsWith("INSERT"), result); + + Person person = new Person(); + person.id = 42L; + person.setName("Walter"); + + entityTemplate.upsert(Person.class) // + .one(person) // + .as(StepVerifier::create) // + .consumeNextWith(actual -> { + assertThat(actual.id).isEqualTo(42L); + assertThat(actual.getName()).isEqualTo("Walter"); + }) // + .verifyComplete(); + + StatementRecorder.RecordedStatement statement = recorder.getCreatedStatement(s -> s.startsWith("INSERT")); + + assertThat(statement.getSql()).startsWith("INSERT INTO person"); + assertThat(statement.getSql()).contains("ON CONFLICT"); + assertThat(statement.getSql()).contains("DO UPDATE SET"); + assertThat(statement.getBindings()).hasSize(2) // + .containsEntry(0, Parameter.from(42L)) // + .containsEntry(1, Parameter.from("Walter")); + } + + @Test // GH-493 + void shouldUpsertInTable() { + + MockRowMetadata metadata = MockRowMetadata.builder().build(); + MockResult result = MockResult.builder().rowMetadata(metadata).rowsUpdated(1).build(); + + recorder.addStubbing(s -> s.startsWith("INSERT"), result); + + Person person = new Person(); + person.id = 42L; + person.setName("Walter"); + + entityTemplate.upsert(Person.class) // + .inTable("the_table") // + .one(person) // + .as(StepVerifier::create) // + .consumeNextWith(actual -> { + assertThat(actual.id).isEqualTo(42L); + }) // + .verifyComplete(); + + StatementRecorder.RecordedStatement statement = recorder.getCreatedStatement(s -> s.startsWith("INSERT")); + + assertThat(statement.getSql()).startsWith("INSERT INTO the_table"); + } + + @Test // GH-493 + void shouldUpsertViaTemplateMethod() { + + MockRowMetadata metadata = MockRowMetadata.builder().build(); + MockResult result = MockResult.builder().rowMetadata(metadata).rowsUpdated(1).build(); + + recorder.addStubbing(s -> s.startsWith("INSERT"), result); + + Person person = new Person(); + person.id = 42L; + person.setName("Walter"); + + entityTemplate.upsert(person) // + .as(StepVerifier::create) // + .consumeNextWith(actual -> { + assertThat(actual.id).isEqualTo(42L); + assertThat(actual.getName()).isEqualTo("Walter"); + }) // + .verifyComplete(); + + StatementRecorder.RecordedStatement statement = recorder.getCreatedStatement(s -> s.startsWith("INSERT")); + + assertThat(statement.getSql()).startsWith("INSERT INTO person"); + assertThat(statement.getSql()).contains("ON CONFLICT"); + } + + @Test // GH-493 + void upsertIncludesInsertOnlyColumns() { + + MockRowMetadata metadata = MockRowMetadata.builder().build(); + MockResult result = MockResult.builder().rowMetadata(metadata).rowsUpdated(1).build(); + + recorder.addStubbing(s -> s.startsWith("INSERT"), result); + + entityTemplate.upsert(Person.class) // + .one(new Person(42L, "Alfred", "insert this")) // + .as(StepVerifier::create) // + .expectNextCount(1) // + .verifyComplete(); + + StatementRecorder.RecordedStatement statement = recorder.getCreatedStatement(s -> s.startsWith("INSERT")); + + assertThat(statement.getSql()).startsWith("INSERT INTO person"); + assertThat(statement.getSql()).contains("THE_NAME"); + assertThat(statement.getSql()).contains("insert_only"); + } + + static class Person { + + @Id Long id; + + @Column("THE_NAME") String name; + + @org.springframework.data.relational.core.mapping.InsertOnlyProperty + String insertOnly; + + Person() {} + + Person(Long id, String name, String insertOnly) { + this.id = id; + this.name = name; + this.insertOnly = insertOnly; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + +} diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/SqlServerR2dbcEntityTemplateUpsertIntegrationTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/SqlServerR2dbcEntityTemplateUpsertIntegrationTests.java new file mode 100644 index 0000000000..8451d86435 --- /dev/null +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/SqlServerR2dbcEntityTemplateUpsertIntegrationTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026-present 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.core; + +import io.r2dbc.spi.ConnectionFactory; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.data.r2dbc.testing.ExternalDatabase; +import org.springframework.data.r2dbc.testing.SqlServerTestSupport; + +/** + * SQL Server-specific integration tests for {@link R2dbcEntityTemplate} upsert. + * + * @author Christoph Strobl + */ +public class SqlServerR2dbcEntityTemplateUpsertIntegrationTests + extends AbstractR2dbcEntityTemplateUpsertIntegrationTests { + + @RegisterExtension + public static final ExternalDatabase database = SqlServerTestSupport.database(); + + @Override + protected DataSource createDataSource() { + return SqlServerTestSupport.createDataSource(database); + } + + @Override + protected ConnectionFactory createConnectionFactory() { + return SqlServerTestSupport.createConnectionFactory(database); + } + + @Override + protected String getCreateLegosetStatement() { + return "CREATE TABLE legoset (" // + + " id bigint CONSTRAINT legoset_pk PRIMARY KEY," // + + " name varchar(255) NOT NULL," // + + " manual integer NULL" // + + ")"; + } + + @Override + protected String getCreateWithInsertOnlyStatement() { + return "CREATE TABLE with_insert_only (" // + + " id bigint CONSTRAINT with_insert_only_pk PRIMARY KEY," // + + " insert_only varchar(255) NULL" // + + ")"; + } +} diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/testing/StatementRecorder.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/testing/StatementRecorder.java index 8dfed09215..1a69f91bac 100644 --- a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/testing/StatementRecorder.java +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/testing/StatementRecorder.java @@ -25,6 +25,7 @@ import io.r2dbc.spi.Statement; import io.r2dbc.spi.TransactionDefinition; import io.r2dbc.spi.ValidationDepth; +import org.springframework.data.r2dbc.mapping.ParameterAdapter; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -306,12 +307,18 @@ public Statement add() { @Override public Statement bind(int index, Object o) { + if(o instanceof ParameterAdapter adapter) { + o = adapter.getValue(); + } this.bindings.put(index, Parameter.from(o)); return this; } @Override public Statement bind(String identifier, Object o) { + if(o instanceof ParameterAdapter adapter) { + o = adapter.getValue(); + } this.bindings.put(identifier, Parameter.from(o)); return this; } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsertBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsertBuilder.java index 2ee9d70e86..1c34294ce2 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsertBuilder.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsertBuilder.java @@ -89,6 +89,7 @@ public DefaultUpsertBuilder where(Condition condition) { public Upsert build() { Assert.state(this.table != null, "Table must not be null"); + Assert.state(this.where != null, "Where condition must not be null"); return new DefaultUpsert(this.table, this.assignments, this.where); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java index 09d4567d82..8ee0ba132c 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java @@ -28,6 +28,6 @@ public enum StandardSqlUpsertRenderContext implements UpsertRenderContext { @Override public UpsertStatementRenderer renderer() { - return new UpsertStatementRenderers.StandardSql(); + return UpsertStatementRenderer.standardSql(); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderer.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderer.java index f4e104423c..a69807ff1f 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderer.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderer.java @@ -16,10 +16,12 @@ package org.springframework.data.relational.core.sql.render; import java.util.List; +import java.util.Map; import java.util.function.BiFunction; import java.util.function.Function; import java.util.stream.Collector; +import org.jspecify.annotations.Nullable; import org.springframework.data.relational.core.sql.Aliased; import org.springframework.data.relational.core.sql.Column; import org.springframework.data.relational.core.sql.SqlIdentifier; @@ -83,8 +85,23 @@ interface UpsertRenderingContext { * @param renderContext active SQL render context * @return context passed to {@link UpsertStatementRenderer#render} */ - static UpsertRenderingContext of(RenderContext renderContext) { - return () -> renderContext; + static UpsertRenderingContext of(RenderContext renderContext, Function bindMarkerResolution) { + + return new UpsertRenderingContext() { + + @Override + public RenderContext renderContext() { + return renderContext; + } + + @Override + public CharSequence bindMarker(Column column, + BiFunction bindMarkerFn) { + + CharSequence maker = bindMarkerResolution.apply(column.getName()); + return bindMarkerFn.apply(columnName(column), maker); + } + }; } /** @return render context */ @@ -191,12 +208,15 @@ default CharSequence assignments(SqlIdentifier targetTableAlias, List co final class Columns { + private final Map bindings; private final List insertColumns; private final List conflictColumns; private final List updateColumns; - public Columns(List insertColumns, List conflictColumns) { + public Columns(List insertColumns, List conflictColumns, + Map bindings) { + this.bindings = bindings; this.insertColumns = insertColumns; this.conflictColumns = conflictColumns; this.updateColumns = insertColumns.stream() @@ -223,5 +243,9 @@ public List insertColumns() { public List conflictColumns() { return conflictColumns; } + + public @Nullable CharSequence binding(Column column) { + return bindings.get(column.getName()); + } } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java index 4538f4571c..d0bd0520b2 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java @@ -16,12 +16,18 @@ package org.springframework.data.relational.core.sql.render; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; import org.springframework.data.relational.core.sql.AssignValue; import org.springframework.data.relational.core.sql.Column; import org.springframework.data.relational.core.sql.Condition; +import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; import org.springframework.data.relational.core.sql.Visitable; import org.springframework.data.relational.core.sql.render.UpsertStatementRenderer.UpsertRenderingContext; @@ -39,7 +45,7 @@ public class UpsertStatementVisitor extends DelegatingVisitor implements PartRen private final StringBuilder builder = new StringBuilder(); private final RenderContext context; - private final List insertColumns = new ArrayList<>(); + private final Map insertColumns = new LinkedHashMap<>(); private final List conflictColumns = new ArrayList<>(5); private @Nullable Table table; @@ -66,7 +72,8 @@ public class UpsertStatementVisitor extends DelegatingVisitor implements PartRen } if (segment instanceof AssignValue assignValue) { - this.insertColumns.add(assignValue.getColumn()); + + this.insertColumns.put(assignValue.getColumn(), getBinding(assignValue.getValue(), context)); return Delegation.retain(); } @@ -76,14 +83,17 @@ public class UpsertStatementVisitor extends DelegatingVisitor implements PartRen @Override public Delegation doLeave(Visitable segment) { - if (segment instanceof org.springframework.data.relational.core.sql.Upsert) { + if (segment instanceof org.springframework.data.relational.core.sql.Upsert source) { Assert.state(table != null, "Upsert requires a table"); UpsertRenderContext upsertContext = context.getUpsertRenderContext(); + Map bindings = insertColumns.entrySet().stream() + .collect(Collectors.toMap(e -> e.getKey().getName(), Entry::getValue)); - UpsertRenderingContext renderingContext = UpsertRenderingContext.of(context); + UpsertRenderingContext renderingContext = UpsertRenderingContext.of(context, bindings::get); String sql = upsertContext.renderer().render(table, - new UpsertStatementRenderer.Columns(insertColumns, conflictColumns), renderingContext); + new UpsertStatementRenderer.Columns(new ArrayList<>(insertColumns.keySet()), conflictColumns, bindings), + renderingContext); builder.append(sql); return Delegation.leave(); @@ -92,6 +102,12 @@ public Delegation doLeave(Visitable segment) { return Delegation.retain(); } + CharSequence getBinding(Expression expression, RenderContext context) { + ExpressionVisitor expressionVisitor = new ExpressionVisitor(context); + expression.visit(expressionVisitor); + return expressionVisitor.getRenderedPart(); + } + @Override public CharSequence getRenderedPart() { return builder; diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java index 2254050ef3..c10fd997fa 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java @@ -18,11 +18,16 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.springframework.data.relational.core.dialect.AnsiDialect; import org.springframework.data.relational.core.dialect.RenderContextFactory; +import org.springframework.data.relational.core.sql.BindMarker; import org.springframework.data.relational.core.sql.Column; +import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; import org.springframework.data.relational.core.sql.render.UpsertStatementRenderer.UpsertRenderingContext; @@ -41,12 +46,14 @@ void mergeUpsertWithMultipleConflictColumnsBuildsFilterClauseWithAllColumns() { SqlIdentifier.unquoted("name")); List conflictColumns = List.of(SqlIdentifier.unquoted("tenant_id"), SqlIdentifier.unquoted("id")); - UpsertRenderingContext ctx = UpsertRenderingContext.of(new RenderContextFactory(AnsiDialect.INSTANCE).createRenderContext()); + + UpsertRenderingContext ctx = UpsertRenderingContext.of(new RenderContextFactory(AnsiDialect.INSTANCE).createRenderContext(), it -> ":%s".formatted(it.getReference())); List insertCols = insertColumns.stream().map(id -> Column.create(id, TABLE)).toList(); List conflictCols = conflictColumns.stream().map(id -> Column.create(id, TABLE)).toList(); + Map bindings = Map.of(insertCols.get(0).getName(), insertCols.get(0).getName().getReference()); String sql = StandardSqlUpsertRenderContext.INSTANCE.renderer().render(TABLE, - new UpsertStatementRenderer.Columns(insertCols, conflictCols), ctx); + new UpsertStatementRenderer.Columns(insertCols, conflictCols, bindings), ctx); assertThat(sql).contains("ON \"_t\".tenant_id = \"_s\".tenant_id AND \"_t\".id = \"_s\".id"); assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name"); diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java index 3a06275233..27e839217b 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.data.relational.core.dialect.AnsiDialect; @@ -27,7 +28,9 @@ import org.springframework.data.relational.core.dialect.PostgresDialect; import org.springframework.data.relational.core.dialect.RenderContextFactory; import org.springframework.data.relational.core.dialect.SqlServerDialect; +import org.springframework.data.relational.core.sql.BindMarker; import org.springframework.data.relational.core.sql.Column; +import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; import org.springframework.data.relational.core.sql.render.UpsertStatementRenderer.UpsertRenderingContext; @@ -45,10 +48,11 @@ class UpsertRenderContextUnitTests { private static String render(UpsertRenderContext upsertContext, Dialect dialect, Table table, List insertColumns, List conflictColumns) { - UpsertRenderingContext ctx = UpsertRenderingContext.of(new RenderContextFactory(dialect).createRenderContext()); + UpsertRenderingContext ctx = UpsertRenderingContext.of(new RenderContextFactory(dialect).createRenderContext(), it -> ":%s".formatted(it.getReference())); List insertCols = insertColumns.stream().map(id -> Column.create(id, table)).toList(); List conflictCols = conflictColumns.stream().map(id -> Column.create(id, table)).toList(); - return upsertContext.renderer().render(table, new UpsertStatementRenderer.Columns(insertCols, conflictCols), ctx); + Map bindings = Map.of(insertCols.get(0).getName(), ":" + insertCols.get(0).getName().getReference()); + return upsertContext.renderer().render(table, new UpsertStatementRenderer.Columns(insertCols, conflictCols, bindings), ctx); } @Test // GH-493 From 6c4a02810ca7a5a1e77275bbdc380b0746ab75f3 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Tue, 24 Mar 2026 14:58:12 +0100 Subject: [PATCH 08/12] Use dedicated DbAction for upsert --- .../jdbc/core/AggregateChangeExecutor.java | 3 + .../JdbcAggregateChangeExecutionContext.java | 24 +++- .../data/jdbc/core/JdbcAggregateTemplate.java | 13 +- ...gregateChangeExecutorContextUnitTests.java | 22 ++++ .../relational/core/conversion/DbAction.java | 115 +++++++++++------- .../RelationalEntityUpsertWriter.java | 41 +++++++ .../SaveBatchingAggregateChange.java | 6 + .../core/conversion/WritingContext.java | 13 ++ ...RelationalEntityUpsertWriterUnitTests.java | 92 ++++++++++++++ .../SaveBatchingAggregateChangeTest.java | 85 +++++++++++++ 10 files changed, 366 insertions(+), 48 deletions(-) create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityUpsertWriter.java create mode 100644 spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityUpsertWriterUnitTests.java diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/AggregateChangeExecutor.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/AggregateChangeExecutor.java index 6d28a39d85..61fdf72e0f 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/AggregateChangeExecutor.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/AggregateChangeExecutor.java @@ -30,6 +30,7 @@ * @author Myeonghyeon Lee * @author Chirag Tailor * @author Mikhail Polivakha + * @author Christoph Strobl * @since 2.0 */ class AggregateChangeExecutor { @@ -87,6 +88,8 @@ private void execute(DbAction action, JdbcAggregateChangeExecutionContext exe executionContext.executeInsert(insert); } else if (action instanceof DbAction.BatchInsert batchInsert) { executionContext.executeBatchInsert(batchInsert); + } else if (action instanceof DbAction.UpsertRoot upsertRoot) { + executionContext.executeUpsertRoot(upsertRoot); } else if (action instanceof DbAction.UpdateRoot updateRoot) { executionContext.executeUpdateRoot(updateRoot); } else if (action instanceof DbAction.Delete delete) { 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 6cd8fa2de1..a9edfe8f1a 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 @@ -60,6 +60,7 @@ * @author Myeonghyeon Lee * @author Chirag Tailor * @author Mark Paluch + * @author Christoph Strobl */ @SuppressWarnings("rawtypes") class JdbcAggregateChangeExecutionContext { @@ -120,6 +121,17 @@ void executeBatchInsert(DbAction.BatchInsert batchInsert) { } } + /** + * @param upsert + * @param + * @since 4.x + */ + void executeUpsertRoot(DbAction.UpsertRoot upsert) { + + accessStrategy.upsert(upsert.entity(), upsert.getEntityType()); + add(new DbActionExecutionResult(upsert)); + } + void executeUpdateRoot(DbAction.UpdateRoot update) { if (update.getPreviousVersion() != null) { @@ -276,7 +288,8 @@ List populateIdsIfNecessary() { Object newEntity = setIdAndCascadingProperties(action, result.getGeneratedId(), cascadingValues); - if (action instanceof DbAction.InsertRoot || action instanceof DbAction.UpdateRoot) { + if (action instanceof DbAction.InsertRoot || action instanceof DbAction.UpdateRoot + || action instanceof DbAction.UpsertRoot) { // noinspection unchecked roots.add((T) newEntity); } @@ -299,8 +312,9 @@ List populateIdsIfNecessary() { if (roots.isEmpty()) { throw new IllegalStateException( - String.format("Cannot retrieve the resulting instance(s) unless a %s or %s action was successfully executed", - DbAction.InsertRoot.class.getName(), DbAction.UpdateRoot.class.getName())); + String.format("Cannot retrieve the resulting instance(s) unless a %s, %s, or %s action was successfully executed", + DbAction.InsertRoot.class.getName(), DbAction.UpdateRoot.class.getName(), + DbAction.UpsertRoot.class.getName())); } Collections.reverse(roots); @@ -345,6 +359,10 @@ private PersistentPropertyPath getRelativePath(DbAction action, Persistent return pathToValue; } + if (action instanceof DbAction.UpsertRoot) { + return pathToValue; + } + throw new IllegalArgumentException(String.format("DbAction of type %s is not supported", action.getClass())); } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java index 984fc5a6a0..1c112b37df 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java @@ -53,6 +53,7 @@ import org.springframework.data.relational.core.conversion.RelationalEntityDeleteWriter; import org.springframework.data.relational.core.conversion.RelationalEntityInsertWriter; import org.springframework.data.relational.core.conversion.RelationalEntityUpdateWriter; +import org.springframework.data.relational.core.conversion.RelationalEntityUpsertWriter; import org.springframework.data.relational.core.conversion.RelationalEntityVersionUtils; import org.springframework.data.relational.core.conversion.RootAggregateChange; import org.springframework.data.relational.core.mapping.RelationalMappingContext; @@ -268,14 +269,11 @@ public List updateAll(Iterable instances) { } @Override - @SuppressWarnings("unchecked") public T upsert(T instance) { Assert.notNull(instance, "Aggregate instance must not be null"); - Class entityType = (Class) ClassUtils.getUserClass(instance); - accessStrategy.upsert(instance, entityType); - return instance; + return performSave(new EntityAndChangeCreator<>(instance, entity -> createUpsertChange(entity))); } private List saveInBatch(Iterable instances, Function> changes) { @@ -634,6 +632,13 @@ private AggregateChangeCreator changeCreatorSelectorForSave(T instance) { : entity -> createUpdateChange(prepareVersionForUpdate(entity)); } + private RootAggregateChange createUpsertChange(T instance) { + + RootAggregateChange aggregateChange = MutableAggregateChange.forSave(instance); + new RelationalEntityUpsertWriter(context).write(instance, aggregateChange); + return aggregateChange; + } + private RootAggregateChange createInsertChange(T instance) { RootAggregateChange aggregateChange = MutableAggregateChange.forSave(instance); 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 a7fac10cb3..0afb46db4a 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 @@ -47,6 +47,7 @@ * @author Jens Schauder * @author Umut Erturk * @author Chirag Tailor + * @author Christoph Strobl */ public class JdbcAggregateChangeExecutorContextUnitTests { @@ -94,6 +95,27 @@ public void idGenerationOfChild() { assertThat(content.id).isEqualTo(24L); } + @Test // GH-493 + public void idGenerationOfChildWhenDoingUpsert() { + + Content content = new Content(); + + root.id = 23L; + when(accessStrategy.upsert(root, DummyEntity.class)).thenReturn(1); + when(accessStrategy.insert(content, Content.class, createBackRef(23L), IdValueSource.GENERATED)).thenReturn(24L); + + DbAction.UpsertRoot rootInsert = new DbAction.UpsertRoot<>(root); + executionContext.executeUpsertRoot(rootInsert); + executionContext.executeInsert(createInsert(rootInsert, "content", content, null, IdValueSource.GENERATED)); + + List newRoots = executionContext.populateIdsIfNecessary(); + + assertThat(newRoots).containsExactly(root); + assertThat(root.id).isEqualTo(23L); + + assertThat(content.id).isEqualTo(24L); + } + @Test // DATAJDBC-453 public void idGenerationOfChildInList() { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DbAction.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DbAction.java index 3aa67cf7e5..783873063e 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DbAction.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DbAction.java @@ -39,6 +39,7 @@ * @author Tyler Van Gorder * @author Myeonghyeon Lee * @author Chirag Tailor + * @author Christoph Strobl */ public interface DbAction { @@ -49,35 +50,33 @@ public interface DbAction { * * @param type of the entity for which this represents a database interaction. */ - record Insert(T entity, PersistentPropertyPath propertyPath, - WithEntity dependingOn, - Map, Object> qualifiers, - IdValueSource idValueSource) implements WithDependingOn { - - public Insert(T entity, PersistentPropertyPath propertyPath, - WithEntity dependingOn, Map, Object> qualifiers, - IdValueSource idValueSource) { - - this.entity = entity; - this.propertyPath = propertyPath; - this.dependingOn = dependingOn; - this.qualifiers = Map.copyOf(qualifiers); - this.idValueSource = idValueSource; - } + record Insert(T entity, PersistentPropertyPath propertyPath, + WithEntity dependingOn, Map, Object> qualifiers, + IdValueSource idValueSource) implements WithDependingOn { - @Override - public Class getEntityType() { - return WithDependingOn.super.getEntityType(); - } + public Insert(T entity, PersistentPropertyPath propertyPath, + WithEntity dependingOn, Map, Object> qualifiers, + IdValueSource idValueSource) { + this.entity = entity; + this.propertyPath = propertyPath; + this.dependingOn = dependingOn; + this.qualifiers = Map.copyOf(qualifiers); + this.idValueSource = idValueSource; + } @Override - public String toString() { - return "Insert{" + "entity=" + entity + ", propertyPath=" + propertyPath + ", dependingOn=" + dependingOn - + ", idValueSource=" + idValueSource + ", qualifiers=" + qualifiers + '}'; - } + public Class getEntityType() { + return WithDependingOn.super.getEntityType(); } + @Override + public String toString() { + return "Insert{" + "entity=" + entity + ", propertyPath=" + propertyPath + ", dependingOn=" + dependingOn + + ", idValueSource=" + idValueSource + ", qualifiers=" + qualifiers + '}'; + } + } + /** * Represents an insert statement for the root of an aggregate. Upon a successful insert, the initial version and * generated ids are populated. @@ -114,6 +113,44 @@ public String toString() { } } + /** + * Represents an upsert statement for the aggregate root. The entity must carry a provided id since upsert requires a + * known identity to determine whether to insert or update. + * + * @param type of the entity for which this represents a database interaction. + * @author Christoph Strobl + * @since 4.x + */ + class UpsertRoot implements WithRoot { + + private T entity; + + public UpsertRoot(T entity) { + this.entity = entity; + } + + public T entity() { + return this.entity; + } + + @Override + public void setEntity(T entity) { + this.entity = entity; + } + + @Override + public IdValueSource idValueSource() { + return IdValueSource.PROVIDED; + } + + @Override + public String toString() { + + // TODO: toString is so inconsistent in here using the DbAction prefix :/ + return "DbAction.UpsertRoot{" + "entity=" + entity + '}'; + } + } + /** * Represents an update statement for the aggregate root. * @@ -159,14 +196,13 @@ public String toString() { * * @param type of the entity for which this represents a database interaction. */ - record Delete(Object rootId, - PersistentPropertyPath propertyPath) implements WithPropertyPath { - + record Delete(Object rootId, + PersistentPropertyPath propertyPath) implements WithPropertyPath { public String toString() { - return "DbAction.Delete(rootId=" + this.rootId() + ", propertyPath=" + this.propertyPath() + ")"; - } + return "DbAction.Delete(rootId=" + this.rootId() + ", propertyPath=" + this.propertyPath() + ")"; } + } /** * Represents a delete statement for an aggregate root when only the ID is known. @@ -177,15 +213,14 @@ public String toString() { * * @param type of the entity for which this represents a database interaction. */ - record DeleteRoot(Object id, Class getEntityType, @Nullable Number previousVersion) implements DbAction { - + record DeleteRoot(Object id, Class getEntityType, @Nullable Number previousVersion) implements DbAction { public String toString() { - return "DbAction.DeleteRoot(id=" + this.id() + ", entityType=" + this.getEntityType() + ", previousVersion=" - + this.previousVersion() + ")"; - } + return "DbAction.DeleteRoot(id=" + this.id() + ", entityType=" + this.getEntityType() + ", previousVersion=" + + this.previousVersion() + ")"; } + } /** * Represents a delete statement for all entities that are reachable via a given path from any aggregate root of a @@ -193,14 +228,13 @@ public String toString() { * * @param type of the entity for which this represents a database interaction. */ - record DeleteAll( + record DeleteAll( PersistentPropertyPath propertyPath) implements WithPropertyPath { - public String toString() { - return "DbAction.DeleteAll(propertyPath=" + this.propertyPath() + ")"; - } + return "DbAction.DeleteAll(propertyPath=" + this.propertyPath() + ")"; } + } /** * Represents a delete statement for all aggregate roots of a given type. @@ -211,13 +245,12 @@ public String toString() { * * @param type of the entity for which this represents a database interaction. */ - record DeleteAllRoot(Class getEntityType) implements DbAction { - + record DeleteAllRoot(Class getEntityType) implements DbAction { public String toString() { - return "DbAction.DeleteAllRoot(entityType=" + this.getEntityType() + ")"; - } + return "DbAction.DeleteAllRoot(entityType=" + this.getEntityType() + ")"; } + } /** * Represents an acquire lock statement for an aggregate root when only the ID is known. diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityUpsertWriter.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityUpsertWriter.java new file mode 100644 index 0000000000..60d921878b --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityUpsertWriter.java @@ -0,0 +1,41 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.relational.core.conversion; + +import org.springframework.data.convert.EntityWriter; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; + +/** + * Converts an aggregate represented by its root into a {@link RootAggregateChange} for an upsert operation. Always + * emits a {@link DbAction.UpsertRoot} as the root action regardless of whether the entity is new or existing, followed + * by delete and insert actions for any referenced entities. + * + * @author Christoph Strobl + * @since 4.x + */ +public class RelationalEntityUpsertWriter implements EntityWriter> { + + private final RelationalMappingContext context; + + public RelationalEntityUpsertWriter(RelationalMappingContext context) { + this.context = context; + } + + @Override + public void write(T root, RootAggregateChange aggregateChange) { + new WritingContext<>(context, root, aggregateChange).upsert(); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/SaveBatchingAggregateChange.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/SaveBatchingAggregateChange.java index e255d06a63..b3e683ea8b 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/SaveBatchingAggregateChange.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/SaveBatchingAggregateChange.java @@ -28,12 +28,14 @@ * ability for an optimized batch operation to be used. * * @author Chirag Tailor + * @author Christoph Strobl * @since 3.0 */ public class SaveBatchingAggregateChange implements BatchingAggregateChange> { private final Class entityType; private final List> rootActions = new ArrayList<>(); + /** * Holds a list of InsertRoot actions that are compatible with each other, in the sense, that they might be combined * into a single batch. @@ -78,6 +80,10 @@ public void add(RootAggregateChange aggregateChange) { if (action instanceof DbAction.UpdateRoot rootAction) { + combineBatchCandidatesIntoSingleBatchRootAction(); + rootActions.add(rootAction); + } else if (action instanceof DbAction.UpsertRoot rootAction) { + combineBatchCandidatesIntoSingleBatchRootAction(); rootActions.add(rootAction); } else if (action instanceof DbAction.InsertRoot rootAction) { 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 8e627c7ea6..e3abadc08c 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 @@ -40,6 +40,7 @@ * @author Mark Paluch * @author Myeonghyeon Lee * @author Chirag Tailor + * @author Christoph Strobl */ class WritingContext { @@ -104,6 +105,18 @@ void save() { } } + /** + * Leaves out the isNew check + * + * @since 4.x + */ + void upsert() { // TODO: how does that really go together with save? + + setRootAction(new DbAction.UpsertRoot<>(root)); + deleteReferenced().forEach(aggregateChange::addAction); + insertReferenced().forEach(aggregateChange::addAction); + } + private boolean isNew(Object o) { return context.getRequiredPersistentEntity(o.getClass()).isNew(o); } diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityUpsertWriterUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityUpsertWriterUnitTests.java new file mode 100644 index 0000000000..f04ca130cc --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityUpsertWriterUnitTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.relational.core.conversion; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +import java.util.ArrayList; +import java.util.List; + +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.relational.core.mapping.RelationalMappingContext; + +/** + * Unit tests for {@link RelationalEntityUpsertWriter}. + * + * @author Christoph Strobl + */ +@ExtendWith(MockitoExtension.class) +class RelationalEntityUpsertWriterUnitTests { + + static final long SOME_ENTITY_ID = 23L; + RelationalMappingContext context = new RelationalMappingContext(); + + @Test // GH-493 + void entityWithProvidedIdGetsConvertedToUpsertAndDeleteForReferencedEntities() { + + SingleReferenceEntity entity = new SingleReferenceEntity(SOME_ENTITY_ID, null, null); + RootAggregateChange aggregateChange = MutableAggregateChange.forSave(entity); + + new RelationalEntityUpsertWriter(context).write(entity, aggregateChange); + + assertThat(extractActions(aggregateChange)) // + .extracting(DbAction::getClass, DbAction::getEntityType, DbActionTestSupport::extractPath, + DbActionTestSupport::actualEntityType, DbActionTestSupport::isWithDependsOn) // + .containsExactly( // + tuple(DbAction.UpsertRoot.class, SingleReferenceEntity.class, "", SingleReferenceEntity.class, false), // + tuple(DbAction.Delete.class, Element.class, "other", null, false) // + ); + } + + @Test // GH-493 + void entityWithProvidedIdAndReferencedEntityGetsConvertedToUpsertWithDeleteAndInsert() { + + SingleReferenceEntity entity = new SingleReferenceEntity(SOME_ENTITY_ID, new Element(null), null); + RootAggregateChange aggregateChange = MutableAggregateChange.forSave(entity); + + new RelationalEntityUpsertWriter(context).write(entity, aggregateChange); + + assertThat(extractActions(aggregateChange)) // + .extracting(DbAction::getClass, DbAction::getEntityType, DbActionTestSupport::extractPath, + DbActionTestSupport::actualEntityType, DbActionTestSupport::isWithDependsOn) // + .containsExactly( // + tuple(DbAction.UpsertRoot.class, SingleReferenceEntity.class, "", SingleReferenceEntity.class, false), // + tuple(DbAction.Delete.class, Element.class, "other", null, false), // + tuple(DbAction.Insert.class, Element.class, "other", Element.class, true) // + ); + } + + private List> extractActions(MutableAggregateChange aggregateChange) { + + List> actions = new ArrayList<>(); + aggregateChange.forEachAction(actions::add); + return actions; + } + + record SingleReferenceEntity( + + @Id Long id, Element other, + // should not trigger own DbAction + String name) { + } + + record Element(@Id Long id) { + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/SaveBatchingAggregateChangeTest.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/SaveBatchingAggregateChangeTest.java index d51430b385..e45d0a9a15 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/SaveBatchingAggregateChangeTest.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/SaveBatchingAggregateChangeTest.java @@ -32,6 +32,7 @@ * Unit tests for {@link SaveBatchingAggregateChange}. * * @author Chirag Tailor + * @author Christoph Strobl */ class SaveBatchingAggregateChangeTest { @@ -47,6 +48,7 @@ void startsWithNoActions() { @Nested class RootActionsTests { + @Test // GH-537 void yieldsUpdateRoot() { @@ -132,6 +134,20 @@ void yieldsInsertRoot() { assertThat(extractActions(change)).containsExactly(rootInsert); } + @Test // GH-493 + void yieldsUpsertRoot() { + + Root root = new Root(1L, null); + DbAction.UpsertRoot rootUpsert = new DbAction.UpsertRoot<>(root); + RootAggregateChange aggregateChange = MutableAggregateChange.forSave(root); + aggregateChange.setRootAction(rootUpsert); + + BatchingAggregateChange> change = BatchingAggregateChange.forSave(Root.class); + change.add(aggregateChange); + + assertThat(extractActions(change)).containsExactly(rootUpsert); + } + @Test // GH-537 void yieldsSingleInsertRoot_followedByNonMatchingInsertRoot_asIndividualActions() { @@ -242,6 +258,75 @@ void yieldsPreviouslyYieldedInsertRoot_asBatchInsertRootAction_whenAdditionalMat } } + @Test // GH-493 + void yieldsUpsertRootBeforeDeleteActions() { + + Root root = new Root(1L, null); + DbAction.UpsertRoot rootUpsert = new DbAction.UpsertRoot<>(root); + RootAggregateChange aggregateChange = MutableAggregateChange.forSave(root); + aggregateChange.setRootAction(rootUpsert); + + DbAction.Delete intermediateDelete = new DbAction.Delete<>(1L, + context.getPersistentPropertyPath("intermediate", Root.class)); + aggregateChange.addAction(intermediateDelete); + + BatchingAggregateChange> change = BatchingAggregateChange.forSave(Root.class); + change.add(aggregateChange); + + assertThat(extractActions(change)).extracting(DbAction::getClass, DbAction::getEntityType).containsExactly( // + Tuple.tuple(DbAction.UpsertRoot.class, Root.class), // + Tuple.tuple(DbAction.Delete.class, Intermediate.class)); + } + + @Test // GH-493 + void yieldsUpsertRootBeforeInsertActions() { + + Root root = new Root(1L, null); + DbAction.UpsertRoot rootUpsert = new DbAction.UpsertRoot<>(root); + RootAggregateChange aggregateChange = MutableAggregateChange.forSave(root); + aggregateChange.setRootAction(rootUpsert); + + Intermediate intermediate = new Intermediate(null, "intermediate", null); + DbAction.Insert intermediateInsert = new DbAction.Insert<>(intermediate, + context.getPersistentPropertyPath("intermediate", Root.class), rootUpsert, emptyMap(), + IdValueSource.GENERATED); + aggregateChange.addAction(intermediateInsert); + + BatchingAggregateChange> change = BatchingAggregateChange.forSave(Root.class); + change.add(aggregateChange); + + assertThat(extractActions(change)).extracting(DbAction::getClass, DbAction::getEntityType).containsExactly( // + Tuple.tuple(DbAction.UpsertRoot.class, Root.class), // + Tuple.tuple(DbAction.BatchInsert.class, Intermediate.class)); + } + + @Test // GH-493 + void yieldsUpsertRootWithDeleteActionsBeforeInsertActions() { + + Root root = new Root(1L, null); + DbAction.UpsertRoot rootUpsert = new DbAction.UpsertRoot<>(root); + RootAggregateChange aggregateChange = MutableAggregateChange.forSave(root); + aggregateChange.setRootAction(rootUpsert); + + DbAction.Delete intermediateDelete = new DbAction.Delete<>(1L, + context.getPersistentPropertyPath("intermediate", Root.class)); + aggregateChange.addAction(intermediateDelete); + + Intermediate intermediate = new Intermediate(null, "intermediate", null); + DbAction.Insert intermediateInsert = new DbAction.Insert<>(intermediate, + context.getPersistentPropertyPath("intermediate", Root.class), rootUpsert, emptyMap(), + IdValueSource.GENERATED); + aggregateChange.addAction(intermediateInsert); + + BatchingAggregateChange> change = BatchingAggregateChange.forSave(Root.class); + change.add(aggregateChange); + + assertThat(extractActions(change)).extracting(DbAction::getClass, DbAction::getEntityType).containsExactly( // + Tuple.tuple(DbAction.UpsertRoot.class, Root.class), // + Tuple.tuple(DbAction.Delete.class, Intermediate.class), // + Tuple.tuple(DbAction.BatchInsert.class, Intermediate.class)); + } + @Test // GH-537 void yieldsRootActionsBeforeDeleteActions() { From 8560fcf56b9229ed349144b535c2ca2a444a7279 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Thu, 26 Mar 2026 11:22:14 +0100 Subject: [PATCH 09/12] reduce upsert API surface to limit possibilities to actual use case --- .../data/jdbc/core/convert/SqlGenerator.java | 12 +-- .../sql/render/UpsertRendererUnitTests.java | 73 ++++++--------- .../r2dbc/core/DefaultStatementMapper.java | 27 ++---- .../relational/core/sql/DefaultUpsert.java | 93 +++++++++++-------- .../core/sql/DefaultUpsertBuilder.java | 74 +++++++-------- .../relational/core/sql/StatementBuilder.java | 11 ++- .../data/relational/core/sql/Upsert.java | 36 ++++++- .../relational/core/sql/UpsertBuilder.java | 81 ++++++++-------- .../sql/render/ConflictColumnCollector.java | 56 ----------- .../sql/render/UpsertStatementVisitor.java | 64 +++++-------- .../data/relational/DependencyTests.java | 11 +++ .../core/sql/UpsertBuilderUnitTests.java | 37 ++++++-- ...andardSqlUpsertRenderContextUnitTests.java | 11 +-- .../render/UpsertRenderContextUnitTests.java | 11 ++- 14 files changed, 286 insertions(+), 311 deletions(-) delete mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConflictColumnCollector.java 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 769e7e9c9e..f67e861c64 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 @@ -450,18 +450,18 @@ private Upsert createUpsertSql(Set additionalColumns) { columnNamesForInsert.addAll(columns.getInsertableColumns()); columnNamesForInsert.addAll(additionalColumns); - List conflictColumns = getIdColumns().stream().map(Column::getName).toList(); + List idColumns = getIdColumns(); + List conflictColumns = idColumns.stream().map(Column::getName).toList(); columnNamesForInsert.addAll(conflictColumns); List assignments = columnNamesForInsert.stream() // .map(this::assignColumnValue) // .collect(Collectors.toList()); - return StatementBuilder.upsert() // - .table(table) // - .columnValue(assignments) // - .where(equalityIdWhereCondition()) // - .build(); + return StatementBuilder.upsert(table) // + .insert(assignments) // + .onConflict(idColumns) // + .update().build(); } /** diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java index cdd46b8562..017cb718be 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java @@ -36,108 +36,93 @@ class UpsertRendererUnitTests { void standardSqlUpsertUsesMerge() { Table table = SQL.table("my_table"); - Upsert upsert = StatementBuilder.upsert().table(table) - .columnValue(table.column("id").set(SQL.bindMarker("id")), table.column("name").set(SQL.bindMarker("name"))) - .where(table.column("id").isEqualTo(SQL.bindMarker("id"))).build(); + Upsert upsert = StatementBuilder.upsert(table) + .insert(table.column("id").set(SQL.bindMarker(":id")), table.column("name").set(SQL.bindMarker(":name"))) + .onConflict(table.column("id")).update().build(); - // Use a dialect that returns null for getUpsertRenderContext() var context = new RenderContextFactory(org.springframework.data.jdbc.core.convert.NonQuotingDialect.INSTANCE) .createRenderContext(); String sql = SqlRenderer.create(context).render(upsert); - assertThat(sql).startsWith("MERGE INTO my_table") // - .containsSubsequence("WHEN MATCHED THEN UPDATE SET", "WHEN NOT MATCHED THEN INSERT"); + assertThat(sql).isEqualToIgnoringWhitespace( + "MERGE INTO my_table \"_t\" USING (VALUES (:id, :name)) AS \"_s\" (id, name) ON _t.id = _s.id WHEN MATCHED THEN UPDATE SET _t.name = _s.name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (_s.id, _s.name)"); } @Test // GH-493 void postgresRendersInsertOnConflictDoUpdate() { Table table = SQL.table("my_table"); - Upsert upsert = StatementBuilder.upsert().table(table) - .columnValue(table.column("id").set(SQL.bindMarker(":id")), table.column("name").set(SQL.bindMarker(":name"))) - .where(table.column("id").isEqualTo(SQL.bindMarker(":id"))).build(); + Upsert upsert = StatementBuilder.upsert(table) + .insert(table.column("id").set(SQL.bindMarker(":id")), table.column("name").set(SQL.bindMarker(":name"))) + .onConflict(table.column("id")).update().build(); var context = new RenderContextFactory(org.springframework.data.jdbc.core.dialect.JdbcPostgresDialect.INSTANCE) .createRenderContext(); String sql = SqlRenderer.create(context).render(upsert); - assertThat(sql).startsWith("INSERT INTO my_table"); - assertThat(sql).contains("my_table"); - assertThat(sql).contains("id"); - assertThat(sql).contains("name"); - assertThat(sql).contains(":id"); - assertThat(sql).contains(":name"); - assertThat(sql).contains("ON CONFLICT ("); - assertThat(sql).contains("DO UPDATE SET"); - assertThat(sql).contains("EXCLUDED"); + assertThat(sql).isEqualToIgnoringWhitespace( + "INSERT INTO my_table (id, name) VALUES (:id, :name) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name"); } @Test // GH-493 void mySqlRendersOnDuplicateKeyUpdate() { Table table = SQL.table("my_table"); - Upsert upsert = StatementBuilder.upsert().table(table) - .columnValue(table.column("id").set(SQL.bindMarker("id")), table.column("name").set(SQL.bindMarker("name"))) - .where(table.column("id").isEqualTo(SQL.bindMarker("id"))).build(); + Upsert upsert = StatementBuilder.upsert(table) + .insert(table.column("id").set(SQL.bindMarker(":id")), table.column("name").set(SQL.bindMarker(":name"))) + .onConflict(table.column("id")).update().build(); var context = new RenderContextFactory(org.springframework.data.jdbc.core.dialect.JdbcMySqlDialect.INSTANCE) .createRenderContext(); String sql = SqlRenderer.create(context).render(upsert); - assertThat(sql).startsWith("INSERT INTO"); - assertThat(sql).contains("my_table"); - assertThat(sql).contains("ON DUPLICATE KEY UPDATE"); + assertThat(sql).isEqualToIgnoringWhitespace( + "INSERT INTO my_table (id, name) VALUES (:id, :name) ON DUPLICATE KEY UPDATE name = VALUES(name)"); } @Test // GH-493 void sqlServerRendersMergeWithSemicolon() { Table table = SQL.table("my_table"); - Upsert upsert = StatementBuilder.upsert().table(table) - .columnValue(table.column("id").set(SQL.bindMarker("id")), table.column("name").set(SQL.bindMarker("name"))) - .where(table.column("id").isEqualTo(SQL.bindMarker("id"))).build(); + Upsert upsert = StatementBuilder.upsert(table) + .insert(table.column("id").set(SQL.bindMarker(":id")), table.column("name").set(SQL.bindMarker(":name"))) + .onConflict(table.column("id")).update().build(); var context = new RenderContextFactory(org.springframework.data.jdbc.core.dialect.JdbcSqlServerDialect.INSTANCE) .createRenderContext(); String sql = SqlRenderer.create(context).render(upsert); - assertThat(sql).contains("MERGE INTO"); - assertThat(sql).contains("my_table"); - assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET"); - assertThat(sql).contains("WHEN NOT MATCHED THEN INSERT"); - assertThat(sql.trim()).endsWith(";"); + assertThat(sql).isEqualToIgnoringWhitespace( + "MERGE INTO my_table \"_t\" USING (VALUES (:id, :name)) AS \"_s\" (id, name) ON \"_t\".id = \"_s\".id WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name);"); } @Test // GH-493 void h2RendersMerge() { Table table = SQL.table("my_table"); - Upsert upsert = StatementBuilder.upsert().table(table) - .columnValue(table.column("id").set(SQL.bindMarker("id")), table.column("name").set(SQL.bindMarker("name"))) - .where(table.column("id").isEqualTo(SQL.bindMarker("id"))).build(); + Upsert upsert = StatementBuilder.upsert(table) + .insert(table.column("id").set(SQL.bindMarker(":id")), table.column("name").set(SQL.bindMarker(":name"))) + .onConflict(table.column("id")).update().build(); var context = new RenderContextFactory(org.springframework.data.jdbc.core.dialect.JdbcH2Dialect.INSTANCE) .createRenderContext(); String sql = SqlRenderer.create(context).render(upsert); - assertThat(sql).contains("MERGE INTO"); - assertThat(sql).contains("my_table"); - assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET"); - assertThat(sql).contains("WHEN NOT MATCHED THEN INSERT"); + assertThat(sql).isEqualToIgnoringWhitespace( + "MERGE INTO my_table \"_t\" USING (VALUES (:id, :name)) AS \"_s\" (id, name) ON \"_t\".id = \"_s\".id WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name)"); } @Test // GH-493 void oracleIdOnlyMergeOmitsWhenMatchedUpdate() { Table table = SQL.table("ent"); - Upsert upsert = StatementBuilder.upsert().table(table).columnValue(table.column("id").set(SQL.bindMarker("id"))) - .where(table.column("id").isEqualTo(SQL.bindMarker("id"))).build(); + Upsert upsert = StatementBuilder.upsert(table).insert(table.column("id").set(SQL.bindMarker(":id"))) + .onConflict(table.column("id")).update().build(); var context = new RenderContextFactory(JdbcOracleDialect.INSTANCE).createRenderContext(); String sql = SqlRenderer.create(context).render(upsert); - assertThat(sql).contains("MERGE INTO"); - assertThat(sql).contains("WHEN NOT MATCHED THEN INSERT"); - assertThat(sql).doesNotContain("WHEN MATCHED THEN UPDATE SET"); + assertThat(sql).isEqualToIgnoringWhitespace( + "MERGE INTO ent \"_t\" USING (SELECT :id AS id FROM DUAL) \"_s\" ON (\"_t\".id = \"_s\".id) WHEN NOT MATCHED THEN INSERT (id) VALUES (\"_s\".id)"); } } diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/DefaultStatementMapper.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/DefaultStatementMapper.java index 69dc058c07..3f77cbba80 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/DefaultStatementMapper.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/DefaultStatementMapper.java @@ -31,7 +31,7 @@ import org.springframework.data.relational.core.query.CriteriaDefinition; import org.springframework.data.relational.core.sql.AssignValue; import org.springframework.data.relational.core.sql.Assignment; -import org.springframework.data.relational.core.sql.Condition; +import org.springframework.data.relational.core.sql.Column; import org.springframework.data.relational.core.sql.Delete; import org.springframework.data.relational.core.sql.DeleteBuilder; import org.springframework.data.relational.core.sql.Expression; @@ -47,7 +47,6 @@ import org.springframework.data.relational.core.sql.Update; import org.springframework.data.relational.core.sql.UpdateBuilder; import org.springframework.data.relational.core.sql.Upsert; -import org.springframework.data.relational.core.sql.UpsertBuilder; import org.springframework.data.relational.core.sql.render.RenderContext; import org.springframework.data.relational.core.sql.render.SqlRenderer; import org.springframework.r2dbc.core.PreparedOperation; @@ -290,26 +289,16 @@ private PreparedOperation getMappedObject(UpsertSpec upsertSpec, BoundAssignments boundAssignments = this.updateMapper.getMappedObject(bindMarkers, upsertSpec.getAssignments(), table, entity); Bindings bindings = boundAssignments.getBindings(); - UpsertBuilder.UpsertWhere upsertBuilder = StatementBuilder.upsert().table(table) - .columnValue(boundAssignments.getAssignments()); - List conflictColumns = upsertSpec.getConflictColumns(); - Assert.notEmpty(conflictColumns, "Conflict columns must not be empty for upsert"); + List conflictColumnIds = upsertSpec.getConflictColumns(); + Assert.notEmpty(conflictColumnIds, "Conflict columns must not be empty for upsert"); - Condition result = null; + Column[] conflictColumns = conflictColumnIds.stream().map(table::column).toArray(Column[]::new); - for (SqlIdentifier conflictCol : conflictColumns) { - - Assignment assignment = boundAssignments.getAssignment(conflictCol); - if (assignment instanceof AssignValue av) { - Condition condition = table.column(conflictCol).isEqualTo(av.getValue()); - result = result == null ? condition : result.and(condition); - } - } - - Assert.state(result != null, "Conflict condition must not be null"); - Condition whereCondition = result; - Upsert upsert = upsertBuilder.where(whereCondition).build(); + Upsert upsert = StatementBuilder.upsert(table) // + .insert(boundAssignments.getAssignments()) // + .onConflict(conflictColumns) // + .update().build(); return new DefaultPreparedOperation<>(upsert, this.renderContext, bindings); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsert.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsert.java index 2035658dd2..ba087398b8 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsert.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsert.java @@ -16,12 +16,17 @@ package org.springframework.data.relational.core.sql; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; +import org.springframework.data.relational.core.dialect.InsertRenderContext; +import org.springframework.data.relational.core.sql.render.NamingStrategies; +import org.springframework.data.relational.core.sql.render.RenderContext; +import org.springframework.data.relational.core.sql.render.RenderNamingStrategy; +import org.springframework.data.relational.core.sql.render.SelectRenderContext; +import org.springframework.data.relational.core.sql.render.StandardSqlUpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertRenderContext; +import org.springframework.data.relational.core.sql.render.UpsertStatementVisitor; import org.springframework.util.Assert; -import org.springframework.util.StringUtils; /** * @author Christoph Strobl @@ -29,15 +34,30 @@ */ class DefaultUpsert implements Upsert { - protected final Table table; - protected final List assignments; - protected final Where where; + private final Table table; + private final List assignments; + private final List conflictColumns; - DefaultUpsert(Table table, List assignments, Condition where) { + DefaultUpsert(Table table, List assignments, List conflictColumns) { this.table = table; this.assignments = new ArrayList<>(assignments); - this.where = new Where(where); + this.conflictColumns = new ArrayList<>(conflictColumns); + } + + @Override + public Table getTable() { + return table; + } + + @Override + public List getAssignments() { + return assignments; + } + + @Override + public List getConflictColumns() { + return conflictColumns; } @Override @@ -48,7 +68,7 @@ public void visit(Visitor visitor) { visitor.enter(this); this.table.visit(visitor); - this.where.visit(visitor); + this.conflictColumns.forEach(col -> col.visit(visitor)); this.assignments.forEach(it -> it.visit(visitor)); visitor.leave(this); @@ -57,41 +77,36 @@ public void visit(Visitor visitor) { @Override public String toString() { - StringBuilder builder = new StringBuilder(); - builder.append("MERGE INTO ").append(table); - - String onCondition = this.where.toString().replaceFirst("^WHERE ", " ON "); - builder.append(onCondition); + UpsertStatementVisitor visitor = new UpsertStatementVisitor(TO_STRING_ANSI_RENDER_CONTEXT); + this.visit(visitor); + return visitor.getRenderedPart().toString(); + } - builder.append(" WHEN MATCHED THEN UPDATE SET ") - .append(StringUtils.collectionToDelimitedString(this.assignments, ", ")); + private static final RenderContext TO_STRING_ANSI_RENDER_CONTEXT = new RenderContext() { - builder.append(" WHEN NOT MATCHED THEN INSERT "); - Map assignmentMap = new LinkedHashMap<>(); + @Override + public RenderNamingStrategy getNamingStrategy() { + return NamingStrategies.asIs(); + } - for (int i = 0; i < assignments.size(); i++) { + @Override + public IdentifierProcessing getIdentifierProcessing() { + return IdentifierProcessing.NONE; + } - if (assignments.get(i) instanceof AssignValue av) { - assignmentMap.put(av.getColumn(), av.getValue()); - } else { - String[] parts = assignments.get(i).toString().split("="); - if (parts.length == 2) { - assignmentMap.put(new Column(parts[0].trim(), null), Expressions.just(parts[1].trim())); - } else { - assignmentMap.put(new Column("column-" + i, null), Expressions.just("?")); - } - } + @Override + public SelectRenderContext getSelectRenderContext() { + throw new UnsupportedOperationException(); } - if (!assignmentMap.isEmpty()) { - builder.append("("); - builder.append(StringUtils.collectionToDelimitedString(assignmentMap.keySet(), ", ")); - builder.append(") VALUES ("); - builder.append(StringUtils.collectionToDelimitedString(assignmentMap.values(), ", ")); - builder.append(")"); - } else { - builder.append("(...) VALUES (...)"); + + @Override + public InsertRenderContext getInsertRenderContext() { + throw new UnsupportedOperationException(); } - return builder.toString(); - } + @Override + public UpsertRenderContext getUpsertRenderContext() { + return StandardSqlUpsertRenderContext.INSTANCE; + } + }; } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsertBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsertBuilder.java index 1c34294ce2..ed94beadfc 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsertBuilder.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsertBuilder.java @@ -16,14 +16,15 @@ package org.springframework.data.relational.core.sql; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.List; -import org.jspecify.annotations.Nullable; -import org.springframework.data.relational.core.sql.UpsertBuilder.UpsertAssign; -import org.springframework.data.relational.core.sql.UpsertBuilder.UpsertWhere; +import org.springframework.data.relational.core.sql.UpsertBuilder.BuildUpsert; +import org.springframework.data.relational.core.sql.UpsertBuilder.UpsertInsert; +import org.springframework.data.relational.core.sql.UpsertBuilder.UpsertOnConflict; +import org.springframework.data.relational.core.sql.UpsertBuilder.UpsertResolution; import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; /** * Default {@link UpsertBuilder} implementation. @@ -31,66 +32,59 @@ * @author Christoph Strobl * @since 4.x */ -class DefaultUpsertBuilder implements UpsertBuilder, UpsertWhere, UpsertAssign { +class DefaultUpsertBuilder implements UpsertBuilder, UpsertInsert, UpsertOnConflict, UpsertResolution, BuildUpsert { - private @Nullable Table table; + private final Table table; private final List assignments = new ArrayList<>(); - private @Nullable Condition where; + private final List conflictColumns = new ArrayList<>(); - @Override - public DefaultUpsertBuilder table(Table table) { + DefaultUpsertBuilder(Table table) { Assert.notNull(table, "Table must not be null"); - this.table = table; - - return this; } @Override - public DefaultUpsertBuilder columnValue(Assignment assignment) { - - Assert.notNull(assignment, "Assignment must not be null"); - - this.assignments.add(assignment); + public UpsertOnConflict insert(Collection assignments) { + Assert.notNull(assignments, "Assignments must not be null"); + this.assignments.addAll(assignments); return this; } @Override - public DefaultUpsertBuilder columnValue(Assignment... assignments) { - - Assert.notNull(assignments, "Assignment must not be null"); - - return columnValue(Arrays.asList(assignments)); - } - - @Override - public DefaultUpsertBuilder columnValue(Collection assignments) { - - Assert.notNull(assignments, "Assignment must not be null"); - - this.assignments.addAll(assignments); + public UpsertResolution onConflict(Collection columns) { + Assert.notNull(columns, "Conflict columns must not be null"); + this.conflictColumns.addAll(columns); return this; } @Override - public DefaultUpsertBuilder where(Condition condition) { - - Assert.notNull(condition, "Condition must not be null"); - - this.where = condition; - + public BuildUpsert update() { return this; } @Override public Upsert build() { + validate(); + return new DefaultUpsert(this.table, this.assignments, this.conflictColumns); + } - Assert.state(this.table != null, "Table must not be null"); - Assert.state(this.where != null, "Where condition must not be null"); - - return new DefaultUpsert(this.table, this.assignments, this.where); + void validate() { + + Assert.state(!this.conflictColumns.isEmpty(), "Conflict columns must not be empty"); + for (Column column : this.conflictColumns) { + boolean present = this.assignments.stream().map(it -> { + if (it instanceof AssignValue av) { + return av.getColumn(); + } + return null; + }).anyMatch(it -> ObjectUtils.nullSafeEquals(column.getName().getReference(), + it != null ? it.getName().getReference() : null)); + if (!present) { + throw new IllegalStateException("No value for conflict column [%s]".formatted(column.getName().getReference())); + } + } } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/StatementBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/StatementBuilder.java index ee09fe23f2..ad20b1a11b 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/StatementBuilder.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/StatementBuilder.java @@ -22,6 +22,7 @@ import org.springframework.data.relational.core.sql.DeleteBuilder.DeleteWhere; import org.springframework.data.relational.core.sql.InsertBuilder.InsertIntoColumnsAndValues; import org.springframework.data.relational.core.sql.SelectBuilder.SelectAndFrom; +import org.springframework.data.relational.core.sql.UpsertBuilder.UpsertInsert; /** * Entrypoint to build SQL statements. @@ -120,8 +121,14 @@ public static UpdateBuilder update() { return Update.builder(); } - public static UpsertBuilder upsert() { - return Upsert.builder(); + /** + * Start building an upsert statement for the given {@link Table}. + * + * @param table the target table; must not be {@literal null}. + * @return the first builder step. + */ + public static UpsertInsert upsert(Table table) { + return Upsert.builder(table); } /** diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Upsert.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Upsert.java index 6ccfa0a58b..8c2a16ebee 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Upsert.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Upsert.java @@ -15,22 +15,48 @@ */ package org.springframework.data.relational.core.sql; +import java.util.List; import java.util.function.Consumer; +import org.springframework.data.relational.core.sql.UpsertBuilder.UpsertInsert; + /** + * An upsert (MERGE / INSERT … ON CONFLICT / INSERT … ON DUPLICATE KEY) statement. + * * @author Christoph Strobl * @since 4.x */ public interface Upsert extends Segment, Visitable { - static UpsertBuilder builder() { - return new DefaultUpsertBuilder(); + /** + * Start building an {@link Upsert} for the given {@link Table}. + * + * @param table the target table; must not be {@literal null}. + * @return the first builder step. + */ + static UpsertInsert builder(Table table) { + return new DefaultUpsertBuilder(table); } - static Upsert create(Consumer consumer) { - - DefaultUpsertBuilder builder = new DefaultUpsertBuilder(); + /** + * Create an {@link Upsert} for the given {@link Table}. + * + * @param table the target table; must not be {@literal null}. + * @param consumer the consumer to configure the upsert. + * @return the first builder step. + */ + static Upsert create(Table table, Consumer consumer) { + DefaultUpsertBuilder builder = new DefaultUpsertBuilder(table); consumer.accept(builder); return builder.build(); } + + /** @return the target table. */ + Table getTable(); + + /** @return the column-value assignments for the INSERT part. */ + List getAssignments(); + + /** @return the columns that identify a conflicting row. */ + List getConflictColumns(); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/UpsertBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/UpsertBuilder.java index 132ea16899..0fd3795da9 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/UpsertBuilder.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/UpsertBuilder.java @@ -16,9 +16,12 @@ package org.springframework.data.relational.core.sql; import java.util.Collection; +import java.util.List; /** - * Entry point to construct an {@link Update} statement. + * Fluent builder for {@link Upsert} statements. + *

+ * Usage: {@code StatementBuilder.upsert(table).insert(col.set(marker), …).onConflict(idCol).update()} * * @author Christoph Strobl * @since 4.x @@ -27,71 +30,69 @@ public interface UpsertBuilder { /** - * Configure the {@link Table} to which the update is applied. - * - * @param table the table to update. - * @return {@code this} {@link SelectBuilder}. + * Step for specifying the columns and values to insert. */ - UpsertAssign table(Table table); + interface UpsertInsert { - /** - * Interface exposing {@code SET} methods. - */ - interface UpsertAssign { + /** + * Specify the column-value assignments for the insert. + * + * @param assignments one or more {@link Assignment column assignments}; must not be {@literal null}. + * @return the next builder step. + */ + default UpsertOnConflict insert(Assignment... assignments) { + return insert(List.of(assignments)); + } /** - * Apply a {@link Assignment SET assignment}. + * Specify the column-value assignments for the insert. * - * @param assignment a single {@link Assignment column assignment}. - * @return {@code this} builder. - * @see Assignment + * @param assignments the {@link Assignment column assignments}; must not be {@literal null}. + * @return the next builder step. */ - UpsertWhere columnValue(Assignment assignment); + UpsertOnConflict insert(Collection assignments); + } + + /** + * Step for specifying the conflict target columns. + */ + interface UpsertOnConflict { /** - * Apply one or more {@link Assignment SET assignments}. + * Declare the columns whose uniqueness constraint drives conflict detection. * - * @param assignments the {@link Assignment column assignments}. - * @return {@code this} builder. - * @see Assignment + * @param columns one or more conflict columns; must not be {@literal null}. + * @return the terminal build step. */ - UpsertWhere columnValue(Assignment... assignments); + default UpsertResolution onConflict(Column... columns) { + return onConflict(List.of(columns)); + } /** - * Apply one or more {@link Assignment SET assignments}. + * Declare the columns whose uniqueness constraint drives conflict detection. * - * @param assignments the {@link Assignment column assignments}. - * @return {@code this} builder. - * @see Assignment + * @param columns the conflict columns; must not be {@literal null}. + * @return the terminal build step. */ - UpsertWhere columnValue(Collection assignments); + UpsertResolution onConflict(Collection columns); } /** - * Interface exposing {@code WHERE} methods. + * Step for specifying what to do when a conflict is detected. */ - interface UpsertWhere extends BuildUpsert { - - /** - * Apply a {@code WHERE} clause. - * - * @param condition the {@code WHERE} condition. - * @return {@code this} builder. - * @see Where - * @see Condition - */ - UpsertWhere where(Condition condition); + interface UpsertResolution { + BuildUpsert update(); } /** - * Interface exposing the {@link Update} build method. + * Terminal step that produces the {@link Upsert} statement. */ interface BuildUpsert { /** - * Build the {@link Update}. + * Build the immutable {@link Upsert} statement. * - * @return the build and immutable {@link Upsert} statement. + * @return the {@link Upsert} statement. */ Upsert build(); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConflictColumnCollector.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConflictColumnCollector.java deleted file mode 100644 index 306b1b4e8b..0000000000 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/ConflictColumnCollector.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2026-present 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 java.util.ArrayList; -import java.util.List; - -import org.springframework.data.relational.core.sql.Column; -import org.springframework.data.relational.core.sql.Comparison; -import org.springframework.data.relational.core.sql.Condition; -import org.springframework.data.relational.core.sql.MultipleCondition; -import org.springframework.data.relational.core.sql.Visitable; -import org.springframework.data.relational.core.sql.Visitor; - -/** - * Collects conflict columns from a {@link Condition} by traversing equality comparisons. For {@link Comparison} with - * {@code =} and a {@link Column} on the left, the column name is collected. For {@link MultipleCondition} (e.g. AND), - * recurses into child conditions. - * - * @since 4.x - */ -final class ConflictColumnCollector implements Visitor { - - private final List conflictColumns = new ArrayList<>(); - - @Override - public void enter(Visitable segment) { - - if (segment instanceof Comparison comparison && comparison.getLeft() instanceof Column column) { - conflictColumns.add(column); - } - - if (segment instanceof MultipleCondition multiple) { - for (Condition condition : multiple.getConditions()) { - condition.visit(this); - } - } - } - - List getConflictColumns() { - return conflictColumns; - } -} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java index d0bd0520b2..abf4114369 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java @@ -16,27 +16,25 @@ package org.springframework.data.relational.core.sql.render; import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import java.util.Map.Entry; -import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; import org.springframework.data.relational.core.sql.AssignValue; import org.springframework.data.relational.core.sql.Column; -import org.springframework.data.relational.core.sql.Condition; import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.SqlIdentifier; -import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.Upsert; import org.springframework.data.relational.core.sql.Visitable; import org.springframework.data.relational.core.sql.render.UpsertStatementRenderer.UpsertRenderingContext; import org.springframework.util.Assert; /** - * {@link PartRenderer} for {@link org.springframework.data.relational.core.sql.Upsert} statements. Traverses the Upsert - * AST (table, where/conflict condition, assignments), collects insert and conflict columns, and delegates - * dialect-specific rendering via {@link UpsertRenderContext#renderer()}. + * {@link PartRenderer} for {@link Upsert} statements. Uses the {@link Upsert} interface accessors directly to obtain + * the insert assignments and conflict columns, then delegates dialect-specific rendering to + * {@link UpsertRenderContext#renderer()}. * * @author Christoph Strobl * @since 4.x @@ -45,12 +43,8 @@ public class UpsertStatementVisitor extends DelegatingVisitor implements PartRen private final StringBuilder builder = new StringBuilder(); private final RenderContext context; - private final Map insertColumns = new LinkedHashMap<>(); - private final List conflictColumns = new ArrayList<>(5); - private @Nullable Table table; - - UpsertStatementVisitor(RenderContext context) { + public UpsertStatementVisitor(RenderContext context) { Assert.notNull(context, "RenderContext must not be null"); this.context = context; @@ -58,42 +52,33 @@ public class UpsertStatementVisitor extends DelegatingVisitor implements PartRen @Override public @Nullable Delegation doEnter(Visitable segment) { - - if (segment instanceof Table t) { - this.table = t; - return Delegation.retain(); - } - - if (segment instanceof Condition condition) { - ConflictColumnCollector collector = new ConflictColumnCollector(); - condition.visit(collector); - this.conflictColumns.addAll(collector.getConflictColumns()); - return Delegation.retain(); - } - - if (segment instanceof AssignValue assignValue) { - - this.insertColumns.put(assignValue.getColumn(), getBinding(assignValue.getValue(), context)); - return Delegation.retain(); - } - return Delegation.retain(); } @Override public Delegation doLeave(Visitable segment) { - if (segment instanceof org.springframework.data.relational.core.sql.Upsert source) { + if (segment instanceof Upsert source) { - Assert.state(table != null, "Upsert requires a table"); - UpsertRenderContext upsertContext = context.getUpsertRenderContext(); - Map bindings = insertColumns.entrySet().stream() - .collect(Collectors.toMap(e -> e.getKey().getName(), Entry::getValue)); + // TODO: this is highly inefficient - maybe we should just work with sqlidentifiers + Map insertColumns = new LinkedHashMap<>(); + for (var assignment : source.getAssignments()) { + if (assignment instanceof AssignValue av) { + insertColumns.put(av.getColumn(), getBinding(av.getValue(), context)); + } + } + Map bindings = new HashMap<>(insertColumns.size()); + for (Entry e : insertColumns.entrySet()) { + if (bindings.put(e.getKey().getName(), e.getValue()) != null) { + throw new IllegalStateException("Duplicate key"); + } + } + + UpsertRenderContext upsertContext = context.getUpsertRenderContext(); UpsertRenderingContext renderingContext = UpsertRenderingContext.of(context, bindings::get); - String sql = upsertContext.renderer().render(table, - new UpsertStatementRenderer.Columns(new ArrayList<>(insertColumns.keySet()), conflictColumns, bindings), - renderingContext); + String sql = upsertContext.renderer().render(source.getTable(), new UpsertStatementRenderer.Columns( + new ArrayList<>(insertColumns.keySet()), source.getConflictColumns(), bindings), renderingContext); builder.append(sql); return Delegation.leave(); @@ -103,6 +88,7 @@ public Delegation doLeave(Visitable segment) { } CharSequence getBinding(Expression expression, RenderContext context) { + ExpressionVisitor expressionVisitor = new ExpressionVisitor(context); expression.visit(expressionVisitor); return expressionVisitor.getRenderedPart(); diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java index 08ff167d86..40d14b8068 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java @@ -60,6 +60,7 @@ void cycleFree() { .that(ignore(OracleUpsertRenderContext.class)) // .that(ignore(SqlServerUpsertRenderContext.class)) // .that(ignore(StandardSqlUpsertRenderContext.class)) // + .that(ignore("org.springframework.data.relational.core.sql.DefaultUpsert")) // .that(ignore(RenderContextFactory.class)); ArchRule rule = SlicesRuleDefinition.slices() // @@ -130,6 +131,16 @@ public boolean test(JavaClass input) { }; } + private DescribedPredicate ignore(String type) { + + return new DescribedPredicate<>("ignored class " + type) { + @Override + public boolean test(JavaClass input) { + return !input.getFullName().startsWith(type); + } + }; + } + private DescribedPredicate ignorePackage(String type) { return new DescribedPredicate<>("ignored class " + type) { diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/UpsertBuilderUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/UpsertBuilderUnitTests.java index 2ef235795a..9a31093cee 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/UpsertBuilderUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/UpsertBuilderUnitTests.java @@ -15,9 +15,11 @@ */ package org.springframework.data.relational.core.sql; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; import org.junit.jupiter.api.Test; +import org.springframework.data.relational.core.sql.UpsertBuilder.BuildUpsert; /** * @author Christoph Strobl @@ -25,22 +27,39 @@ public class UpsertBuilderUnitTests { @Test // GH-493 - public void toStringShouldRenderPseudoMergeStatement() { + void buildErrorsWhenConflictColumnsNotPartOfInsert() { Table table = SQL.table("users"); Column idColumn = table.column("id"); - Column usernameColumn = table.column("username"); + Column usernameColumn = table.column("name"); - Upsert update = StatementBuilder.upsert().table(table).columnValue(usernameColumn.set(SQL.bindMarker())) - .where(idColumn.isEqualTo(Expressions.just("id-1"))).build(); + BuildUpsert builder = StatementBuilder.upsert(table).insert(usernameColumn.set(SQL.bindMarker())) + .onConflict(idColumn).update(); - String mergeStatement = update.toString(); + assertThatExceptionOfType(IllegalStateException.class).isThrownBy(builder::build); + } + + @Test // GH-493 + public void toStringShouldRenderAnsiMergeStatement() { + + Table table = SQL.table("users"); + Column idColumn = table.column("id"); + Column usernameColumn = table.column("name"); + + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.bindMarker()), usernameColumn.set(SQL.bindMarker())).onConflict(idColumn).update() + .build(); + + String mergeStatement = upsert.toString(); - assertThat(mergeStatement).startsWith("MERGE INTO users").containsSubsequence( // - "ON users.id = id-1", // + assertThat(mergeStatement).startsWith("MERGE INTO users \"_t\"").containsSubsequence( // + "USING (VALUES (?, ?)) AS \"_s\" (id, name)", // + "ON _t.id = _s.id", // "WHEN MATCHED THEN UPDATE SET", // - "users.username = ?", // - "WHEN NOT MATCHED THEN INSERT (users.username) VALUES (?)"); + "_t.name = _s.name", // + "WHEN NOT MATCHED THEN INSERT (id, name) VALUES (_s.id, _s.name)"); } + + } diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java index c10fd997fa..05ca42108c 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java @@ -19,15 +19,11 @@ import java.util.List; import java.util.Map; -import java.util.Map.Entry; -import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.springframework.data.relational.core.dialect.AnsiDialect; import org.springframework.data.relational.core.dialect.RenderContextFactory; -import org.springframework.data.relational.core.sql.BindMarker; import org.springframework.data.relational.core.sql.Column; -import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; import org.springframework.data.relational.core.sql.render.UpsertStatementRenderer.UpsertRenderingContext; @@ -46,11 +42,12 @@ void mergeUpsertWithMultipleConflictColumnsBuildsFilterClauseWithAllColumns() { SqlIdentifier.unquoted("name")); List conflictColumns = List.of(SqlIdentifier.unquoted("tenant_id"), SqlIdentifier.unquoted("id")); - - UpsertRenderingContext ctx = UpsertRenderingContext.of(new RenderContextFactory(AnsiDialect.INSTANCE).createRenderContext(), it -> ":%s".formatted(it.getReference())); + UpsertRenderingContext ctx = UpsertRenderingContext.of( + new RenderContextFactory(AnsiDialect.INSTANCE).createRenderContext(), it -> ":%s".formatted(it.getReference())); List insertCols = insertColumns.stream().map(id -> Column.create(id, TABLE)).toList(); List conflictCols = conflictColumns.stream().map(id -> Column.create(id, TABLE)).toList(); - Map bindings = Map.of(insertCols.get(0).getName(), insertCols.get(0).getName().getReference()); + Map bindings = Map.of(insertCols.get(0).getName(), + insertCols.get(0).getName().getReference()); String sql = StandardSqlUpsertRenderContext.INSTANCE.renderer().render(TABLE, new UpsertStatementRenderer.Columns(insertCols, conflictCols, bindings), ctx); diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java index 27e839217b..696447fd8f 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java @@ -28,9 +28,7 @@ import org.springframework.data.relational.core.dialect.PostgresDialect; import org.springframework.data.relational.core.dialect.RenderContextFactory; import org.springframework.data.relational.core.dialect.SqlServerDialect; -import org.springframework.data.relational.core.sql.BindMarker; import org.springframework.data.relational.core.sql.Column; -import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; import org.springframework.data.relational.core.sql.render.UpsertStatementRenderer.UpsertRenderingContext; @@ -48,11 +46,14 @@ class UpsertRenderContextUnitTests { private static String render(UpsertRenderContext upsertContext, Dialect dialect, Table table, List insertColumns, List conflictColumns) { - UpsertRenderingContext ctx = UpsertRenderingContext.of(new RenderContextFactory(dialect).createRenderContext(), it -> ":%s".formatted(it.getReference())); + UpsertRenderingContext ctx = UpsertRenderingContext.of(new RenderContextFactory(dialect).createRenderContext(), + it -> ":%s".formatted(it.getReference())); List insertCols = insertColumns.stream().map(id -> Column.create(id, table)).toList(); List conflictCols = conflictColumns.stream().map(id -> Column.create(id, table)).toList(); - Map bindings = Map.of(insertCols.get(0).getName(), ":" + insertCols.get(0).getName().getReference()); - return upsertContext.renderer().render(table, new UpsertStatementRenderer.Columns(insertCols, conflictCols, bindings), ctx); + Map bindings = Map.of(insertCols.get(0).getName(), + ":" + insertCols.get(0).getName().getReference()); + return upsertContext.renderer().render(table, + new UpsertStatementRenderer.Columns(insertCols, conflictCols, bindings), ctx); } @Test // GH-493 From f0068afeecf6a6b250f0b13c353e5f3dcde6b9ce Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Thu, 26 Mar 2026 11:27:47 +0100 Subject: [PATCH 10/12] Hacking - Insert / Upsert / Insert not sure which fits better --- .../core/sql/DefaultInsertBuilder.java | 13 ++++++++++ .../relational/core/sql/InsertBuilder.java | 8 ++++++- .../data/relational/core/sql/Upsert.java | 2 +- .../core/sql/UpsertBuilderUnitTests.java | 24 ++++++++++++++++++- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultInsertBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultInsertBuilder.java index d714d0a97f..be014f33ca 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultInsertBuilder.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultInsertBuilder.java @@ -21,6 +21,8 @@ import java.util.List; import org.jspecify.annotations.Nullable; +import org.springframework.data.relational.core.sql.InsertBuilder.UpsertToggle; +import org.springframework.data.relational.core.sql.UpsertBuilder.UpsertResolution; import org.springframework.util.Assert; /** @@ -101,6 +103,17 @@ public InsertValuesWithBuild values(Collection values) { return this; } + @Override + @SuppressWarnings("NullAway") + public UpsertResolution onConflict(Column... columns) { + + List assignments = new ArrayList<>(this.columns.size()); + for(int i = 0; i < this.columns.size(); i++) { + assignments.add(Assignments.value(this.columns.get(i), this.values.get(i))); + } + return new DefaultUpsertBuilder(this.into).insert(assignments).onConflict(columns); + } + @Override public Insert build() { return new DefaultInsert(this.into, this.columns, this.values); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/InsertBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/InsertBuilder.java index a2b5b05ddc..437de84e21 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/InsertBuilder.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/InsertBuilder.java @@ -17,6 +17,8 @@ import java.util.Collection; +import org.springframework.data.relational.core.sql.UpsertBuilder.UpsertResolution; + /** * Entry point to construct an {@link Insert} statement. * @@ -187,10 +189,14 @@ interface InsertValues { InsertValuesWithBuild values(Collection values); } + interface UpsertToggle { // TODO: do we need/want this? should we keep upsert or enhance insert? + UpsertResolution onConflict(Column... columns); + } + /** * Interface exposing the {@link Insert} build method. */ - interface BuildInsert { + interface BuildInsert extends UpsertToggle { /** * Build the {@link Insert} statement. diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Upsert.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Upsert.java index 8c2a16ebee..06443febcc 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Upsert.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Upsert.java @@ -26,7 +26,7 @@ * @author Christoph Strobl * @since 4.x */ -public interface Upsert extends Segment, Visitable { +public interface Upsert extends Segment, Visitable { // TODO: should we rename this to Merge? /** * Start building an {@link Upsert} for the given {@link Table}. diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/UpsertBuilderUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/UpsertBuilderUnitTests.java index 9a31093cee..8484bbfbe5 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/UpsertBuilderUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/UpsertBuilderUnitTests.java @@ -34,7 +34,7 @@ void buildErrorsWhenConflictColumnsNotPartOfInsert() { Column usernameColumn = table.column("name"); BuildUpsert builder = StatementBuilder.upsert(table).insert(usernameColumn.set(SQL.bindMarker())) - .onConflict(idColumn).update(); + .onConflict(idColumn).update(); assertThatExceptionOfType(IllegalStateException.class).isThrownBy(builder::build); } @@ -60,6 +60,28 @@ public void toStringShouldRenderAnsiMergeStatement() { "WHEN NOT MATCHED THEN INSERT (id, name) VALUES (_s.id, _s.name)"); } + @Test // GH-493 + public void fromInsertToUpsert() { + + Table table = SQL.table("users"); + Column idColumn = table.column("id"); + Column usernameColumn = table.column("name"); + + Upsert upsert = StatementBuilder.insert().into(table) // + .columns(idColumn, usernameColumn) // + .values(SQL.bindMarker(), SQL.literalOf("chris")) // + .onConflict(idColumn) // + .update() // + .build(); + String mergeStatement = upsert.toString(); + + assertThat(mergeStatement).startsWith("MERGE INTO users \"_t\"").containsSubsequence( // + "USING (VALUES (?, 'chris')) AS \"_s\" (id, name)", // + "ON _t.id = _s.id", // + "WHEN MATCHED THEN UPDATE SET", // + "_t.name = _s.name", // + "WHEN NOT MATCHED THEN INSERT (id, name) VALUES (_s.id, _s.name)"); + } } From 71f8ad6f942ff3f431cb02e600536a7dbebeffca Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Thu, 26 Mar 2026 12:53:00 +0100 Subject: [PATCH 11/12] suppress warnings and fix nullability issues --- .../springframework/data/jdbc/core/convert/Association.java | 5 +++++ .../data/jdbc/core/convert/SqlParametersFactory.java | 2 +- .../core/sql/render/UpsertRenderContextUnitTests.java | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Association.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Association.java index 4a59889f7c..5f7538a8a2 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Association.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/Association.java @@ -91,6 +91,11 @@ public static Association from(RelationalPersistentProperty property, JdbcConver } private static boolean hasMultipleColumns(@Nullable RelationalPersistentEntity identifierEntity) { + + if( identifierEntity == null ) { + return false; + } + Iterator iterator = identifierEntity.iterator(); if (iterator.hasNext()) { iterator.next(); 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 e273498e25..bbedf27ba1 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 @@ -245,7 +245,7 @@ private ParameterSourceHolder getParameterSource(@Nullable S instance, return holder; } - private void populateParameterSource(Object instance, RelationalPersistentEntity persistentEntity, String prefix, + private void populateParameterSource(@Nullable Object instance, RelationalPersistentEntity persistentEntity, String prefix, Predicate skipProperty, ParameterSourceHolder holder) { PersistentPropertyAccessor propertyAccessor = instance != null ? persistentEntity.getPropertyAccessor(instance) diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java index 696447fd8f..e23d9b30cc 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java @@ -36,6 +36,7 @@ /** * Unit tests for {@link UpsertRenderContext} implementations via {@link UpsertStatementRenderer#render}. */ +@SuppressWarnings("deprecation") class UpsertRenderContextUnitTests { private static final Table TABLE = Table.create(SqlIdentifier.unquoted("my_table")); From 5a341d574af7d2f05bda7b7837d5c06694e6173b Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Thu, 26 Mar 2026 14:38:55 +0100 Subject: [PATCH 12/12] update tests --- .../relational/core/sql/DefaultUpsert.java | 2 +- .../core/sql/render/SqlRenderer.java | 10 ++ .../sql/render/UpsertStatementRenderers.java | 36 ++-- .../HsqlDbDialectRenderingUnitTests.java | 82 +++++++++ .../MySqlDialectRenderingUnitTests.java | 55 +++++- .../OracleDialectRenderingUnitTests.java | 82 +++++++++ .../PostgresDialectRenderingUnitTests.java | 81 +++++++-- .../SqlServerDialectRenderingUnitTests.java | 82 +++++++-- .../render/UpsertRenderContextUnitTests.java | 169 ------------------ 9 files changed, 376 insertions(+), 223 deletions(-) create mode 100644 spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/HsqlDbDialectRenderingUnitTests.java create mode 100644 spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/OracleDialectRenderingUnitTests.java delete mode 100644 spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsert.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsert.java index ba087398b8..23d716f940 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsert.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsert.java @@ -75,7 +75,7 @@ public void visit(Visitor visitor) { } @Override - public String toString() { + public String toString() { // TODO: this method vs. SqlRenderer.toString(upsert); UpsertStatementVisitor visitor = new UpsertStatementVisitor(TO_STRING_ANSI_RENDER_CONTEXT); this.visit(visitor); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlRenderer.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlRenderer.java index d143bbd570..1d0b4b7a9f 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlRenderer.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlRenderer.java @@ -91,6 +91,16 @@ public static String toString(Update update) { return create().render(update); } + /** + * Renders a {@link Upsert} statement into its Standard SQL representation. + * + * @param update must not be {@literal null}. + * @return the rendered statement. + */ + public static String toString(Upsert update) { + return create().render(update); + } + /** * Renders a {@link Delete} statement into its SQL representation. * diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderers.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderers.java index bd92401e92..ae3d4c7e61 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderers.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderers.java @@ -107,15 +107,6 @@ public String render(Table table, Columns columns, UpsertRenderingContext ctx) { CharSequence conflictColumnNames = ctx.columnNames(columns.conflictColumns(), Collectors.joining(", ")); CharSequence bindMarkers = ctx.bindMarkers(columns.insertColumns(), Collectors.joining(", ")); - List updateColumns = columns.updateColumns(); - - if (updateColumns.isEmpty()) { - updateColumns = columns.conflictColumns(); - } - - CharSequence setValues = ctx.assignments(SqlIdentifier.EMPTY, updateColumns, SqlIdentifier.EMPTY, - "EXCLUDED.%s"::formatted, Collectors.joining(", ")); - if (columns.updateColumns().isEmpty()) { return "INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO NOTHING".formatted(// tableName, // @@ -124,6 +115,9 @@ public String render(Table table, Columns columns, UpsertRenderingContext ctx) { conflictColumnNames); } + CharSequence setValues = ctx.assignments(SqlIdentifier.EMPTY, columns.updateColumns(), SqlIdentifier.EMPTY, + "EXCLUDED.%s"::formatted, Collectors.joining(", ")); + return "INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s".formatted(// tableName, // insertColumnNames, // @@ -134,7 +128,9 @@ public String render(Table table, Columns columns, UpsertRenderingContext ctx) { } } - /** MySQL / MariaDB {@code INSERT ... ON DUPLICATE KEY UPDATE}. */ + /** + * MySQL / MariaDB {@code INSERT ... ON DUPLICATE KEY UPDATE}. + */ static class MySql implements UpsertStatementRenderer { static final MySql INSTANCE = new MySql(); @@ -149,11 +145,7 @@ public String render(Table table, Columns columns, UpsertRenderingContext ctx) { CharSequence columnNames = ctx.columnNames(columns.insertColumns(), Collectors.joining(", ")); CharSequence bindMarkers = ctx.bindMarkers(columns.insertColumns(), Collectors.joining(", ")); - List updateColumns = columns.updateColumns(); - - if (updateColumns.isEmpty()) { - updateColumns = columns.conflictColumns(); - } + List updateColumns = columnsToUpdate(columns); CharSequence setValues = ctx.assignments(SqlIdentifier.EMPTY, updateColumns, SqlIdentifier.EMPTY, "VALUES(%s)"::formatted, Collectors.joining(", ")); @@ -164,6 +156,20 @@ public String render(Table table, Columns columns, UpsertRenderingContext ctx) { bindMarkers, // setValues); } + + private static List columnsToUpdate(Columns columns) { + + if (!columns.updateColumns().isEmpty()) { + return columns.updateColumns(); + } + + /* MySQL requires at least one column to be updated. + * and since all columns are conflicting we can pick any + * + * Note to future self: We cannot use INSERT IGNORE here, as it would suppress data validation errors. + */ + return columns.conflictColumns().subList(0, 1); + } } /** Oracle {@code MERGE} with {@code SELECT ... FROM DUAL} as source. */ diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/HsqlDbDialectRenderingUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/HsqlDbDialectRenderingUnitTests.java new file mode 100644 index 0000000000..272715d83d --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/HsqlDbDialectRenderingUnitTests.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026-present 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.dialect; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.data.relational.core.sql.Column; +import org.springframework.data.relational.core.sql.SQL; +import org.springframework.data.relational.core.sql.StatementBuilder; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.Upsert; +import org.springframework.data.relational.core.sql.render.SqlRenderer; + +/** + * @author Christoph Strobl + */ +class HsqlDbDialectRenderingUnitTests { + + private final RenderContextFactory factory = new RenderContextFactory(new HsqlDbDialect()); + + @Test // GH-493 + void rendersUpsertHappyPath() { + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column nameColumn = table.column("name"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.bindMarker(":id")), nameColumn.set(SQL.bindMarker(":name"))).onConflict(idColumn) + .update().build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualTo( + "MERGE INTO my_table \"_t\" USING (VALUES (:id, :name)) AS \"_s\" (id, name) ON \"_t\".id = \"_s\".id WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name)"); + } + + @Test // GH-493 + void rendersUpsertWithValues() { + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column nameColumn = table.column("name"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.literalOf(42)), nameColumn.set(SQL.literalOf("batman"))).onConflict(idColumn).update() + .build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualTo( + "MERGE INTO my_table \"_t\" USING (VALUES (42, 'batman')) AS \"_s\" (id, name) ON \"_t\".id = \"_s\".id WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name)"); + } + + @Test // GH-493 + void rendersUpsertWhereConflictColumnsMatchInsertColumns() { // omits `WHEN MATCHED` + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column tenantColumn = table.column("tenant_id"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.bindMarker(":id")), tenantColumn.set(SQL.bindMarker(":tenant_id"))) + .onConflict(idColumn, tenantColumn).update().build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualTo( + "MERGE INTO my_table \"_t\" USING (VALUES (:id, :tenant_id)) AS \"_s\" (id, tenant_id) ON \"_t\".id = \"_s\".id AND \"_t\".tenant_id = \"_s\".tenant_id WHEN NOT MATCHED THEN INSERT (id, tenant_id) VALUES (\"_s\".id, \"_s\".tenant_id)"); + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/MySqlDialectRenderingUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/MySqlDialectRenderingUnitTests.java index a64def5328..c7fc86864f 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/MySqlDialectRenderingUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/MySqlDialectRenderingUnitTests.java @@ -15,15 +15,17 @@ */ package org.springframework.data.relational.core.dialect; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; - +import org.springframework.data.relational.core.sql.Column; import org.springframework.data.relational.core.sql.LockMode; +import org.springframework.data.relational.core.sql.SQL; import org.springframework.data.relational.core.sql.Select; import org.springframework.data.relational.core.sql.StatementBuilder; import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.Upsert; import org.springframework.data.relational.core.sql.render.NamingStrategies; import org.springframework.data.relational.core.sql.render.SqlRenderer; @@ -33,6 +35,7 @@ * @author Mark Paluch * @author Jens Schauder * @author Myeonghyeon Lee + * @author Christoph Strobl */ public class MySqlDialectRenderingUnitTests { @@ -123,4 +126,52 @@ public void shouldRenderSelectWithLimitWithLockRead() { assertThat(sql).isEqualTo("SELECT foo.* FROM foo LIMIT 10 LOCK IN SHARE MODE"); } + + @Test // GH-493 + void rendersUpsertHappyPath() { + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column nameColumn = table.column("name"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.bindMarker(":id")), nameColumn.set(SQL.bindMarker(":name"))).onConflict(idColumn) + .update().build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualToIgnoringWhitespace( + "INSERT INTO my_table (id, name) VALUES (:id, :name) ON DUPLICATE KEY UPDATE name = VALUES(name)"); + } + + @Test // GH-493 + void rendersUpsertWithValues() { + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column nameColumn = table.column("name"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.literalOf(42)), nameColumn.set(SQL.literalOf("batman"))).onConflict(idColumn).update() + .build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualToIgnoringWhitespace( + "INSERT INTO my_table (id, name) VALUES (42, 'batman') ON DUPLICATE KEY UPDATE name = VALUES(name)"); + } + + @Test // GH-493 + void rendersUpsertWhereConflictColumnsMatchInsertColumns() { // renders first column only for UPDATE + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column tenantColumn = table.column("tenant_id"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.bindMarker(":id")), tenantColumn.set(SQL.bindMarker(":tenant_id"))) + .onConflict(idColumn, tenantColumn).update().build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualTo( + "INSERT INTO my_table (id, tenant_id) VALUES (:id, :tenant_id) ON DUPLICATE KEY UPDATE id = VALUES(id)"); + } } diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/OracleDialectRenderingUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/OracleDialectRenderingUnitTests.java new file mode 100644 index 0000000000..e34d3a3ee6 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/OracleDialectRenderingUnitTests.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026-present 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.dialect; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.data.relational.core.sql.Column; +import org.springframework.data.relational.core.sql.SQL; +import org.springframework.data.relational.core.sql.StatementBuilder; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.Upsert; +import org.springframework.data.relational.core.sql.render.SqlRenderer; + +/** + * @author Christoph Strobl + */ +class OracleDialectRenderingUnitTests { + + private final RenderContextFactory factory = new RenderContextFactory(new OracleDialect()); + + @Test // GH-493 + void rendersUpsertHappyPath() { + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column nameColumn = table.column("name"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.bindMarker(":id")), nameColumn.set(SQL.bindMarker(":name"))).onConflict(idColumn) + .update().build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualToIgnoringWhitespace( + "MERGE INTO my_table \"_t\" USING (SELECT :id AS id, :name AS name FROM DUAL) \"_s\" ON (\"_t\".id = \"_s\".id) WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name)"); + } + + @Test // GH-493 + void rendersUpsertWithValues() { + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column nameColumn = table.column("name"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.literalOf(42)), nameColumn.set(SQL.literalOf("batman"))).onConflict(idColumn).update() + .build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualToIgnoringWhitespace( + "MERGE INTO my_table \"_t\" USING (SELECT 42 AS id, 'batman' AS name FROM DUAL) \"_s\" ON (\"_t\".id = \"_s\".id) WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name)"); + } + + @Test // GH-493 + void rendersUpsertWhereConflictColumnsMatchInsertColumns() { // omits `WHEN MATCHED` + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column tenantColumn = table.column("tenant_id"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.bindMarker(":id")), tenantColumn.set(SQL.bindMarker(":tenant_id"))) + .onConflict(idColumn, tenantColumn).update().build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualTo( + "MERGE INTO my_table \"_t\" USING (SELECT :id AS id, :tenant_id AS tenant_id FROM DUAL) \"_s\" ON (\"_t\".id = \"_s\".id AND \"_t\".tenant_id = \"_s\".tenant_id) WHEN NOT MATCHED THEN INSERT (id, tenant_id) VALUES (\"_s\".id, \"_s\".tenant_id)"); + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/PostgresDialectRenderingUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/PostgresDialectRenderingUnitTests.java index f26347eb44..1bf23d7bf1 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/PostgresDialectRenderingUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/PostgresDialectRenderingUnitTests.java @@ -15,18 +15,19 @@ */ package org.springframework.data.relational.core.dialect; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; - import org.springframework.data.domain.Sort; import org.springframework.data.relational.core.sql.Column; import org.springframework.data.relational.core.sql.LockMode; import org.springframework.data.relational.core.sql.OrderByField; +import org.springframework.data.relational.core.sql.SQL; import org.springframework.data.relational.core.sql.Select; import org.springframework.data.relational.core.sql.StatementBuilder; import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.Upsert; import org.springframework.data.relational.core.sql.render.NamingStrategies; import org.springframework.data.relational.core.sql.render.SqlRenderer; @@ -37,10 +38,11 @@ * @author Jens Schauder * @author Myeonghyeon Lee * @author Chirag Tailor + * @author Christoph Strobl */ public class PostgresDialectRenderingUnitTests { - private final RenderContextFactory factory = new RenderContextFactory(PostgresDialect.INSTANCE); + private final RenderContextFactory factory = new RenderContextFactory(new PostgresDialect()); @BeforeEach public void before() throws Exception { @@ -156,10 +158,8 @@ public void shouldRenderSelectWithLimitWithLockRead() { void shouldRenderSelectOrderByWithNoOptions() { Table table = Table.create("foo"); - Select select = StatementBuilder.select(table.asterisk()) - .from(table) - .orderBy(OrderByField.from(Column.create("bar", table))) - .build(); + Select select = StatementBuilder.select(table.asterisk()).from(table) + .orderBy(OrderByField.from(Column.create("bar", table))).build(); String sql = SqlRenderer.create(factory.createRenderContext()).render(select); @@ -170,10 +170,8 @@ void shouldRenderSelectOrderByWithNoOptions() { void shouldRenderSelectOrderByWithDirection() { Table table = Table.create("foo"); - Select select = StatementBuilder.select(table.asterisk()) - .from(table) - .orderBy(OrderByField.from(Column.create("bar", table), Sort.Direction.ASC)) - .build(); + Select select = StatementBuilder.select(table.asterisk()).from(table) + .orderBy(OrderByField.from(Column.create("bar", table), Sort.Direction.ASC)).build(); String sql = SqlRenderer.create(factory.createRenderContext()).render(select); @@ -184,10 +182,8 @@ void shouldRenderSelectOrderByWithDirection() { void shouldRenderSelectOrderByWithNullPrecedence() { Table table = Table.create("foo"); - Select select = StatementBuilder.select(table.asterisk()) - .from(table) - .orderBy(OrderByField.from(Column.create("bar", table)) - .withNullHandling(Sort.NullHandling.NULLS_FIRST)) + Select select = StatementBuilder.select(table.asterisk()).from(table) + .orderBy(OrderByField.from(Column.create("bar", table)).withNullHandling(Sort.NullHandling.NULLS_FIRST)) .build(); String sql = SqlRenderer.create(factory.createRenderContext()).render(select); @@ -199,14 +195,61 @@ void shouldRenderSelectOrderByWithNullPrecedence() { void shouldRenderSelectOrderByWithDirectionAndNullHandling() { Table table = Table.create("foo"); - Select select = StatementBuilder.select(table.asterisk()) - .from(table) - .orderBy(OrderByField.from(Column.create("bar", table), Sort.Direction.DESC) - .withNullHandling(Sort.NullHandling.NULLS_FIRST)) + Select select = StatementBuilder + .select(table.asterisk()).from(table).orderBy(OrderByField + .from(Column.create("bar", table), Sort.Direction.DESC).withNullHandling(Sort.NullHandling.NULLS_FIRST)) .build(); String sql = SqlRenderer.create(factory.createRenderContext()).render(select); assertThat(sql).isEqualTo("SELECT foo.* FROM foo ORDER BY foo.bar DESC NULLS FIRST"); } + + @Test // GH-493 + void rendersUpsertHappyPath() { + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column nameColumn = table.column("name"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.bindMarker(":id")), nameColumn.set(SQL.bindMarker(":name"))).onConflict(idColumn) + .update().build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualToIgnoringWhitespace( + "INSERT INTO my_table (id, name) VALUES (:id, :name) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name"); + } + + @Test // GH-493 + void rendersUpsertWithValues() { + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column nameColumn = table.column("name"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.literalOf(42)), nameColumn.set(SQL.literalOf("batman"))).onConflict(idColumn).update() + .build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualToIgnoringWhitespace( + "INSERT INTO my_table (id, name) VALUES (42, 'batman') ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name"); + } + + @Test // GH-493 + void rendersUpsertWhereConflictColumnsMatchInsertColumns() { // renders DO NOTHING + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column tenantColumn = table.column("tenant_id"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.bindMarker(":id")), tenantColumn.set(SQL.bindMarker(":tenant_id"))) + .onConflict(idColumn, tenantColumn).update().build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualTo( + "INSERT INTO my_table (id, tenant_id) VALUES (:id, :tenant_id) ON CONFLICT (id, tenant_id) DO NOTHING"); + } } diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/SqlServerDialectRenderingUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/SqlServerDialectRenderingUnitTests.java index 07d6f10512..7ddaf51b00 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/SqlServerDialectRenderingUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/SqlServerDialectRenderingUnitTests.java @@ -15,18 +15,19 @@ */ package org.springframework.data.relational.core.dialect; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; - import org.springframework.data.domain.Sort; import org.springframework.data.relational.core.sql.Column; import org.springframework.data.relational.core.sql.LockMode; import org.springframework.data.relational.core.sql.OrderByField; +import org.springframework.data.relational.core.sql.SQL; import org.springframework.data.relational.core.sql.Select; import org.springframework.data.relational.core.sql.StatementBuilder; import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.Upsert; import org.springframework.data.relational.core.sql.render.NamingStrategies; import org.springframework.data.relational.core.sql.render.SqlRenderer; @@ -37,10 +38,11 @@ * @author Jens Schauder * @author Myeonghyeon Lee * @author Chirag Tailor + * @author Christoph Strobl */ public class SqlServerDialectRenderingUnitTests { - private final RenderContextFactory factory = new RenderContextFactory(SqlServerDialect.INSTANCE); + private final RenderContextFactory factory = new RenderContextFactory(new SqlServerDialect()); @BeforeEach public void before() { @@ -128,8 +130,7 @@ public void shouldRenderSelectWithLockWrite() { String sql = SqlRenderer.create(factory.createRenderContext()).render(select); - assertThat(sql).isEqualTo( - "SELECT foo.* FROM foo WITH (UPDLOCK, ROWLOCK)"); + assertThat(sql).isEqualTo("SELECT foo.* FROM foo WITH (UPDLOCK, ROWLOCK)"); } @Test // DATAJDBC-498 @@ -141,8 +142,7 @@ public void shouldRenderSelectWithLockRead() { String sql = SqlRenderer.create(factory.createRenderContext()).render(select); - assertThat(sql).isEqualTo( - "SELECT foo.* FROM foo WITH (HOLDLOCK, ROWLOCK)"); + assertThat(sql).isEqualTo("SELECT foo.* FROM foo WITH (HOLDLOCK, ROWLOCK)"); } @Test // DATAJDBC-498 @@ -155,7 +155,7 @@ public void shouldRenderSelectWithLimitOffsetWithLockWrite() { String sql = SqlRenderer.create(factory.createRenderContext()).render(select); assertThat(sql).isEqualTo( - "SELECT foo.*, ROW_NUMBER() over (ORDER BY (SELECT 1)) AS __relational_row_number__ FROM foo WITH (UPDLOCK, ROWLOCK) ORDER BY __relational_row_number__ OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY"); + "SELECT foo.*, ROW_NUMBER() over (ORDER BY (SELECT 1)) AS __relational_row_number__ FROM foo WITH (UPDLOCK, ROWLOCK) ORDER BY __relational_row_number__ OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY"); } @Test // DATAJDBC-498 @@ -168,7 +168,7 @@ public void shouldRenderSelectWithLimitOffsetWithLockRead() { String sql = SqlRenderer.create(factory.createRenderContext()).render(select); assertThat(sql).isEqualTo( - "SELECT foo.*, ROW_NUMBER() over (ORDER BY (SELECT 1)) AS __relational_row_number__ FROM foo WITH (HOLDLOCK, ROWLOCK) ORDER BY __relational_row_number__ OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY"); + "SELECT foo.*, ROW_NUMBER() over (ORDER BY (SELECT 1)) AS __relational_row_number__ FROM foo WITH (HOLDLOCK, ROWLOCK) ORDER BY __relational_row_number__ OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY"); } @Test // DATAJDBC-498 @@ -177,11 +177,12 @@ public void shouldRenderSelectWithLimitOffsetAndOrderByWithLockWrite() { Table table = Table.create("foo"); LockMode lockMode = LockMode.PESSIMISTIC_WRITE; Select select = StatementBuilder.select(table.asterisk()).from(table).orderBy(table.column("column_1")).limit(10) - .offset(20).lock(lockMode).build(); + .offset(20).lock(lockMode).build(); String sql = SqlRenderer.create(factory.createRenderContext()).render(select); - assertThat(sql).isEqualTo("SELECT foo.* FROM foo WITH (UPDLOCK, ROWLOCK) ORDER BY foo.column_1 OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY"); + assertThat(sql).isEqualTo( + "SELECT foo.* FROM foo WITH (UPDLOCK, ROWLOCK) ORDER BY foo.column_1 OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY"); } @Test // DATAJDBC-498 @@ -190,25 +191,72 @@ public void shouldRenderSelectWithLimitOffsetAndOrderByWithLockRead() { Table table = Table.create("foo"); LockMode lockMode = LockMode.PESSIMISTIC_READ; Select select = StatementBuilder.select(table.asterisk()).from(table).orderBy(table.column("column_1")).limit(10) - .offset(20).lock(lockMode).build(); + .offset(20).lock(lockMode).build(); String sql = SqlRenderer.create(factory.createRenderContext()).render(select); - assertThat(sql).isEqualTo("SELECT foo.* FROM foo WITH (HOLDLOCK, ROWLOCK) ORDER BY foo.column_1 OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY"); + assertThat(sql).isEqualTo( + "SELECT foo.* FROM foo WITH (HOLDLOCK, ROWLOCK) ORDER BY foo.column_1 OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY"); } @Test // GH-821 void shouldRenderSelectOrderByIgnoringNullHandling() { Table table = Table.create("foo"); - Select select = StatementBuilder.select(table.asterisk()) - .from(table) - .orderBy(OrderByField.from(Column.create("bar", table)) - .withNullHandling(Sort.NullHandling.NULLS_FIRST)) + Select select = StatementBuilder.select(table.asterisk()).from(table) + .orderBy(OrderByField.from(Column.create("bar", table)).withNullHandling(Sort.NullHandling.NULLS_FIRST)) .build(); String sql = SqlRenderer.create(factory.createRenderContext()).render(select); assertThat(sql).isEqualTo("SELECT foo.* FROM foo ORDER BY foo.bar"); } + + @Test // GH-493 + void rendersUpsertHappyPath() { + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column nameColumn = table.column("name"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.bindMarker(":id")), nameColumn.set(SQL.bindMarker(":name"))).onConflict(idColumn) + .update().build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualTo( + "MERGE INTO my_table \"_t\" USING (VALUES (:id, :name)) AS \"_s\" (id, name) ON \"_t\".id = \"_s\".id WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name);"); + } + + @Test // GH-493 + void rendersUpsertWithValues() { + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column nameColumn = table.column("name"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.literalOf(42)), nameColumn.set(SQL.literalOf("batman"))).onConflict(idColumn).update() + .build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualTo( + "MERGE INTO my_table \"_t\" USING (VALUES (42, 'batman')) AS \"_s\" (id, name) ON \"_t\".id = \"_s\".id WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name);"); + } + + @Test // GH-493 + void rendersUpsertWhereConflictColumnsMatchInsertColumns() { // omits `WHEN MATCHED` + + Table table = Table.create("my_table"); + Column idColumn = table.column("id"); + Column tenantColumn = table.column("tenant_id"); + Upsert upsert = StatementBuilder.upsert(table) + .insert(idColumn.set(SQL.bindMarker(":id")), tenantColumn.set(SQL.bindMarker(":tenant_id"))) + .onConflict(idColumn, tenantColumn).update().build(); + + String sql = SqlRenderer.create(factory.createRenderContext()).render(upsert); + + assertThat(sql).isEqualTo( + "MERGE INTO my_table \"_t\" USING (VALUES (:id, :tenant_id)) AS \"_s\" (id, tenant_id) ON \"_t\".id = \"_s\".id AND \"_t\".tenant_id = \"_s\".tenant_id WHEN NOT MATCHED THEN INSERT (id, tenant_id) VALUES (\"_s\".id, \"_s\".tenant_id);"); + } } diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java deleted file mode 100644 index e23d9b30cc..0000000000 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/UpsertRenderContextUnitTests.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright 2026-present 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 static org.assertj.core.api.Assertions.assertThat; - -import java.util.List; -import java.util.Map; - -import org.junit.jupiter.api.Test; -import org.springframework.data.relational.core.dialect.AnsiDialect; -import org.springframework.data.relational.core.dialect.Dialect; -import org.springframework.data.relational.core.dialect.MySqlDialect; -import org.springframework.data.relational.core.dialect.OracleDialect; -import org.springframework.data.relational.core.dialect.PostgresDialect; -import org.springframework.data.relational.core.dialect.RenderContextFactory; -import org.springframework.data.relational.core.dialect.SqlServerDialect; -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.data.relational.core.sql.render.UpsertStatementRenderer.UpsertRenderingContext; - -/** - * Unit tests for {@link UpsertRenderContext} implementations via {@link UpsertStatementRenderer#render}. - */ -@SuppressWarnings("deprecation") -class UpsertRenderContextUnitTests { - - private static final Table TABLE = Table.create(SqlIdentifier.unquoted("my_table")); - private static final List INSERT_COLUMNS = List.of(SqlIdentifier.unquoted("id"), - SqlIdentifier.unquoted("name")); - private static final List CONFLICT_COLUMNS = List.of(SqlIdentifier.unquoted("id")); - - private static String render(UpsertRenderContext upsertContext, Dialect dialect, Table table, - List insertColumns, List conflictColumns) { - - UpsertRenderingContext ctx = UpsertRenderingContext.of(new RenderContextFactory(dialect).createRenderContext(), - it -> ":%s".formatted(it.getReference())); - List insertCols = insertColumns.stream().map(id -> Column.create(id, table)).toList(); - List conflictCols = conflictColumns.stream().map(id -> Column.create(id, table)).toList(); - Map bindings = Map.of(insertCols.get(0).getName(), - ":" + insertCols.get(0).getName().getReference()); - return upsertContext.renderer().render(table, - new UpsertStatementRenderer.Columns(insertCols, conflictCols, bindings), ctx); - } - - @Test // GH-493 - void standardUpsertRendersMergeInto() { - - String sql = render(StandardSqlUpsertRenderContext.INSTANCE, AnsiDialect.INSTANCE, TABLE, INSERT_COLUMNS, - CONFLICT_COLUMNS); - - assertThat(sql).isEqualTo( - "MERGE INTO my_table \"_t\" USING (VALUES (:id, :name)) AS \"_s\" (id, name) ON \"_t\".id = \"_s\".id WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name)"); - } - - @Test // GH-493 - void mergeUpsertWithMultipleConflictColumnsBuildsFilterClauseWithAllColumns() { - - List insertColumns = List.of(SqlIdentifier.unquoted("tenant_id"), SqlIdentifier.unquoted("id"), - SqlIdentifier.unquoted("name")); - List conflictColumns = List.of(SqlIdentifier.unquoted("tenant_id"), SqlIdentifier.unquoted("id")); - - String sql = render(StandardSqlUpsertRenderContext.INSTANCE, AnsiDialect.INSTANCE, TABLE, insertColumns, - conflictColumns); - - assertThat(sql).isEqualToIgnoringWhitespace( - "MERGE INTO my_table \"_t\" USING (VALUES (:tenant_id, :id, :name)) AS \"_s\" (tenant_id, id, name) " - + "ON \"_t\".tenant_id = \"_s\".tenant_id AND \"_t\".id = \"_s\".id " - + "WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name " - + "WHEN NOT MATCHED THEN INSERT (tenant_id, id, name) VALUES (\"_s\".tenant_id, \"_s\".id, \"_s\".name)"); - } - - @Test // GH-493 - void postgresUpsertRendersInsertOnConflictDoUpdate() { - - String sql = render(PostgresUpsertRenderContext.INSTANCE, PostgresDialect.INSTANCE, TABLE, INSERT_COLUMNS, - CONFLICT_COLUMNS); - - assertThat(sql).isEqualToIgnoringWhitespace( - "INSERT INTO my_table (id, name) VALUES (:id, :name) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name"); - } - - @Test // GH-493 - void postgresUpsertRendersInsertOnConflictDoNothing() { - - String sql = render(PostgresUpsertRenderContext.INSTANCE, PostgresDialect.INSTANCE, TABLE, INSERT_COLUMNS, - INSERT_COLUMNS); - - assertThat(sql).isEqualToIgnoringWhitespace( - "INSERT INTO my_table (id, name) VALUES (:id, :name) ON CONFLICT (id, name) DO NOTHING"); - } - - @Test // GH-493 - void mySqlUpsertRendersOnDuplicateKeyUpdate() { - - String sql = render(MySqlUpsertRenderContext.INSTANCE, MySqlDialect.INSTANCE, TABLE, INSERT_COLUMNS, - CONFLICT_COLUMNS); - - assertThat(sql).isEqualToIgnoringWhitespace( - "INSERT INTO my_table (id, name) VALUES (:id, :name) ON DUPLICATE KEY UPDATE name = VALUES(name)"); - } - - @Test // GH-493 - void mySqlUpsertRendersCorrectlyWhenUpdateCoversEntireKey() { - - String sql = render(MySqlUpsertRenderContext.INSTANCE, MySqlDialect.INSTANCE, TABLE, INSERT_COLUMNS, - INSERT_COLUMNS); - - assertThat(sql).isEqualToIgnoringWhitespace( - "INSERT INTO my_table (id, name) VALUES (:id, :name) ON DUPLICATE KEY UPDATE id = VALUES(id), name = VALUES(name)"); - } - - @Test // GH-493 - void oracleMergeUpsertRendersOnConditionInParentheses() { - - String sql = render(OracleUpsertRenderContext.INSTANCE, OracleDialect.INSTANCE, TABLE, INSERT_COLUMNS, - CONFLICT_COLUMNS); - - assertThat(sql).isEqualToIgnoringWhitespace( - "MERGE INTO my_table \"_t\" USING (SELECT :id AS id, :name AS name FROM DUAL) \"_s\" ON (\"_t\".id = \"_s\".id) WHEN MATCHED THEN UPDATE SET \"_t\".name = \"_s\".name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (\"_s\".id, \"_s\".name)"); - } - - @Test // GH-493 - void standardMergeIdOnlyOmitsWhenMatchedUpdate() { - - List idOnly = List.of(SqlIdentifier.unquoted("id")); - String sql = render(StandardSqlUpsertRenderContext.INSTANCE, AnsiDialect.INSTANCE, TABLE, idOnly, idOnly); - - assertThat(sql).isEqualTo( - "MERGE INTO my_table \"_t\" USING (VALUES (:id)) AS \"_s\" (id) ON \"_t\".id = \"_s\".id WHEN NOT MATCHED THEN INSERT (id) VALUES (\"_s\".id)"); - } - - @Test // GH-493 - void oracleMergeIdOnlyOmitsWhenMatchedUpdate() { - - List idOnly = List.of(SqlIdentifier.unquoted("id")); - String sql = render(OracleUpsertRenderContext.INSTANCE, OracleDialect.INSTANCE, TABLE, idOnly, idOnly); - - assertThat(sql).isEqualToIgnoringWhitespace( - "MERGE INTO my_table \"_t\" USING (SELECT :id AS id FROM DUAL) \"_s\" ON (\"_t\".id = \"_s\".id) WHEN NOT MATCHED THEN INSERT (id) VALUES (\"_s\".id)"); - } - - @Test // GH-493 - void sqlServerUpsertRendersMergeWithSemicolon() { - - String sql = render(SqlServerUpsertRenderContext.INSTANCE, SqlServerDialect.INSTANCE, TABLE, INSERT_COLUMNS, - CONFLICT_COLUMNS); - - assertThat(sql).contains("MERGE INTO"); - assertThat(sql).contains("my_table"); - assertThat(sql).contains("WHEN MATCHED THEN UPDATE SET"); - assertThat(sql).contains("WHEN NOT MATCHED THEN INSERT"); - assertThat(sql.trim()).endsWith(";"); - } -}