diff --git a/pom.xml b/pom.xml
index 524c7402b8..564377c5eb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
org.springframework.dataspring-data-relational-parent
- 4.1.0-SNAPSHOT
+ 4.1.x-GH-493-SNAPSHOTpomSpring 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.dataspring-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.0spring-data-jdbc
- 4.1.0-SNAPSHOT
+ 4.1.x-GH-493-SNAPSHOTSpring Data JDBCSpring Data module for JDBC repositories.
@@ -15,7 +15,7 @@
org.springframework.dataspring-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 super T> 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 super T> 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 super T> 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 super T> 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 super T> 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.0spring-data-r2dbc
- 4.1.0-SNAPSHOT
+ 4.1.x-GH-493-SNAPSHOTSpring Data R2DBCSpring Data module for R2DBC
@@ -15,7 +15,7 @@
org.springframework.dataspring-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.
+ *
+ *
+ *
+ * @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.0spring-data-relational
- 4.1.0-SNAPSHOT
+ 4.1.x-GH-493-SNAPSHOTSpring Data RelationalSpring Data Relational support
@@ -14,7 +14,7 @@
org.springframework.dataspring-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