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-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/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..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 @@ -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; @@ -52,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; @@ -266,6 +268,14 @@ public List updateAll(Iterable instances) { return doInBatch(instances, entity -> createUpdateChange(prepareVersionForUpdate(entity))); } + @Override + public T upsert(T instance) { + + Assert.notNull(instance, "Aggregate instance must not be null"); + + return performSave(new EntityAndChangeCreator<>(instance, entity -> createUpsertChange(entity))); + } + private List saveInBatch(Iterable instances, Function> changes) { Assert.notNull(instances, "Aggregate instances must not be null"); @@ -622,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); @@ -734,7 +751,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 +762,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/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/CascadingDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java index c187dc726d..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 { @@ -87,6 +87,11 @@ public NamedParameterJdbcOperations getJdbcOperations() { return collect(das -> das.insert(insertSubjects, domainType, idValueSource)); } + @Override + public int upsert(T instance, Class domainType) { + return collect(das -> das.upsert(instance, domainType)); + } + @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..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 { @@ -118,6 +119,19 @@ 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 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 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); + /** * 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..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; @@ -24,8 +24,9 @@ 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; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -69,6 +70,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 +182,21 @@ public boolean updateWithVersion(S instance, Class domainType, Number pre return true; } + @Override + public int upsert(T instance, Class domainType) { + + SqlIdentifierParameterSource parameterSource = sqlParametersFactory.forInsert(instance, domainType, Identifier.empty(), + IdValueSource.PROVIDED); + + String statement = sql(domainType).getUpsert(parameterSource.getIdentifiers()); + + if (logger.isTraceEnabled()) { + logger.trace("Upsert: [%s]".formatted(statement)); + } + + return operations.update(statement, parameterSource); + } + @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..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 @@ -80,6 +80,11 @@ public NamedParameterJdbcOperations getJdbcOperations() { return delegate.insert(insertSubjects, domainType, idValueSource); } + @Override + public int upsert(T instance, Class domainType) { + return delegate.upsert(instance, domainType); + } + @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..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 @@ -45,7 +45,35 @@ 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; @@ -70,6 +98,7 @@ * @author Hari Ohm Prasath * @author Viktor Ardelean * @author Kurt Niemi + * @author Christoph Strobl */ public class SqlGenerator { @@ -86,6 +115,7 @@ public class SqlGenerator { private final JdbcConverter converter; private final SqlContext sqlContext; + private final RenderContext renderContext; private final SqlRenderer sqlRenderer; private final Columns columns; @@ -121,7 +151,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; @@ -394,6 +425,45 @@ String getInsert(Set additionalColumns) { return createInsertSql(additionalColumns); } + /** + * 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) { + return render(createUpsertSql(additionalColumns)); + } + + /** + * @param additionalColumns + * @return + * @since 4.x + */ + private Upsert createUpsertSql(Set additionalColumns) { + + Table table = getTable(); + + Set columnNamesForInsert = new TreeSet<>(Comparator.comparing(SqlIdentifier::getReference)); + columnNamesForInsert.addAll(columns.getInsertableColumns()); + columnNamesForInsert.addAll(additionalColumns); + + 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) // + .insert(assignments) // + .onConflict(idColumns) // + .update().build(); + } + /** * Create a {@code UPDATE … SET …} statement. * @@ -938,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() // @@ -949,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()); } @@ -1032,6 +1104,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/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-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..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 @@ -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; @@ -204,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(); @@ -268,6 +274,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/JdbcMySqlDialect.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/JdbcMySqlDialect.java index 07008a6e83..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 @@ -23,7 +23,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Date; - import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.ReadingConverter; import org.springframework.data.convert.WritingConverter; 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..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 @@ -184,6 +184,11 @@ public void setNamespaceStrategy(NamespaceStrategy namespaceStrategy) { .toArray(); } + @Override + public int upsert(T instance, Class domainType) { + 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..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 @@ -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,110 @@ 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() { + + withSqlServerIdentityInsertOn(template, "with_insert_only", () -> { + + 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"); + }); + } + + @Test // GH-493 + void upsertWhenMatchedAndUpdateAssignmentsEqualConflictKeyOnly() { + + long id = 8889L; + withSqlServerIdentityInsertOn(template, "with_id_only", () -> { + + 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 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/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-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/convert/SqlGeneratorUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java index bdd2884b41..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; @@ -599,6 +600,33 @@ void getInsertForQuotedColumnName() { + "(\"test\"\"_@123\") " + "VALUES (:test_123)"); } + @Test // GH-493 + void getUpsertThrowsWhenDialectDoesNotSupportUpsert() { + + SqlGenerator sqlGenerator = createSqlGenerator(DummyEntity.class); + 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 + 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/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..017cb718be --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/sql/render/UpsertRendererUnitTests.java @@ -0,0 +1,128 @@ +/* + * 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 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; +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 standardSqlUpsertUsesMerge() { + + Table table = SQL.table("my_table"); + 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.convert.NonQuotingDialect.INSTANCE) + .createRenderContext(); + String sql = SqlRenderer.create(context).render(upsert); + + 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) + .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).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) + .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).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) + .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).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) + .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).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).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).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/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-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..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 @@ -29,8 +29,24 @@ 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.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; +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.render.RenderContext; import org.springframework.data.relational.core.sql.render.SqlRenderer; import org.springframework.r2dbc.core.PreparedOperation; @@ -46,6 +62,7 @@ * @author Roman Chigvintsev * @author Mingyuan Wu * @author Diego Krupitza + * @author Christoph Strobl */ class DefaultStatementMapper implements StatementMapper { @@ -227,6 +244,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 +280,29 @@ 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(); + + List conflictColumnIds = upsertSpec.getConflictColumns(); + Assert.notEmpty(conflictColumnIds, "Conflict columns must not be empty for upsert"); + + Column[] conflictColumns = conflictColumnIds.stream().map(table::column).toArray(Column[]::new); + + Upsert upsert = StatementBuilder.upsert(table) // + .insert(boundAssignments.getAssignments()) // + .onConflict(conflictColumns) // + .update().build(); + + return new DefaultPreparedOperation<>(upsert, this.renderContext, bindings); + } + private String toSql(SqlIdentifier identifier) { Assert.notNull(identifier, "SqlIdentifier must not be null"); @@ -309,6 +354,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 +401,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/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 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/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 ca3b5264cf..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 @@ -25,6 +25,9 @@ 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 @@ -41,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. * @@ -147,4 +158,15 @@ default SimpleFunction getExistsFunction() { default boolean supportsSingleQueryLoading() { return true; } + + /** + * Returns an {@link UpsertRenderContext} for single-statement upsert. + * + * @return the upsert render context. {@link StandardSqlUpsertRenderContext} by default. + * @throws UnsupportedOperationException if the dialect does not support single-statement upsert. + * @since 4.x + */ + 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 c0fb030c10..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 @@ -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 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/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/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/DefaultUpsert.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsert.java new file mode 100644 index 0000000000..23d716f940 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsert.java @@ -0,0 +1,112 @@ +/* + * 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.List; + +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; + +/** + * @author Christoph Strobl + * @since 4.x + */ +class DefaultUpsert implements Upsert { + + private final Table table; + private final List assignments; + private final List conflictColumns; + + DefaultUpsert(Table table, List assignments, List conflictColumns) { + + this.table = table; + this.assignments = new ArrayList<>(assignments); + this.conflictColumns = new ArrayList<>(conflictColumns); + } + + @Override + public Table getTable() { + return table; + } + + @Override + public List getAssignments() { + return assignments; + } + + @Override + public List getConflictColumns() { + return conflictColumns; + } + + @Override + public void visit(Visitor visitor) { + + Assert.notNull(visitor, "Visitor must not be null"); + + visitor.enter(this); + + this.table.visit(visitor); + this.conflictColumns.forEach(col -> col.visit(visitor)); + this.assignments.forEach(it -> it.visit(visitor)); + + visitor.leave(this); + } + + @Override + public String toString() { // TODO: this method vs. SqlRenderer.toString(upsert); + + UpsertStatementVisitor visitor = new UpsertStatementVisitor(TO_STRING_ANSI_RENDER_CONTEXT); + this.visit(visitor); + return visitor.getRenderedPart().toString(); + } + + private static final RenderContext TO_STRING_ANSI_RENDER_CONTEXT = new RenderContext() { + + @Override + public RenderNamingStrategy getNamingStrategy() { + return NamingStrategies.asIs(); + } + + @Override + public IdentifierProcessing getIdentifierProcessing() { + return IdentifierProcessing.NONE; + } + + @Override + public SelectRenderContext getSelectRenderContext() { + throw new UnsupportedOperationException(); + } + + @Override + public InsertRenderContext getInsertRenderContext() { + throw new UnsupportedOperationException(); + } + + @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 new file mode 100644 index 0000000000..ed94beadfc --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultUpsertBuilder.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.relational.core.sql; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +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. + * + * @author Christoph Strobl + * @since 4.x + */ +class DefaultUpsertBuilder implements UpsertBuilder, UpsertInsert, UpsertOnConflict, UpsertResolution, BuildUpsert { + + private final Table table; + private final List assignments = new ArrayList<>(); + private final List conflictColumns = new ArrayList<>(); + + DefaultUpsertBuilder(Table table) { + + Assert.notNull(table, "Table must not be null"); + this.table = table; + } + + @Override + public UpsertOnConflict insert(Collection assignments) { + + Assert.notNull(assignments, "Assignments must not be null"); + this.assignments.addAll(assignments); + return this; + } + + @Override + public UpsertResolution onConflict(Collection columns) { + + Assert.notNull(columns, "Conflict columns must not be null"); + this.conflictColumns.addAll(columns); + return this; + } + + @Override + public BuildUpsert update() { + return this; + } + + @Override + public Upsert build() { + validate(); + return new DefaultUpsert(this.table, this.assignments, this.conflictColumns); + } + + 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/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/StatementBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/StatementBuilder.java index 41dfb48c9e..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,6 +121,16 @@ public static UpdateBuilder update() { return Update.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); + } + /** * 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..06443febcc --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Upsert.java @@ -0,0 +1,62 @@ +/* + * 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.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 { // TODO: should we rename this to Merge? + + /** + * 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); + } + + /** + * 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 new file mode 100644 index 0000000000..0fd3795da9 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/UpsertBuilder.java @@ -0,0 +1,99 @@ +/* + * 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; +import java.util.List; + +/** + * Fluent builder for {@link Upsert} statements. + *

+ * Usage: {@code StatementBuilder.upsert(table).insert(col.set(marker), …).onConflict(idCol).update()} + * + * @author Christoph Strobl + * @since 4.x + * @see StatementBuilder + */ +public interface UpsertBuilder { + + /** + * Step for specifying the columns and values to insert. + */ + interface UpsertInsert { + + /** + * 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)); + } + + /** + * Specify the column-value assignments for the insert. + * + * @param assignments the {@link Assignment column assignments}; must not be {@literal null}. + * @return the next builder step. + */ + UpsertOnConflict insert(Collection assignments); + } + + /** + * Step for specifying the conflict target columns. + */ + interface UpsertOnConflict { + + /** + * Declare the columns whose uniqueness constraint drives conflict detection. + * + * @param columns one or more conflict columns; must not be {@literal null}. + * @return the terminal build step. + */ + default UpsertResolution onConflict(Column... columns) { + return onConflict(List.of(columns)); + } + + /** + * Declare the columns whose uniqueness constraint drives conflict detection. + * + * @param columns the conflict columns; must not be {@literal null}. + * @return the terminal build step. + */ + UpsertResolution onConflict(Collection columns); + } + + /** + * Step for specifying what to do when a conflict is detected. + */ + interface UpsertResolution { + BuildUpsert update(); + } + + /** + * Terminal step that produces the {@link Upsert} statement. + */ + interface BuildUpsert { + + /** + * Build the 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/MySqlUpsertRenderContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/MySqlUpsertRenderContext.java new file mode 100644 index 0000000000..3b2fd1de72 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/MySqlUpsertRenderContext.java @@ -0,0 +1,32 @@ +/* + * 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; + +/** + * MySQL / MariaDB upsert using {@code INSERT ... ON DUPLICATE KEY UPDATE}. + * + * @author Christoph Strobl + * @since 4.x + */ +public enum MySqlUpsertRenderContext implements UpsertRenderContext { + + INSTANCE; + + @Override + 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 new file mode 100644 index 0000000000..79d3d18070 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/OracleUpsertRenderContext.java @@ -0,0 +1,31 @@ +/* + * 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; + +/** + * Oracle MERGE upsert. Uses {@code SELECT ... FROM DUAL} for source values. + * + * @since 4.x + */ +public enum OracleUpsertRenderContext implements UpsertRenderContext { + + INSTANCE; + + @Override + 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 new file mode 100644 index 0000000000..9349a975b5 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/PostgresUpsertRenderContext.java @@ -0,0 +1,31 @@ +/* + * 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; + +/** + * PostgreSQL upsert using {@code INSERT ... ON CONFLICT ... DO UPDATE SET}. + * + * @since 4.x + */ +public enum PostgresUpsertRenderContext implements UpsertRenderContext { + + INSTANCE; + + @Override + public UpsertStatementRenderer renderer() { + return UpsertStatementRenderer.postgres(); + } +} 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..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 @@ -24,6 +24,7 @@ * @author Mark Paluch * @author Mikhail Polivakha * @author Jens Schauder + * @author Christoph Strobl * @since 1.1 */ public interface RenderContext { @@ -52,4 +53,10 @@ public interface RenderContext { * @return the {@link InsertRenderContext} */ InsertRenderContext getInsertRenderContext(); + + /** + * @return the {@link UpsertRenderContext}. + * @since 4.x + */ + 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..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 @@ -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; /** @@ -26,6 +27,7 @@ * * @author Mark Paluch * @author Jens Schauder + * @author Christoph Strobl * @since 1.1 * @see RenderContext */ @@ -89,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. * @@ -152,4 +164,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-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 new file mode 100644 index 0000000000..a035d47b57 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/SqlServerUpsertRenderContext.java @@ -0,0 +1,31 @@ +/* + * 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; + +/** + * SQL Server MERGE upsert. Delegates to {@link UpsertStatementRenderers.StandardSql} and appends a required semicolon. + * + * @since 4.x + */ +public enum SqlServerUpsertRenderContext implements UpsertRenderContext { + + INSTANCE; + + @Override + 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 new file mode 100644 index 0000000000..8ee0ba132c --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContext.java @@ -0,0 +1,33 @@ +/* + * 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; + +/** + * 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. + * + * @since 4.x + */ +public enum StandardSqlUpsertRenderContext implements UpsertRenderContext { + + INSTANCE; + + @Override + public UpsertStatementRenderer renderer() { + return UpsertStatementRenderer.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 new file mode 100644 index 0000000000..5613861aa6 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertRenderContext.java @@ -0,0 +1,27 @@ +/* + * 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; + +/** + * {@link UpsertStatementRenderers}. + * + * @since 4.x + */ +public interface UpsertRenderContext { + + UpsertStatementRenderer renderer(); + +} 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..a69807ff1f --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderer.java @@ -0,0 +1,251 @@ +/* + * 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.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; +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, 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 */ + 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 Map bindings; + private final List insertColumns; + private final List conflictColumns; + private final List updateColumns; + + public Columns(List insertColumns, List conflictColumns, + Map bindings) { + + this.bindings = bindings; + 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; + } + + 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/UpsertStatementRenderers.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderers.java new file mode 100644 index 0000000000..ae3d4c7e61 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementRenderers.java @@ -0,0 +1,236 @@ +/* + * 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(", ")); + + if (columns.updateColumns().isEmpty()) { + return "INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO NOTHING".formatted(// + tableName, // + insertColumnNames, // + bindMarkers, // + 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, // + 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 = columnsToUpdate(columns); + + 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); + } + + 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. */ + 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 new file mode 100644 index 0000000000..abf4114369 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/render/UpsertStatementVisitor.java @@ -0,0 +1,101 @@ +/* + * 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.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Map.Entry; + +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.Expression; +import org.springframework.data.relational.core.sql.SqlIdentifier; +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 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 + */ +public class UpsertStatementVisitor extends DelegatingVisitor implements PartRenderer { + + private final StringBuilder builder = new StringBuilder(); + private final RenderContext context; + + public UpsertStatementVisitor(RenderContext context) { + + Assert.notNull(context, "RenderContext must not be null"); + this.context = context; + } + + @Override + public @Nullable Delegation doEnter(Visitable segment) { + return Delegation.retain(); + } + + @Override + public Delegation doLeave(Visitable segment) { + + if (segment instanceof Upsert source) { + + // 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(source.getTable(), new UpsertStatementRenderer.Columns( + new ArrayList<>(insertColumns.keySet()), source.getConflictColumns(), bindings), renderingContext); + builder.append(sql); + + return Delegation.leave(); + } + + 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/DependencyTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/DependencyTests.java index 3d3772bd43..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 @@ -18,7 +18,13 @@ 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; import com.tngtech.archunit.core.domain.JavaClass; @@ -35,6 +41,7 @@ * * @author Jens Schauder * @author Mark Paluch + * @author Christoph Strobl */ public class DependencyTests { @@ -47,6 +54,13 @@ void cycleFree() { .importPackages("org.springframework.data.relational") // .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("org.springframework.data.relational.core.sql.DefaultUpsert")) // .that(ignore(RenderContextFactory.class)); ArchRule rule = SlicesRuleDefinition.slices() // @@ -117,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/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() { 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/UpsertBuilderUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/UpsertBuilderUnitTests.java new file mode 100644 index 0000000000..8484bbfbe5 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/UpsertBuilderUnitTests.java @@ -0,0 +1,87 @@ +/* + * 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.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 + */ +public class UpsertBuilderUnitTests { + + @Test // GH-493 + void buildErrorsWhenConflictColumnsNotPartOfInsert() { + + Table table = SQL.table("users"); + Column idColumn = table.column("id"); + Column usernameColumn = table.column("name"); + + BuildUpsert builder = StatementBuilder.upsert(table).insert(usernameColumn.set(SQL.bindMarker())) + .onConflict(idColumn).update(); + + 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 \"_t\"").containsSubsequence( // + "USING (VALUES (?, ?)) 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 + 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)"); + } + +} 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..05ca42108c --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/StandardSqlUpsertRenderContextUnitTests.java @@ -0,0 +1,60 @@ +/* + * 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.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.UpsertStatementRenderer.UpsertRenderingContext; + +/** + * Unit tests for {@link StandardSqlUpsertRenderContext}. + */ +class StandardSqlUpsertRenderContextUnitTests { + + private static final Table TABLE = Table.create(SqlIdentifier.unquoted("my_table")); + + @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")); + + 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, 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"); + assertThat(sql).contains( + "WHEN NOT MATCHED THEN INSERT (tenant_id, id, name) VALUES (\"_s\".tenant_id, \"_s\".id, \"_s\".name)"); + } +} 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.