diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java index e2bbef6daf5..b2799702a43 100644 --- a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java +++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java @@ -19,6 +19,7 @@ import io.micronaut.context.BeanContext; import io.micronaut.context.annotation.EachBean; import io.micronaut.context.annotation.Parameter; +import io.micronaut.context.annotation.Requires; import io.micronaut.core.annotation.AnnotationMetadata; import io.micronaut.core.annotation.Internal; import io.micronaut.data.model.runtime.convert.DatabaseType; @@ -148,8 +149,9 @@ * @since 1.0.0 */ @EachBean(DataSource.class) +@Requires(condition = DefaultJdbcRepositoryOperationsCondition.class) @Internal -public final class DefaultJdbcRepositoryOperations extends AbstractSqlRepositoryOperations implements +public class DefaultJdbcRepositoryOperations extends AbstractSqlRepositoryOperations implements JdbcRepositoryOperations, DeleteReturningRepositoryOperations, AsyncCapableRepository, @@ -270,7 +272,7 @@ protected SqlTypeMapper createTupleMapper() { @Override public T persistOne(JdbcOperationContext ctx, T value, RuntimePersistentEntity persistentEntity) { SqlStoredQuery storedQuery = resolveEntityInsert(ctx.annotationMetadata, ctx.repositoryType, (Class) value.getClass(), persistentEntity); - JdbcEntityOperations persistOneOp = new JdbcEntityOperations<>(ctx, storedQuery, persistentEntity, value, true); + JdbcEntityOperations persistOneOp = getJdbcEntityOperations(ctx, persistentEntity, value, storedQuery, true); persistOneOp.persist(); return persistOneOp.getEntity(); } @@ -285,7 +287,7 @@ public List persistBatch(JdbcOperationContext ctx, Iterable values, childPersistentEntity.getIntrospection().getBeanType(), childPersistentEntity ); - JdbcEntitiesOperations persistBatchOp = new JdbcEntitiesOperations<>(ctx, childPersistentEntity, values, storedQuery, true); + JdbcEntitiesOperations persistBatchOp = getJdbcEntitiesOperations(ctx, childPersistentEntity, values, storedQuery, true); persistBatchOp.veto(predicate); persistBatchOp.persist(); return persistBatchOp.getEntities(); @@ -294,7 +296,7 @@ public List persistBatch(JdbcOperationContext ctx, Iterable values, @Override public T updateOne(JdbcOperationContext ctx, T value, RuntimePersistentEntity persistentEntity) { SqlStoredQuery storedQuery = resolveEntityUpdate(ctx.annotationMetadata, ctx.repositoryType, (Class) value.getClass(), persistentEntity); - JdbcEntityOperations op = new JdbcEntityOperations<>(ctx, persistentEntity, value, storedQuery); + JdbcEntityOperations op = getJdbcEntityOperations(ctx, persistentEntity, value, storedQuery); op.update(); return op.getEntity(); } @@ -306,7 +308,7 @@ public void persistManyAssociation(JdbcOperationContext ctx, Object child, RuntimePersistentEntity childPersistentEntity) { SqlStoredQuery storedQuery = resolveSqlInsertAssociation(ctx.repositoryType, runtimeAssociation, persistentEntity, value); try { - new JdbcEntityOperations<>(ctx, childPersistentEntity, child, storedQuery).execute(); + getJdbcEntityOperations(ctx, childPersistentEntity, child, storedQuery).execute(); } catch (Exception e) { throw new DataAccessException("SQL error executing INSERT: " + e.getMessage(), e); } @@ -319,7 +321,7 @@ public void persistManyAssociationBatch(JdbcOperationContext ctx, Iterable child, RuntimePersistentEntity childPersistentEntity) { SqlStoredQuery storedQuery = resolveSqlInsertAssociation(ctx.repositoryType, runtimeAssociation, persistentEntity, value); try { - JdbcEntitiesOperations assocOp = new JdbcEntitiesOperations<>(ctx, childPersistentEntity, child, storedQuery); + JdbcEntitiesOperations assocOp = getJdbcEntitiesOperations(ctx, childPersistentEntity, child, storedQuery); assocOp.veto(ctx.persisted::contains); assocOp.execute(); } catch (Exception e) { @@ -689,14 +691,14 @@ public Optional deleteAll(@NonNull DeleteBatchOperation operation JdbcOperationContext ctx = createContext(operation, connection, storedQuery); RuntimePersistentEntity persistentEntity = storedQuery.getPersistentEntity(); if (isSupportsBatchDelete(persistentEntity, storedQuery.getDialect())) { - JdbcEntitiesOperations op = new JdbcEntitiesOperations<>(ctx, persistentEntity, operation, storedQuery); + JdbcEntitiesOperations op = getJdbcEntitiesOperations(ctx, persistentEntity, operation, storedQuery); op.delete(); return op.rowsUpdated; } return sum( operation.split().stream() .map(deleteOp -> { - JdbcEntityOperations op = new JdbcEntityOperations<>(ctx, persistentEntity, deleteOp.getEntity(), storedQuery); + JdbcEntityOperations op = getJdbcEntityOperations(ctx, persistentEntity, deleteOp.getEntity(), storedQuery); op.delete(); return op.rowsUpdated; }) @@ -709,7 +711,7 @@ public int delete(@NonNull DeleteOperation operation) { return executeWrite(connection -> { SqlStoredQuery storedQuery = getSqlStoredQuery(operation.getStoredQuery()); JdbcOperationContext ctx = createContext(operation, connection, storedQuery); - JdbcEntityOperations op = new JdbcEntityOperations<>(ctx, storedQuery.getPersistentEntity(), operation.getEntity(), storedQuery); + JdbcEntityOperations op = getJdbcEntityOperations(ctx, storedQuery.getPersistentEntity(), operation.getEntity(), storedQuery); op.delete(); return op; }, operation.getAnnotationMetadata()).rowsUpdated; @@ -720,7 +722,7 @@ public R deleteReturning(DeleteReturningOperation operation) { return executeWrite(connection -> { SqlStoredQuery storedQuery = getSqlStoredQuery(operation.getStoredQuery()); JdbcOperationContext ctx = createContext(operation, connection, storedQuery); - JdbcEntityOperations op = new JdbcEntityOperations<>(ctx, storedQuery.getPersistentEntity(), operation.getEntity(), storedQuery); + JdbcEntityOperations op = getJdbcEntityOperations(ctx, storedQuery.getPersistentEntity(), operation.getEntity(), storedQuery); op.delete(); return (R) op.getEntity(); }, operation.getAnnotationMetadata()); @@ -735,13 +737,13 @@ public List deleteAllReturning(DeleteReturningBatchOperation ope // DELETE_RETURNING must use the returning path so returned rows are mapped; Oracle also requires OUT parameters. if (storedQuery.getOperationType() != StoredQuery.OperationType.DELETE_RETURNING && isSupportsBatchDelete(persistentEntity, storedQuery.getDialect())) { - JdbcEntitiesOperations op = new JdbcEntitiesOperations<>(ctx, persistentEntity, operation, storedQuery); + JdbcEntitiesOperations op = getJdbcEntitiesOperations(ctx, persistentEntity, operation, storedQuery); op.delete(); return (List) op.getEntities(); } return (List) operation.split().stream() .map(deleteOp -> { - JdbcEntityOperations op = new JdbcEntityOperations<>(ctx, persistentEntity, deleteOp.getEntity(), storedQuery); + JdbcEntityOperations op = getJdbcEntityOperations(ctx, persistentEntity, deleteOp.getEntity(), storedQuery); op.delete(); return op.getEntity(); }).toList(); @@ -754,7 +756,7 @@ public T update(@NonNull UpdateOperation operation) { return executeWrite(connection -> { SqlStoredQuery storedQuery = getSqlStoredQuery(operation.getStoredQuery()); JdbcOperationContext ctx = createContext(operation, connection, storedQuery); - JdbcEntityOperations op = new JdbcEntityOperations<>(ctx, storedQuery.getPersistentEntity(), operation.getEntity(), storedQuery); + JdbcEntityOperations op = getJdbcEntityOperations(ctx, storedQuery.getPersistentEntity(), operation.getEntity(), storedQuery, isUpsertOperation(storedQuery)); op.update(); return op.getEntity(); }, operation.getAnnotationMetadata()); @@ -771,13 +773,13 @@ public Iterable updateAll(@NonNull UpdateBatchOperation operation) { return operation.split() .stream() .map(updateOp -> { - JdbcEntityOperations op = new JdbcEntityOperations<>(ctx, persistentEntity, updateOp.getEntity(), storedQuery); + JdbcEntityOperations op = getJdbcEntityOperations(ctx, persistentEntity, updateOp.getEntity(), storedQuery, isUpsertOperation(storedQuery)); op.update(); return op.getEntity(); }) .toList(); } - JdbcEntitiesOperations op = new JdbcEntitiesOperations<>(ctx, persistentEntity, operation, storedQuery); + JdbcEntitiesOperations op = getJdbcEntitiesOperations(ctx, persistentEntity, operation, storedQuery, isUpsertOperation(storedQuery)); op.update(); return op.getEntities(); }, operation.getAnnotationMetadata()); @@ -789,7 +791,7 @@ public T persist(@NonNull InsertOperation operation) { return executeWrite(connection -> { final SqlStoredQuery storedQuery = getSqlStoredQuery(operation.getStoredQuery()); JdbcOperationContext ctx = createContext(operation, connection, storedQuery); - JdbcEntityOperations op = new JdbcEntityOperations<>(ctx, storedQuery, storedQuery.getPersistentEntity(), operation.getEntity(), true); + JdbcEntityOperations op = getJdbcEntityOperations(ctx, storedQuery.getPersistentEntity(), operation.getEntity(), storedQuery, true); op.persist(); return op; }, operation.getAnnotationMetadata()).getEntity(); @@ -855,13 +857,13 @@ public Iterable persistAll(@NonNull InsertBatchOperation operation) { if (!isSupportsBatchInsert(persistentEntity, storedQuery)) { return operation.split().stream() .map(persistOp -> { - JdbcEntityOperations op = new JdbcEntityOperations<>(ctx, storedQuery, persistentEntity, persistOp.getEntity(), true); + JdbcEntityOperations op = getJdbcEntityOperations(ctx, persistentEntity, persistOp.getEntity(), storedQuery, true); op.persist(); return op.getEntity(); }) .toList(); } else { - JdbcEntitiesOperations op = new JdbcEntitiesOperations<>(ctx, persistentEntity, operation, storedQuery, true); + JdbcEntitiesOperations op = getJdbcEntitiesOperations(ctx, persistentEntity, operation, storedQuery, true); op.persist(); return op.getEntities(); } @@ -1064,7 +1066,7 @@ public T next() { * if exception is not mappable to {@link DataAccessException} in given dialect {@link SqlExceptionMapper} */ @Nullable - private DataAccessException mapSqlException(SQLException sqlException, Dialect dialect) { + protected DataAccessException mapSqlException(SQLException sqlException, Dialect dialect) { List dialectSqlExceptionMapperList = sqlExceptionMappers.getOrDefault(dialect, List.of()); for (SqlExceptionMapper dialectSqlExceptionMapper : dialectSqlExceptionMapperList) { DataAccessException dataAccessException = dialectSqlExceptionMapper.mapSqlException(sqlException); @@ -1130,7 +1132,7 @@ private JdbcOperationContext createContext(EntityOperation operation, Con * @param dialect the SQL dialect * @return the generated id */ - private Object getGeneratedIdentity(@NonNull ResultSet generatedKeysResultSet, RuntimePersistentProperty identity, Dialect dialect) { + protected Object getGeneratedIdentity(@NonNull ResultSet generatedKeysResultSet, RuntimePersistentProperty identity, Dialect dialect) { if (dialect == Dialect.POSTGRES) { // Postgres returns all fields, not just id, so we need to access generated id by the name return Objects.requireNonNull(columnNameResultSetReader.readDynamic(generatedKeysResultSet, identity.getPersistedName(), identity.getDataType())); @@ -1138,6 +1140,74 @@ private Object getGeneratedIdentity(@NonNull ResultSet generatedKeysResultSet, R return Objects.requireNonNull(columnIndexResultSetReader.readDynamic(generatedKeysResultSet, 1, identity.getDataType())); } + /** + * Checks whether the stored query represents an upsert operation. + * + * @param storedQuery The stored query + * @return true if the stored query is an upsert operation + */ + protected boolean isUpsertOperation(SqlStoredQuery storedQuery) { + return storedQuery.getOperationType() == StoredQuery.OperationType.UPSERT; + } + + /** + * Creates the entity operation for a single write. + * + * @param ctx The operation context + * @param persistentEntity The persistent entity + * @param entity The entity instance + * @param storedQuery The stored query + * @param The entity type + * @return The entity operation + */ + protected JdbcEntityOperations getJdbcEntityOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, T entity, SqlStoredQuery storedQuery) { + return getJdbcEntityOperations(ctx, persistentEntity, entity, storedQuery, false); + } + + /** + * Creates the entity operation for a single write. + * + * @param ctx The operation context + * @param persistentEntity The persistent entity + * @param entity The entity instance + * @param storedQuery The stored query + * @param insert Whether the operation should use insert-generated-id mechanics + * @param The entity type + * @return The entity operation + */ + protected JdbcEntityOperations getJdbcEntityOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, T entity, SqlStoredQuery storedQuery, boolean insert) { + return new JdbcEntityOperations<>(ctx, storedQuery, persistentEntity, entity, insert); + } + + /** + * Creates the entity operation for a batch write. + * + * @param ctx The operation context + * @param persistentEntity The persistent entity + * @param entities The entity instances + * @param storedQuery The stored query + * @param The entity type + * @return The entity operation + */ + protected JdbcEntitiesOperations getJdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery) { + return getJdbcEntitiesOperations(ctx, persistentEntity, entities, storedQuery, false); + } + + /** + * Creates the entity operation for a batch write. + * + * @param ctx The operation context + * @param persistentEntity The persistent entity + * @param entities The entity instances + * @param storedQuery The stored query + * @param insert Whether the operation should use insert-generated-id mechanics + * @param The entity type + * @return The entity operation + */ + protected JdbcEntitiesOperations getJdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery, boolean insert) { + return new JdbcEntitiesOperations<>(ctx, persistentEntity, entities, storedQuery, insert); + } + /** * Handles {@link SQLException} first trying to map it to {@link DataAccessException} using {@link SqlExceptionMapper}. * If mapped exception is not {@link DataAccessException} then returns {@link DataAccessException} using provided fallbackMapper. @@ -1147,7 +1217,7 @@ private Object getGeneratedIdentity(@NonNull ResultSet generatedKeysResultSet, R * @param fallbackMapper The fallback mapper that returns {@link DataAccessException} if {@link SQLException} was not mapped to {@link DataAccessException} * @return DataAccessException */ - private DataAccessException sqlExceptionToDataAccessException(SQLException sqlException, Dialect dialect, Function fallbackMapper) { + protected DataAccessException sqlExceptionToDataAccessException(SQLException sqlException, Dialect dialect, Function fallbackMapper) { DataAccessException dataAccessException = mapSqlException(sqlException, dialect); if (dataAccessException != null) { return dataAccessException; @@ -1195,14 +1265,14 @@ private DataConversionService jdbcDataConversionService() { return conversionService; } - private final class JdbcParameterBinder implements BindableParametersStoredQuery.Binder { + protected class JdbcParameterBinder implements BindableParametersStoredQuery.Binder { private final SqlStoredQuery sqlStoredQuery; private final Connection connection; private final PreparedStatement ps; private int index = 1; - private JdbcParameterBinder(Connection connection, PreparedStatement ps, SqlStoredQuery sqlStoredQuery) { + protected JdbcParameterBinder(Connection connection, PreparedStatement ps, SqlStoredQuery sqlStoredQuery) { this.connection = connection; this.ps = ps; this.sqlStoredQuery = sqlStoredQuery; @@ -1273,18 +1343,14 @@ public int currentIndex() { } - private final class JdbcEntityOperations extends AbstractSyncEntityOperations { + protected class JdbcEntityOperations extends AbstractSyncEntityOperations { - private final SqlStoredQuery storedQuery; - private int rowsUpdated; + protected final SqlStoredQuery storedQuery; + protected int rowsUpdated; @Nullable - private Map previousValues; + protected Map previousValues; - private JdbcEntityOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, T entity, SqlStoredQuery storedQuery) { - this(ctx, storedQuery, persistentEntity, entity, false); - } - - private JdbcEntityOperations(JdbcOperationContext ctx, SqlStoredQuery storedQuery, RuntimePersistentEntity persistentEntity, T entity, boolean insert) { + protected JdbcEntityOperations(JdbcOperationContext ctx, SqlStoredQuery storedQuery, RuntimePersistentEntity persistentEntity, T entity, boolean insert) { super(ctx, DefaultJdbcRepositoryOperations.this.cascadeOperations, entityEventRegistry, persistentEntity, @@ -1409,7 +1475,7 @@ private void executeUpdate() throws SQLException { Object id = getGeneratedIdentity(generatedKeys, identity, storedQuery.getDialect()); BeanProperty property = identity.getProperty(); entity = updateEntityId(property, entity, id); - } else { + } else if (!isUpsertOperation(storedQuery)) { throw new DataAccessException("Failed to generate ID for entity: " + entity); } } @@ -1419,16 +1485,12 @@ private void executeUpdate() throws SQLException { } } - private final class JdbcEntitiesOperations extends AbstractSyncEntitiesOperations { + protected class JdbcEntitiesOperations extends AbstractSyncEntitiesOperations { - private final SqlStoredQuery storedQuery; - private int rowsUpdated; - - private JdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery) { - this(ctx, persistentEntity, entities, storedQuery, false); - } + protected final SqlStoredQuery storedQuery; + protected int rowsUpdated; - private JdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery, boolean insert) { + protected JdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery, boolean insert) { super(ctx, DefaultJdbcRepositoryOperations.this.cascadeOperations, DefaultJdbcRepositoryOperations.this.conversionService, @@ -1446,7 +1508,14 @@ protected void collectAutoPopulatedPreviousValues() { } } - private PreparedStatement prepare(Connection connection) throws SQLException { + /** + * Prepares the batch statement for this operation. + * + * @param connection The JDBC connection + * @return The prepared statement + * @throws SQLException If statement preparation fails + */ + protected PreparedStatement prepare(Connection connection) throws SQLException { if (insert) { Dialect dialect = storedQuery.getDialect(); if (hasGeneratedId && (dialect == Dialect.ORACLE || dialect == Dialect.SQL_SERVER)) { @@ -1504,7 +1573,9 @@ protected void execute() { continue; } if (!iterator.hasNext()) { - throw new DataAccessException("Failed to generate ID for entity: " + d.entity); + if (!isUpsertOperation(storedQuery)) { + throw new DataAccessException("Failed to generate ID for entity: " + d.entity); + } } else { Object id = iterator.next(); d.entity = updateEntityId(identity.getProperty(), d.entity, id); @@ -1528,7 +1599,7 @@ protected static class JdbcOperationContext extends OperationContext { public final Connection connection; public final Dialect dialect; @Nullable - private final InvocationContext invocationContext; + protected final InvocationContext invocationContext; /** * The default constructor. diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/JdbcRepositoryOperationsConditions.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/JdbcRepositoryOperationsConditions.java new file mode 100644 index 00000000000..c6adf82cc40 --- /dev/null +++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/JdbcRepositoryOperationsConditions.java @@ -0,0 +1,159 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.operations; + +import io.micronaut.context.condition.Condition; +import io.micronaut.context.condition.ConditionContext; +import io.micronaut.core.annotation.Internal; +import io.micronaut.data.jdbc.config.DataJdbcConfiguration; +import io.micronaut.data.runtime.support.DataSourceConfigurationUtils; + +import java.util.List; +import java.util.Optional; + +/** + * Condition that enables the default JDBC repository operations for datasources without a specialized implementation. + */ +@Internal +final class DefaultJdbcRepositoryOperationsCondition implements Condition { + + /** + * Checks whether the current datasource is not configured with a dialect that requires specialized JDBC operations. + * + * @param context The condition context + * @return {@code true} when default JDBC operations should be enabled + */ + @Override + public boolean matches(ConditionContext context) { + return JdbcRepositoryOperationsConditions.isDefaultOperationsDialect(context); + } +} + +/** + * Condition that enables Oracle-specific JDBC repository operations for Oracle datasources. + */ +@Internal +final class OracleJdbcRepositoryOperationsCondition implements Condition { + + /** + * Checks whether the current datasource is configured with the Oracle dialect. + * + * @param context The condition context + * @return {@code true} when Oracle JDBC operations should be enabled + */ + @Override + public boolean matches(ConditionContext context) { + return JdbcRepositoryOperationsConditions.isOracleDialect(context); + } +} + +/** + * Condition that enables SQL Server-specific JDBC repository operations for SQL Server datasources. + */ +@Internal +final class SqlServerJdbcRepositoryOperationsCondition implements Condition { + + /** + * Checks whether the current datasource is configured with the SQL Server dialect. + * + * @param context The condition context + * @return {@code true} when SQL Server JDBC operations should be enabled + */ + @Override + public boolean matches(ConditionContext context) { + return JdbcRepositoryOperationsConditions.isSqlServerDialect(context); + } +} + +/** + * Shared condition utilities for selecting the JDBC repository operations bean. + */ +@Internal +final class JdbcRepositoryOperationsConditions { + + private static final String DATASOURCES = "datasources"; + private static final String DIALECT = "dialect"; + private static final String ORACLE_DIALECT = "ORACLE"; + private static final String SQL_SERVER_DIALECT = "SQL_SERVER"; + private static final String DEFAULT = "default"; + + private JdbcRepositoryOperationsConditions() { + } + + /** + * Checks whether the datasource associated with the current bean resolution uses the Oracle dialect. + * + * @param context The condition context + * @return {@code true} when the datasource is configured with {@code datasources..dialect=ORACLE} + */ + static boolean isOracleDialect(ConditionContext context) { + return isDialect(context, ORACLE_DIALECT); + } + + /** + * Checks whether the datasource associated with the current bean resolution uses the SQL Server dialect. + * + * @param context The condition context + * @return {@code true} when the datasource is configured with {@code datasources..dialect=SQL_SERVER} + */ + static boolean isSqlServerDialect(ConditionContext context) { + return isDialect(context, SQL_SERVER_DIALECT); + } + + /** + * Checks whether default JDBC operations should remain available for the current condition evaluation. + * + *

When the condition is evaluated for a specific {@code @EachBean(DataSource)} instance, the + * current datasource qualifier is available and only that datasource is considered. When Micronaut + * evaluates the bean definition before a datasource qualifier is available, all configured + * datasources are considered so mixed datasource applications can still create default operations + * for non-special datasources while Oracle or SQL Server operations handle their own datasources.

+ * + * @param context The condition context + * @return {@code true} when at least the current or one configured datasource should use default operations + */ + static boolean isDefaultOperationsDialect(ConditionContext context) { + Optional dataSourceName = DataSourceConfigurationUtils.resolveDataSourceName(context); + if (dataSourceName.isPresent()) { + return !isDialect(context, dataSourceName.get(), ORACLE_DIALECT) + && !isDialect(context, dataSourceName.get(), SQL_SERVER_DIALECT); + } + List dataSourceNames = DataSourceConfigurationUtils.resolveConfiguredDataSourceNames(context, DATASOURCES, DataJdbcConfiguration.class); + if (dataSourceNames.isEmpty()) { + return true; + } + return dataSourceNames.stream() + .anyMatch(name -> !isDialect(context, name, ORACLE_DIALECT) && !isDialect(context, name, SQL_SERVER_DIALECT)); + } + + private static boolean isDialect(ConditionContext context, String expectedDialect) { + Optional dataSourceName = DataSourceConfigurationUtils.resolveDataSourceName(context); + if (dataSourceName.isPresent()) { + return isDialect(context, dataSourceName.get(), expectedDialect); + } + List dataSourceNames = DataSourceConfigurationUtils.resolveConfiguredDataSourceNames(context, DATASOURCES, DataJdbcConfiguration.class); + if (dataSourceNames.isEmpty()) { + return isDialect(context, DEFAULT, expectedDialect); + } + return dataSourceNames.stream().anyMatch(name -> isDialect(context, name, expectedDialect)); + } + + private static boolean isDialect(ConditionContext context, String dataSourceName, String expectedDialect) { + String dialectProperty = DATASOURCES + '.' + dataSourceName + '.' + DIALECT; + String dialect = context.getProperty(dialectProperty, String.class).orElse(null); + return expectedDialect.equalsIgnoreCase(dialect); + } +} diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/OracleJdbcRepositoryOperations.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/OracleJdbcRepositoryOperations.java new file mode 100644 index 00000000000..b9ba53031fe --- /dev/null +++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/OracleJdbcRepositoryOperations.java @@ -0,0 +1,322 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.operations; + +import io.micronaut.context.BeanContext; +import io.micronaut.context.annotation.EachBean; +import io.micronaut.context.annotation.Parameter; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.data.connection.ConnectionOperations; +import io.micronaut.data.exceptions.DataAccessException; +import io.micronaut.data.jdbc.config.DataJdbcConfiguration; +import io.micronaut.data.jdbc.mapper.JdbcQueryStatement; +import io.micronaut.data.model.DataType; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.model.runtime.AttributeConverterRegistry; +import io.micronaut.data.model.runtime.QueryOutParameterBinding; +import io.micronaut.data.model.runtime.QueryParameterBinding; +import io.micronaut.data.model.runtime.RuntimeEntityRegistry; +import io.micronaut.data.model.runtime.RuntimePersistentEntity; +import io.micronaut.data.model.runtime.RuntimePersistentProperty; +import io.micronaut.data.runtime.convert.DataConversionService; +import io.micronaut.data.runtime.convert.DatabaseConversionContextFactory; +import io.micronaut.data.runtime.date.DateTimeProvider; +import io.micronaut.data.runtime.multitenancy.SchemaTenantResolver; +import io.micronaut.data.runtime.operations.internal.sql.SqlJsonColumnMapperProvider; +import io.micronaut.data.runtime.operations.internal.sql.SqlStoredQuery; +import io.micronaut.json.JsonMapper; +import io.micronaut.transaction.TransactionOperations; +import jakarta.inject.Named; +import oracle.jdbc.OraclePreparedStatement; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; + +/** + * Oracle-specific JDBC repository operations. + * + *

This implementation extends {@link DefaultJdbcRepositoryOperations} so Oracle can reuse the + * standard JDBC repository behavior and only replace the entity operation objects that need Oracle + * driver support. In particular, Oracle {@code MERGE} statements with {@code RETURNING ... INTO} + * require {@link OraclePreparedStatement#registerReturnParameter(int, int)} and + * {@link OraclePreparedStatement#getReturnResultSet()}, which are not available through standard + * JDBC. The default implementation continues to handle all non-Oracle-specific paths, while this + * class overrides the single-entity and batch entity operations for Oracle upsert generated-id + * returning.

+ */ +@EachBean(DataSource.class) +@Requires(classes = OraclePreparedStatement.class) +@Requires(condition = OracleJdbcRepositoryOperationsCondition.class) +final class OracleJdbcRepositoryOperations extends DefaultJdbcRepositoryOperations { + + /** + * Default constructor. + * + * @param dataSourceName The data source name + * @param jdbcConfiguration The jdbcConfiguration + * @param dataSource The datasource + * @param connectionOperations The connection operations + * @param transactionOperations The JDBC operations for the data source + * @param executorService The executor service + * @param beanContext The bean context + * @param dateTimeProvider The dateTimeProvider + * @param entityRegistry The entity registry + * @param conversionService The conversion service + * @param attributeConverterRegistry The attribute converter registry + * @param schemaTenantResolver The schema tenant resolver + * @param schemaHandler The schema handler + * @param jsonMapper The JSON mapper + * @param sqlJsonColumnMapperProvider The SQL JSON column mapper provider + * @param conversionContextFactory The conversion context factory + * @param sqlExceptionMapperList The SQL exception mapper list + */ + @SuppressWarnings("ParameterNumber") + OracleJdbcRepositoryOperations(@Parameter String dataSourceName, + @Parameter DataJdbcConfiguration jdbcConfiguration, + DataSource dataSource, + @Parameter ConnectionOperations connectionOperations, + @Parameter TransactionOperations transactionOperations, + @Named("io") @Nullable ExecutorService executorService, + BeanContext beanContext, + @NonNull DateTimeProvider dateTimeProvider, + RuntimeEntityRegistry entityRegistry, + DataConversionService conversionService, + AttributeConverterRegistry attributeConverterRegistry, + @Nullable SchemaTenantResolver schemaTenantResolver, + JdbcSchemaHandler schemaHandler, + @Nullable JsonMapper jsonMapper, + SqlJsonColumnMapperProvider sqlJsonColumnMapperProvider, + @Parameter DatabaseConversionContextFactory conversionContextFactory, + List sqlExceptionMapperList) { + super( + dataSourceName, + jdbcConfiguration, + dataSource, + connectionOperations, + transactionOperations, + executorService, + beanContext, + dateTimeProvider, + entityRegistry, + conversionService, + attributeConverterRegistry, + schemaTenantResolver, + schemaHandler, + jsonMapper, + sqlJsonColumnMapperProvider, + conversionContextFactory, + sqlExceptionMapperList + ); + } + + @Override + protected JdbcEntityOperations getJdbcEntityOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, T entity, SqlStoredQuery storedQuery) { + return getJdbcEntityOperations(ctx, persistentEntity, entity, storedQuery, false); + } + + @Override + protected JdbcEntityOperations getJdbcEntityOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, T entity, SqlStoredQuery storedQuery, boolean insert) { + return new OracleJdbcEntityOperations<>(ctx, persistentEntity, entity, storedQuery, insert); + } + + @Override + protected JdbcEntitiesOperations getJdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery) { + return getJdbcEntitiesOperations(ctx, persistentEntity, entities, storedQuery, false); + } + + @Override + protected JdbcEntitiesOperations getJdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery, boolean insert) { + return new OracleJdbcEntitiesOperations<>(ctx, persistentEntity, entities, storedQuery, insert); + } + + private boolean shouldUseOracleUpsertReturning(SqlStoredQuery storedQuery) { + return storedQuery.getDialect() == Dialect.ORACLE + && isUpsertOperation(storedQuery) + && CollectionUtils.isNotEmpty(storedQuery.getOutParameterBindings()); + } + + private void registerReturnParameters(OraclePreparedStatement ps, + SqlStoredQuery query, + int inCount) throws SQLException { + List outParams = query.getOutParameterBindings(); + if (CollectionUtils.isEmpty(outParams)) { + throw new DataAccessException("Missing OUT parameter metadata for Oracle RETURNING. SqlQueryBuilder must attach QueryOutParameterBinding list."); + } + int pos = inCount; + for (QueryOutParameterBinding outParam : outParams) { + DataType dataType = query.getDialect().getDataType(outParam.dataType()); + int sqlType = JdbcQueryStatement.findSqlType(dataType, query.getDialect()); + if (sqlType == -1) { + sqlType = Types.VARCHAR; + } + ps.registerReturnParameter(++pos, sqlType); + } + } + + private OraclePreparedStatement unwrapOraclePreparedStatement(PreparedStatement ps) throws SQLException { + return ps.unwrap(OraclePreparedStatement.class); + } + + private int bindParameters(PreparedStatement ps, + JdbcOperationContext ctx, + SqlStoredQuery storedQuery, + T entity, + @Nullable Map previousValues) { + JdbcParameterBinder parameterBinder = new JdbcParameterBinder(ctx.connection, ps, storedQuery); + storedQuery.bindParameters(parameterBinder, ctx.invocationContext, entity, previousValues); + return parameterBinder.currentIndex() - 1; + } + + private Object readReturnedId(OraclePreparedStatement oraclePreparedStatement, + RuntimePersistentProperty identity, + SqlStoredQuery storedQuery, + Object entity) throws SQLException { + List ids = readReturnedIds(oraclePreparedStatement, identity, storedQuery); + if (ids.isEmpty()) { + throw new DataAccessException("Oracle upsert RETURNING clause produced no generated ID for entity: " + entity); + } else if (ids.size() != 1) { + throw new DataAccessException("Oracle upsert RETURNING clause produced " + ids.size() + " generated IDs for a single entity: " + entity); + } + return ids.getFirst(); + } + + private List readReturnedIds(OraclePreparedStatement oraclePreparedStatement, + RuntimePersistentProperty identity, + SqlStoredQuery storedQuery) throws SQLException { + List ids = new ArrayList<>(); + try (ResultSet resultSet = oraclePreparedStatement.getReturnResultSet()) { + while (resultSet.next()) { + ids.add(getGeneratedIdentity(resultSet, identity, storedQuery.getDialect())); + } + } + return ids; + } + + protected class OracleJdbcEntityOperations extends JdbcEntityOperations { + protected OracleJdbcEntityOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, T entity, SqlStoredQuery storedQuery, boolean insert) { + super(ctx, storedQuery, persistentEntity, entity, insert); + } + + @Override + protected void execute() throws SQLException { + if (shouldUseOracleUpsertReturning(storedQuery)) { + upsert(); + } else { + super.execute(); + } + } + + private void upsert() throws SQLException { + QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); + try (PreparedStatement ps = ctx.connection.prepareStatement(storedQuery.getQuery())) { + OraclePreparedStatement oraclePreparedStatement = unwrapOraclePreparedStatement(ps); + int inCount = bindParameters(ps, ctx, storedQuery, entity, previousValues); + registerReturnParameters(oraclePreparedStatement, storedQuery, inCount); + rowsUpdated = oraclePreparedStatement.executeUpdate(); + RuntimePersistentProperty identity = persistentEntity.getIdentity(); + Object id = readReturnedId(oraclePreparedStatement, identity, storedQuery, entity); + entity = updateEntityId(identity.getProperty(), entity, id); + } catch (SQLException e) { + DataAccessException dataAccessException = mapSqlException(e, ctx.dialect); + if (dataAccessException != null) { + throw dataAccessException; + } + throw e; + } + } + } + + protected class OracleJdbcEntitiesOperations extends JdbcEntitiesOperations { + protected OracleJdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery, boolean insert) { + super(ctx, persistentEntity, entities, storedQuery, insert); + } + + @Override + protected void execute() { + if (shouldUseOracleUpsertReturning(storedQuery)) { + upsert(); + } else { + super.execute(); + } + } + + private void upsert() { + QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); + List notVetoedEntities = notVetoedEntities(); + if (notVetoedEntities.isEmpty()) { + rowsUpdated = 0; + return; + } + try (PreparedStatement ps = ctx.connection.prepareStatement(storedQuery.getQuery())) { + OraclePreparedStatement oraclePreparedStatement = unwrapOraclePreparedStatement(ps); + boolean returnParametersRegistered = false; + for (Data d : notVetoedEntities) { + int inCount = bindParameters(ps, ctx, storedQuery, d.entity, d.previousValues); + if (!returnParametersRegistered) { + registerReturnParameters(oraclePreparedStatement, storedQuery, inCount); + returnParametersRegistered = true; + } + ps.addBatch(); + } + rowsUpdated = Arrays.stream(ps.executeBatch()).sum(); + updateEntityIdsFromReturnedIds(oraclePreparedStatement, notVetoedEntities); + } catch (SQLException e) { + throw sqlExceptionToDataAccessException(e, ctx.dialect, + sqlException -> new DataAccessException( + "Error executing upsert statement: " + sqlException.getMessage(), + sqlException + ) + ); + } + } + + private void updateEntityIdsFromReturnedIds(OraclePreparedStatement oraclePreparedStatement, + List notVetoedEntities) throws SQLException { + RuntimePersistentProperty identity = persistentEntity.getIdentity(); + List ids = readReturnedIds(oraclePreparedStatement, identity, storedQuery); + Iterator iterator = ids.iterator(); + int updated = 0; + for (Data d : notVetoedEntities) { + if (!iterator.hasNext()) { + throw new DataAccessException("Oracle upsert RETURNING clause produced " + updated + " generated IDs for " + notVetoedEntities.size() + " entities"); + } + Object id = iterator.next(); + d.entity = updateEntityId(identity.getProperty(), d.entity, id); + updated++; + } + if (iterator.hasNext()) { + throw new DataAccessException("Oracle upsert RETURNING clause produced more generated IDs than entities"); + } + } + + private List notVetoedEntities() { + return entities.stream().filter(d -> !d.vetoed).toList(); + } + } +} diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/SqlServerJdbcRepositoryOperations.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/SqlServerJdbcRepositoryOperations.java new file mode 100644 index 00000000000..8c17f874e47 --- /dev/null +++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/SqlServerJdbcRepositoryOperations.java @@ -0,0 +1,261 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.operations; + +import io.micronaut.context.BeanContext; +import io.micronaut.context.annotation.EachBean; +import io.micronaut.context.annotation.Parameter; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.data.connection.ConnectionOperations; +import io.micronaut.data.exceptions.DataAccessException; +import io.micronaut.data.jdbc.config.DataJdbcConfiguration; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.model.runtime.AttributeConverterRegistry; +import io.micronaut.data.model.runtime.QueryParameterBinding; +import io.micronaut.data.model.runtime.RuntimeEntityRegistry; +import io.micronaut.data.model.runtime.RuntimePersistentEntity; +import io.micronaut.data.model.runtime.RuntimePersistentProperty; +import io.micronaut.data.runtime.convert.DataConversionService; +import io.micronaut.data.runtime.convert.DatabaseConversionContextFactory; +import io.micronaut.data.runtime.date.DateTimeProvider; +import io.micronaut.data.runtime.multitenancy.SchemaTenantResolver; +import io.micronaut.data.runtime.operations.internal.sql.SqlJsonColumnMapperProvider; +import io.micronaut.data.runtime.operations.internal.sql.SqlStoredQuery; +import io.micronaut.json.JsonMapper; +import io.micronaut.transaction.TransactionOperations; +import jakarta.inject.Named; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; + +/** + * SQL Server-specific JDBC repository operations. + * + *

This implementation extends {@link DefaultJdbcRepositoryOperations} so SQL Server can reuse + * the standard JDBC repository behavior and only replace the entity operation objects that need + * SQL Server-specific upsert generated-id handling. SQL Server {@code MERGE ... OUTPUT inserted.id} + * returns generated IDs as a result set, which means the upsert path needs to execute the statement + * as a query and read that result instead of relying on {@link PreparedStatement#getGeneratedKeys()}.

+ */ +@EachBean(DataSource.class) +@Requires(condition = SqlServerJdbcRepositoryOperationsCondition.class) +final class SqlServerJdbcRepositoryOperations extends DefaultJdbcRepositoryOperations { + + /** + * Default constructor. + * + * @param dataSourceName The data source name + * @param jdbcConfiguration The jdbcConfiguration + * @param dataSource The datasource + * @param connectionOperations The connection operations + * @param transactionOperations The JDBC operations for the data source + * @param executorService The executor service + * @param beanContext The bean context + * @param dateTimeProvider The dateTimeProvider + * @param entityRegistry The entity registry + * @param conversionService The conversion service + * @param attributeConverterRegistry The attribute converter registry + * @param schemaTenantResolver The schema tenant resolver + * @param schemaHandler The schema handler + * @param jsonMapper The JSON mapper + * @param sqlJsonColumnMapperProvider The SQL JSON column mapper provider + * @param conversionContextFactory The conversion context factory + * @param sqlExceptionMapperList The SQL exception mapper list + */ + @SuppressWarnings("ParameterNumber") + SqlServerJdbcRepositoryOperations(@Parameter String dataSourceName, + @Parameter DataJdbcConfiguration jdbcConfiguration, + DataSource dataSource, + @Parameter ConnectionOperations connectionOperations, + @Parameter TransactionOperations transactionOperations, + @Named("io") @Nullable ExecutorService executorService, + BeanContext beanContext, + @NonNull DateTimeProvider dateTimeProvider, + RuntimeEntityRegistry entityRegistry, + DataConversionService conversionService, + AttributeConverterRegistry attributeConverterRegistry, + @Nullable SchemaTenantResolver schemaTenantResolver, + JdbcSchemaHandler schemaHandler, + @Nullable JsonMapper jsonMapper, + SqlJsonColumnMapperProvider sqlJsonColumnMapperProvider, + @Parameter DatabaseConversionContextFactory conversionContextFactory, + List sqlExceptionMapperList) { + super( + dataSourceName, + jdbcConfiguration, + dataSource, + connectionOperations, + transactionOperations, + executorService, + beanContext, + dateTimeProvider, + entityRegistry, + conversionService, + attributeConverterRegistry, + schemaTenantResolver, + schemaHandler, + jsonMapper, + sqlJsonColumnMapperProvider, + conversionContextFactory, + sqlExceptionMapperList + ); + } + + @Override + protected JdbcEntityOperations getJdbcEntityOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, T entity, SqlStoredQuery storedQuery) { + return getJdbcEntityOperations(ctx, persistentEntity, entity, storedQuery, false); + } + + @Override + protected JdbcEntityOperations getJdbcEntityOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, T entity, SqlStoredQuery storedQuery, boolean insert) { + return new SqlServerJdbcEntityOperations<>(ctx, persistentEntity, entity, storedQuery, insert); + } + + @Override + protected JdbcEntitiesOperations getJdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery) { + return getJdbcEntitiesOperations(ctx, persistentEntity, entities, storedQuery, false); + } + + @Override + protected JdbcEntitiesOperations getJdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery, boolean insert) { + return new SqlServerJdbcEntitiesOperations<>(ctx, persistentEntity, entities, storedQuery, insert); + } + + private boolean shouldUseSqlServerUpsertReturning(SqlStoredQuery storedQuery) { + return storedQuery.getDialect() == Dialect.SQL_SERVER + && isUpsertOperation(storedQuery) + && CollectionUtils.isNotEmpty(storedQuery.getOutParameterBindings()); + } + + private void bindParameters(PreparedStatement ps, + JdbcOperationContext ctx, + SqlStoredQuery storedQuery, + T entity, + @Nullable Map previousValues) { + JdbcParameterBinder parameterBinder = new JdbcParameterBinder(ctx.connection, ps, storedQuery); + storedQuery.bindParameters(parameterBinder, ctx.invocationContext, entity, previousValues); + } + + private Object readReturnedId(ResultSet resultSet, + RuntimePersistentProperty identity, + SqlStoredQuery storedQuery, + Object entity) throws SQLException { + List ids = readReturnedIds(resultSet, identity, storedQuery); + if (ids.isEmpty()) { + throw new DataAccessException("SQL Server upsert OUTPUT clause produced no generated ID for entity: " + entity); + } else if (ids.size() != 1) { + throw new DataAccessException("SQL Server upsert OUTPUT clause produced " + ids.size() + " generated IDs for a single entity: " + entity); + } + return ids.getFirst(); + } + + private List readReturnedIds(ResultSet resultSet, + RuntimePersistentProperty identity, + SqlStoredQuery storedQuery) throws SQLException { + List ids = new ArrayList<>(); + while (resultSet.next()) { + ids.add(getGeneratedIdentity(resultSet, identity, storedQuery.getDialect())); + } + return ids; + } + + protected class SqlServerJdbcEntityOperations extends JdbcEntityOperations { + protected SqlServerJdbcEntityOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, T entity, SqlStoredQuery storedQuery, boolean insert) { + super(ctx, storedQuery, persistentEntity, entity, insert); + } + + @Override + protected void execute() throws SQLException { + if (shouldUseSqlServerUpsertReturning(storedQuery)) { + upsert(); + } else { + super.execute(); + } + } + + private void upsert() throws SQLException { + QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); + try { + try (PreparedStatement ps = ctx.connection.prepareStatement(storedQuery.getQuery())) { + bindParameters(ps, ctx, storedQuery, entity, previousValues); + RuntimePersistentProperty identity = persistentEntity.getIdentity(); + try (ResultSet resultSet = ps.executeQuery()) { + Object id = readReturnedId(resultSet, identity, storedQuery, entity); + entity = updateEntityId(identity.getProperty(), entity, id); + rowsUpdated = 1; + } + } + } catch (SQLException e) { + DataAccessException dataAccessException = mapSqlException(e, ctx.dialect); + if (dataAccessException != null) { + throw dataAccessException; + } + throw e; + } + } + } + + protected class SqlServerJdbcEntitiesOperations extends JdbcEntitiesOperations { + protected SqlServerJdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery, boolean insert) { + super(ctx, persistentEntity, entities, storedQuery, insert); + } + + @Override + protected void execute() { + if (shouldUseSqlServerUpsertReturning(storedQuery)) { + upsert(); + } else { + super.execute(); + } + } + + private void upsert() { + QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); + try (PreparedStatement ps = ctx.connection.prepareStatement(storedQuery.getQuery())) { + RuntimePersistentProperty identity = persistentEntity.getIdentity(); + for (Data d : entities) { + if (d.vetoed) { + continue; + } + bindParameters(ps, ctx, storedQuery, d.entity, d.previousValues); + try (ResultSet resultSet = ps.executeQuery()) { + Object id = readReturnedId(resultSet, identity, storedQuery, d.entity); + d.entity = updateEntityId(identity.getProperty(), d.entity, id); + rowsUpdated++; + } + ps.clearParameters(); + } + } catch (SQLException e) { + throw sqlExceptionToDataAccessException(e, ctx.dialect, + sqlException -> new DataAccessException( + "Error executing upsert statement: " + sqlException.getMessage(), + sqlException + ) + ); + } + } + } +} diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2UpsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2UpsertSpec.groovy new file mode 100644 index 00000000000..742a4e2f727 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2UpsertSpec.groovy @@ -0,0 +1,54 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.h2 + +import io.micronaut.data.jdbc.h2.upsert.H2CustomerProfileRepository +import io.micronaut.data.jdbc.h2.upsert.H2CustomerProfileUuidRepository +import io.micronaut.data.jdbc.h2.upsert.H2ProductReviewRepository +import io.micronaut.data.jdbc.h2.upsert.H2WarehouseInventoryRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class H2UpsertSpec extends AbstractUpsertSpec implements H2TestPropertyProvider { + + @Override + ProductReviewRepository getProductReviewRepository() { + return context.getBean(H2ProductReviewRepository) + } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(H2CustomerProfileRepository) + } + + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(H2CustomerProfileUuidRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(H2WarehouseInventoryRepository) + } + + @Override + List packages() { + return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert") + } +} diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaUpsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaUpsertSpec.groovy new file mode 100644 index 00000000000..aa53130f7dd --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaUpsertSpec.groovy @@ -0,0 +1,59 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.mariadb + +import io.micronaut.data.jdbc.mysql.upsert.MySqlCustomerProfileRepository +import io.micronaut.data.jdbc.mysql.upsert.MySqlCustomerProfileUuidRepository +import io.micronaut.data.jdbc.mysql.upsert.MySqlProductReviewRepository +import io.micronaut.data.jdbc.mysql.upsert.MySqlWarehouseInventoryRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class MariaUpsertSpec extends AbstractUpsertSpec implements MariaTestPropertyProvider { + + @Override + ProductReviewRepository getProductReviewRepository() { + return context.getBean(MySqlProductReviewRepository) + } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(MySqlCustomerProfileRepository) + } + + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(MySqlCustomerProfileUuidRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(MySqlWarehouseInventoryRepository) + } + + @Override + List packages() { + return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert") + } + + @Override + protected boolean supportsGeneratedUuidReturning() { + return false + } +} diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlUpsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlUpsertSpec.groovy new file mode 100644 index 00000000000..66694a56efa --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlUpsertSpec.groovy @@ -0,0 +1,59 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.mysql + +import io.micronaut.data.jdbc.mysql.upsert.MySqlCustomerProfileRepository +import io.micronaut.data.jdbc.mysql.upsert.MySqlCustomerProfileUuidRepository +import io.micronaut.data.jdbc.mysql.upsert.MySqlProductReviewRepository +import io.micronaut.data.jdbc.mysql.upsert.MySqlWarehouseInventoryRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class MySqlUpsertSpec extends AbstractUpsertSpec implements MySQLTestPropertyProvider { + + @Override + ProductReviewRepository getProductReviewRepository() { + return context.getBean(MySqlProductReviewRepository) + } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(MySqlCustomerProfileRepository) + } + + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(MySqlCustomerProfileUuidRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(MySqlWarehouseInventoryRepository) + } + + @Override + List packages() { + return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert") + } + + @Override + protected boolean supportsGeneratedUuidReturning() { + return false + } +} diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/operations/JdbcRepositoryOperationsConditionsSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/operations/JdbcRepositoryOperationsConditionsSpec.groovy new file mode 100644 index 00000000000..139ff14406b --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/operations/JdbcRepositoryOperationsConditionsSpec.groovy @@ -0,0 +1,210 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.operations + +import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Factory +import io.micronaut.context.annotation.Requires +import io.micronaut.inject.BeanDefinitionReference +import io.micronaut.inject.qualifiers.Qualifiers +import jakarta.inject.Named +import jakarta.inject.Singleton +import spock.lang.Specification + +import javax.sql.DataSource +import java.sql.Connection +import java.sql.SQLException +import java.sql.SQLFeatureNotSupportedException +import java.util.logging.Logger + +class JdbcRepositoryOperationsConditionsSpec extends Specification { + + void "default operations are selected for non-special dialect #dialect"() { + given: + ApplicationContext context = contextWithDataSource('default', dialect) + + expect: + context.getBean(JdbcRepositoryOperations, Qualifiers.byName('default')) instanceof DefaultJdbcRepositoryOperations + + cleanup: + context.close() + + where: + dialect << ['H2', 'MYSQL'] + } + + void "oracle operations are selected for oracle dialect #dialect"() { + given: + ApplicationContext context = contextWithDataSource('default', dialect) + + expect: + context.getBean(JdbcRepositoryOperations, Qualifiers.byName('default')) instanceof OracleJdbcRepositoryOperations + + cleanup: + context.close() + + where: + dialect << ['ORACLE', 'oracle'] + } + + void "sql server operations are selected for sql server dialect #dialect"() { + given: + ApplicationContext context = contextWithDataSource('default', dialect) + + expect: + context.getBean(JdbcRepositoryOperations, Qualifiers.byName('default')) instanceof SqlServerJdbcRepositoryOperations + + cleanup: + context.close() + + where: + dialect << ['SQL_SERVER', 'sql_server'] + } + + void "operations condition uses named datasource dialect #dialect"() { + given: + ApplicationContext context = applicationContextBuilder([ + 'datasources.default.dialect': 'H2', + 'datasources.default.enabled': true, + 'datasources.mdb.enabled' : true, + 'datasources.mdb.dialect' : dialect + ]) + context.start() + + expect: + context.getBean(JdbcRepositoryOperations, Qualifiers.byName('default')) instanceof DefaultJdbcRepositoryOperations + context.getBean(JdbcRepositoryOperations, Qualifiers.byName('mdb')).class == operationsType + + cleanup: + context.close() + + where: + dialect | operationsType + 'ORACLE' | OracleJdbcRepositoryOperations + 'SQL_SERVER' | SqlServerJdbcRepositoryOperations + } + + void "default operations are selected for named non-special datasource when default is #specialDialect"() { + given: + ApplicationContext context = applicationContextBuilder([ + 'datasources.default.dialect': specialDialect, + 'datasources.default.enabled': true, + 'datasources.mdb.enabled' : true, + 'datasources.mdb.dialect' : 'H2' + ]) + context.start() + + expect: + context.getBean(JdbcRepositoryOperations, Qualifiers.byName('default')).class == specialOperationsType + context.getBean(JdbcRepositoryOperations, Qualifiers.byName('mdb')) instanceof DefaultJdbcRepositoryOperations + + cleanup: + context.close() + + where: + specialDialect | specialOperationsType + 'ORACLE' | OracleJdbcRepositoryOperations + 'SQL_SERVER' | SqlServerJdbcRepositoryOperations + } + + private ApplicationContext contextWithDataSource(String dataSourceName, String dialect) { + ApplicationContext context = applicationContextBuilder([ + ('datasources.' + dataSourceName + '.enabled'): true, + ('datasources.' + dataSourceName + '.dialect') : dialect + ]) + context.start() + return context + } + + private ApplicationContext applicationContextBuilder(Map properties) { + return ApplicationContext.builder(properties + [ + 'micronaut.test.resources.enabled' : false, + 'jdbc.repository.operations.conditions.stub-datasources': true + ]).beansPredicate(beanType -> { + if (beanType instanceof BeanDefinitionReference) { + String beanDefinitionName = beanType.beanDefinitionName + return !beanDefinitionName.contains('io.micronaut.configuration.jdbc.hikari.$DatasourceFactory') + && !beanDefinitionName.contains('io.micronaut.configuration.jdbc.tomcat.$DatasourceFactory') + } + return true + }).build() + } + + @Factory + @Requires(property = 'jdbc.repository.operations.conditions.stub-datasources', value = 'true') + static class StubDataSourceFactory { + + @Singleton + @Named('default') + @Requires(property = 'datasources.default.enabled', value = 'true') + DataSource defaultDataSource() { + return new StubDataSource() + } + + @Singleton + @Named('mdb') + @Requires(property = 'datasources.mdb.enabled', value = 'true') + DataSource mdbDataSource() { + return new StubDataSource() + } + } + + private static final class StubDataSource implements DataSource { + + @Override + Connection getConnection() throws SQLException { + throw new SQLFeatureNotSupportedException() + } + + @Override + Connection getConnection(String username, String password) throws SQLException { + throw new SQLFeatureNotSupportedException() + } + + @Override + PrintWriter getLogWriter() throws SQLException { + return null + } + + @Override + void setLogWriter(PrintWriter out) throws SQLException { + } + + @Override + void setLoginTimeout(int seconds) throws SQLException { + } + + @Override + int getLoginTimeout() throws SQLException { + return 0 + } + + @Override + Logger getParentLogger() throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException() + } + + @Override + T unwrap(Class iface) throws SQLException { + throw new SQLFeatureNotSupportedException() + } + + @Override + boolean isWrapperFor(Class iface) throws SQLException { + return false + } + } +} diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/OracleXEUpsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/OracleXEUpsertSpec.groovy new file mode 100644 index 00000000000..ba84d710cee --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/OracleXEUpsertSpec.groovy @@ -0,0 +1,153 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.oraclexe + +import io.micronaut.data.jdbc.oraclexe.upsert.CustomerProfileSequence +import io.micronaut.data.jdbc.oraclexe.upsert.OracleXECustomerProfileRepository +import io.micronaut.data.jdbc.oraclexe.upsert.OracleXECustomerProfileSequenceRepository +import io.micronaut.data.jdbc.oraclexe.upsert.OracleXECustomerProfileUuidRepository +import io.micronaut.data.jdbc.oraclexe.upsert.OracleXEProductReviewRepository +import io.micronaut.data.jdbc.oraclexe.upsert.OracleXEWarehouseInventoryRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleTestPropertyProvider { + + @Override + ProductReviewRepository getProductReviewRepository() { + return context.getBean(OracleXEProductReviewRepository) + } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(OracleXECustomerProfileRepository) + } + + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(OracleXECustomerProfileUuidRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(OracleXEWarehouseInventoryRepository) + } + + OracleXECustomerProfileSequenceRepository getCustomerProfileSequenceRepository() { + return context.getBean(OracleXECustomerProfileSequenceRepository) + } + + @Override + List packages() { + return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert", "io.micronaut.data.jdbc.oraclexe.upsert") + } + + @Override + protected void cleanupAdditionalRepositories() { + customerProfileSequenceRepository.deleteAll() + } + + void "upsert by email conflict returns entity when sequence id is used"() { + given: + CustomerProfileSequence cp = new CustomerProfileSequence("test@example.com", "test") + + when: + CustomerProfileSequence inserted = customerProfileSequenceRepository.upsert(cp) + + then: + inserted.id != null + inserted == cp + + when: + CustomerProfileSequence found = customerProfileSequenceRepository.findById(cp.id).get() + + then: + assertCustomerProfileSequence(cp, found) + + when: + cp.setDisplayName("test modified") + CustomerProfileSequence updated = customerProfileSequenceRepository.upsert(cp) + + then: + updated == cp + + when: + found = customerProfileSequenceRepository.findById(cp.id).get() + + then: + assertCustomerProfileSequence(cp, found) + } + + void "upsertAll by email conflict returns entities when sequence id is used"() { + given: + CustomerProfileSequence cp1 = new CustomerProfileSequence("test1@example.com", "test 1") + CustomerProfileSequence cp2 = new CustomerProfileSequence("test2@example.com", "test 2") + + when: + List inserted = customerProfileSequenceRepository.upsertAll([cp1, cp2]) + + then: + inserted.size() == 2 + inserted.get(0).id != null + inserted.get(1).id != null + inserted.get(0) == cp1 + inserted.get(1) == cp2 + + when: + CustomerProfileSequence found1 = customerProfileSequenceRepository.findById(cp1.id).get() + CustomerProfileSequence found2 = customerProfileSequenceRepository.findById(cp2.id).get() + + then: + assertCustomerProfileSequence(found1, cp1) + assertCustomerProfileSequence(found2, cp2) + + when: + cp1.setDisplayName("test 1 modified") + cp2.setDisplayName("test 2 modified") + CustomerProfileSequence cp3 = new CustomerProfileSequence("test3@example.com", "test 3") + CustomerProfileSequence cp4 = new CustomerProfileSequence("test4@example.com", "test 4") + List updated = customerProfileSequenceRepository.upsertAll([cp1, cp2, cp3, cp4]) + + then: + updated.size() == 4 + updated.get(0) == cp1 + updated.get(1) == cp2 + updated.get(2).id != null + updated.get(3).id != null + updated.get(2) == cp3 + updated.get(3) == cp4 + + when: + found1 = customerProfileSequenceRepository.findById(cp1.id).get() + found2 = customerProfileSequenceRepository.findById(cp2.id).get() + CustomerProfileSequence found3 = customerProfileSequenceRepository.findById(cp3.id).get() + CustomerProfileSequence found4 = customerProfileSequenceRepository.findById(cp4.id).get() + + then: + assertCustomerProfileSequence(found1, cp1) + assertCustomerProfileSequence(found2, cp2) + assertCustomerProfileSequence(found3, cp3) + assertCustomerProfileSequence(found4, cp4) + } + + private static void assertCustomerProfileSequence(CustomerProfileSequence customerProfile1, CustomerProfileSequence customerProfile2) { + assert customerProfile1.email == customerProfile2.email + assert customerProfile1.displayName == customerProfile2.displayName + } +} diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/postgres/PostgresUpsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/postgres/PostgresUpsertSpec.groovy new file mode 100644 index 00000000000..cf96c34b4d1 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/postgres/PostgresUpsertSpec.groovy @@ -0,0 +1,148 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.postgres + +import io.micronaut.data.jdbc.postgres.upsert.CustomerProfileSequence +import io.micronaut.data.jdbc.postgres.upsert.PostgresCustomerProfileRepository +import io.micronaut.data.jdbc.postgres.upsert.PostgresCustomerProfileSequenceRepository +import io.micronaut.data.jdbc.postgres.upsert.PostgresCustomerProfileUuidRepository +import io.micronaut.data.jdbc.postgres.upsert.PostgresProductReviewRepository +import io.micronaut.data.jdbc.postgres.upsert.PostgresWarehouseInventoryRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPropertyProvider { + + @Override + ProductReviewRepository getProductReviewRepository() { + return context.getBean(PostgresProductReviewRepository) + } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(PostgresCustomerProfileRepository) + } + + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(PostgresCustomerProfileUuidRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(PostgresWarehouseInventoryRepository) + } + + PostgresCustomerProfileSequenceRepository getCustomerProfileSequenceRepository() { + return context.getBean(PostgresCustomerProfileSequenceRepository) + } + + @Override + List packages() { + return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert", "io.micronaut.data.jdbc.postgres.upsert") + } + + void "upsert by email conflict returns entity when sequence id is used"() { + given: + CustomerProfileSequence cp = new CustomerProfileSequence("test@example.com", "test") + + when: + CustomerProfileSequence inserted = customerProfileSequenceRepository.upsert(cp) + + then: + inserted.id != null + inserted == cp + + when: + CustomerProfileSequence found = customerProfileSequenceRepository.findById(cp.id).get() + + then: + assertCustomerProfileSequence(cp, found) + + when: + cp.setDisplayName("test modified") + CustomerProfileSequence updated = customerProfileSequenceRepository.upsert(cp) + + then: + updated == cp + + when: + found = customerProfileSequenceRepository.findById(cp.id).get() + + then: + assertCustomerProfileSequence(cp, found) + } + + void "upsertAll by email conflict returns entities when sequence id is used"() { + given: + CustomerProfileSequence cp1 = new CustomerProfileSequence("test1@example.com", "test 1") + CustomerProfileSequence cp2 = new CustomerProfileSequence("test2@example.com", "test 2") + + when: + List inserted = customerProfileSequenceRepository.upsertAll([cp1, cp2]) + + then: + inserted.size() == 2 + inserted.get(0).id != null + inserted.get(1).id != null + inserted.get(0) == cp1 + inserted.get(1) == cp2 + + when: + CustomerProfileSequence found1 = customerProfileSequenceRepository.findById(cp1.id).get() + CustomerProfileSequence found2 = customerProfileSequenceRepository.findById(cp2.id).get() + + then: + assertCustomerProfileSequence(found1, cp1) + assertCustomerProfileSequence(found2, cp2) + + when: + cp1.setDisplayName("test 1 modified") + cp2.setDisplayName("test 2 modified") + CustomerProfileSequence cp3 = new CustomerProfileSequence("test3@example.com", "test 3") + CustomerProfileSequence cp4 = new CustomerProfileSequence("test4@example.com", "test 4") + List updated = customerProfileSequenceRepository.upsertAll([cp1, cp2, cp3, cp4]) + + then: + updated.size() == 4 + updated.get(0) == cp1 + updated.get(1) == cp2 + updated.get(2).id != null + updated.get(3).id != null + updated.get(2) == cp3 + updated.get(3) == cp4 + + when: + found1 = customerProfileSequenceRepository.findById(cp1.id).get() + found2 = customerProfileSequenceRepository.findById(cp2.id).get() + CustomerProfileSequence found3 = customerProfileSequenceRepository.findById(cp3.id).get() + CustomerProfileSequence found4 = customerProfileSequenceRepository.findById(cp4.id).get() + + then: + assertCustomerProfileSequence(found1, cp1) + assertCustomerProfileSequence(found2, cp2) + assertCustomerProfileSequence(found3, cp3) + assertCustomerProfileSequence(found4, cp4) + } + + private static void assertCustomerProfileSequence(CustomerProfileSequence customerProfile1, CustomerProfileSequence customerProfile2) { + assert customerProfile1.email == customerProfile2.email + assert customerProfile1.displayName == customerProfile2.displayName + } +} diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/sqlserver/SqlServerUpsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/sqlserver/SqlServerUpsertSpec.groovy new file mode 100644 index 00000000000..0144ee882a7 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/sqlserver/SqlServerUpsertSpec.groovy @@ -0,0 +1,148 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.sqlserver + +import io.micronaut.data.jdbc.sqlserver.upsert.CustomerProfileSequence +import io.micronaut.data.jdbc.sqlserver.upsert.MSCustomerProfileRepository +import io.micronaut.data.jdbc.sqlserver.upsert.MSCustomerProfileSequenceRepository +import io.micronaut.data.jdbc.sqlserver.upsert.MSCustomerProfileUuidRepository +import io.micronaut.data.jdbc.sqlserver.upsert.MSProductReviewRepository +import io.micronaut.data.jdbc.sqlserver.upsert.MSWarehouseInventoryRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class SqlServerUpsertSpec extends AbstractUpsertSpec implements MSSQLTestPropertyProvider { + + @Override + ProductReviewRepository getProductReviewRepository() { + return context.getBean(MSProductReviewRepository) + } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(MSCustomerProfileRepository) + } + + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(MSCustomerProfileUuidRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(MSWarehouseInventoryRepository) + } + + MSCustomerProfileSequenceRepository getCustomerProfileSequenceRepository() { + return context.getBean(MSCustomerProfileSequenceRepository) + } + + @Override + List packages() { + return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert", "io.micronaut.data.jdbc.sqlserver.upsert") + } + + void "upsert by email conflict returns entity when sequence id is used"() { + given: + CustomerProfileSequence cp = new CustomerProfileSequence("test@example.com", "test") + + when: + CustomerProfileSequence inserted = customerProfileSequenceRepository.upsert(cp) + + then: + inserted.id != null + inserted == cp + + when: + CustomerProfileSequence found = customerProfileSequenceRepository.findById(cp.id).get() + + then: + assertCustomerProfileSequence(cp, found) + + when: + cp.setDisplayName("test modified") + CustomerProfileSequence updated = customerProfileSequenceRepository.upsert(cp) + + then: + updated == cp + + when: + found = customerProfileSequenceRepository.findById(cp.id).get() + + then: + assertCustomerProfileSequence(cp, found) + } + + void "upsertAll by email conflict returns entities when sequence id is used"() { + given: + CustomerProfileSequence cp1 = new CustomerProfileSequence("test1@example.com", "test 1") + CustomerProfileSequence cp2 = new CustomerProfileSequence("test2@example.com", "test 2") + + when: + List inserted = customerProfileSequenceRepository.upsertAll([cp1, cp2]) + + then: + inserted.size() == 2 + inserted.get(0).id != null + inserted.get(1).id != null + inserted.get(0) == cp1 + inserted.get(1) == cp2 + + when: + CustomerProfileSequence found1 = customerProfileSequenceRepository.findById(cp1.id).get() + CustomerProfileSequence found2 = customerProfileSequenceRepository.findById(cp2.id).get() + + then: + assertCustomerProfileSequence(found1, cp1) + assertCustomerProfileSequence(found2, cp2) + + when: + cp1.setDisplayName("test 1 modified") + cp2.setDisplayName("test 2 modified") + CustomerProfileSequence cp3 = new CustomerProfileSequence("test3@example.com", "test 3") + CustomerProfileSequence cp4 = new CustomerProfileSequence("test4@example.com", "test 4") + List updated = customerProfileSequenceRepository.upsertAll([cp1, cp2, cp3, cp4]) + + then: + updated.size() == 4 + updated.get(0) == cp1 + updated.get(1) == cp2 + updated.get(2).id != null + updated.get(3).id != null + updated.get(2) == cp3 + updated.get(3) == cp4 + + when: + found1 = customerProfileSequenceRepository.findById(cp1.id).get() + found2 = customerProfileSequenceRepository.findById(cp2.id).get() + CustomerProfileSequence found3 = customerProfileSequenceRepository.findById(cp3.id).get() + CustomerProfileSequence found4 = customerProfileSequenceRepository.findById(cp4.id).get() + + then: + assertCustomerProfileSequence(found1, cp1) + assertCustomerProfileSequence(found2, cp2) + assertCustomerProfileSequence(found3, cp3) + assertCustomerProfileSequence(found4, cp4) + } + + private static void assertCustomerProfileSequence(CustomerProfileSequence customerProfile1, CustomerProfileSequence customerProfile2) { + assert customerProfile1.email == customerProfile2.email + assert customerProfile1.displayName == customerProfile2.displayName + } +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2CustomerProfileRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2CustomerProfileRepository.java new file mode 100644 index 00000000000..84a84f493b7 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2CustomerProfileRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.h2.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository; + +@JdbcRepository(dialect = Dialect.H2) +public interface H2CustomerProfileRepository extends CustomerProfileRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2CustomerProfileUuidRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2CustomerProfileUuidRepository.java new file mode 100644 index 00000000000..b6d9fa18bce --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2CustomerProfileUuidRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.h2.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository; + +@JdbcRepository(dialect = Dialect.H2) +public interface H2CustomerProfileUuidRepository extends CustomerProfileUuidRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2ProductReviewRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2ProductReviewRepository.java new file mode 100644 index 00000000000..1de1ad98f2f --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2ProductReviewRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.h2.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; + +@JdbcRepository(dialect = Dialect.H2) +public interface H2ProductReviewRepository extends ProductReviewRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2WarehouseInventoryRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2WarehouseInventoryRepository.java new file mode 100644 index 00000000000..c5f0d1bb704 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2WarehouseInventoryRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.h2.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository; + +@JdbcRepository(dialect = Dialect.H2) +public interface H2WarehouseInventoryRepository extends WarehouseInventoryRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlCustomerProfileRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlCustomerProfileRepository.java new file mode 100644 index 00000000000..fa93aa2c707 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlCustomerProfileRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.mysql.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository; + +@JdbcRepository(dialect = Dialect.MYSQL) +public interface MySqlCustomerProfileRepository extends CustomerProfileRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlCustomerProfileUuidRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlCustomerProfileUuidRepository.java new file mode 100644 index 00000000000..b9122d7a04e --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlCustomerProfileUuidRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.mysql.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository; + +@JdbcRepository(dialect = Dialect.MYSQL) +public interface MySqlCustomerProfileUuidRepository extends CustomerProfileUuidRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlProductReviewRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlProductReviewRepository.java new file mode 100644 index 00000000000..6ff7a98323b --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlProductReviewRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.mysql.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; + +@JdbcRepository(dialect = Dialect.MYSQL) +public interface MySqlProductReviewRepository extends ProductReviewRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlWarehouseInventoryRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlWarehouseInventoryRepository.java new file mode 100644 index 00000000000..8b55684484e --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlWarehouseInventoryRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.mysql.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository; + +@JdbcRepository(dialect = Dialect.MYSQL) +public interface MySqlWarehouseInventoryRepository extends WarehouseInventoryRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/CustomerProfileSequence.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/CustomerProfileSequence.java new file mode 100644 index 00000000000..d5a6c6c11c1 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/CustomerProfileSequence.java @@ -0,0 +1,77 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.oraclexe.upsert; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Index; +import io.micronaut.data.annotation.MappedEntity; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; +import org.jspecify.annotations.Nullable; + +@MappedEntity +@Index(columns = "email", unique = true) +public class CustomerProfileSequence { + + @Id + @GeneratedValue(value = GeneratedValue.Type.SEQUENCE) + @Nullable + private Long id; + + @NotBlank + private String email; + + @NotBlank + private String displayName; + + public CustomerProfileSequence() { + } + + public CustomerProfileSequence(String email, String displayName) { + this(null, email, displayName); + } + + public CustomerProfileSequence(@Nullable Long id, String email, String displayName) { + this.id = id; + this.email = email; + this.displayName = displayName; + } + + @Nullable + public Long getId() { + return id; + } + + public void setId(@Nullable Long id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXECustomerProfileRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXECustomerProfileRepository.java new file mode 100644 index 00000000000..664c899113a --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXECustomerProfileRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.oraclexe.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository; + +@JdbcRepository(dialect = Dialect.ORACLE) +public interface OracleXECustomerProfileRepository extends CustomerProfileRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXECustomerProfileSequenceRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXECustomerProfileSequenceRepository.java new file mode 100644 index 00000000000..7f8f271012a --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXECustomerProfileSequenceRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.oraclexe.upsert; + +import io.micronaut.data.annotation.Upsert; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +import java.util.List; + +@JdbcRepository(dialect = Dialect.ORACLE) +public interface OracleXECustomerProfileSequenceRepository extends CrudRepository { + + @Upsert(conflictsOn = "email") + CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); + + @Upsert(conflictsOn = "email") + List upsertAll(Iterable customerProfiles); +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXECustomerProfileUuidRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXECustomerProfileUuidRepository.java new file mode 100644 index 00000000000..cb71ea63948 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXECustomerProfileUuidRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.oraclexe.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository; + +@JdbcRepository(dialect = Dialect.ORACLE) +public interface OracleXECustomerProfileUuidRepository extends CustomerProfileUuidRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXEProductReviewRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXEProductReviewRepository.java new file mode 100644 index 00000000000..83d93be5599 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXEProductReviewRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.oraclexe.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; + +@JdbcRepository(dialect = Dialect.ORACLE) +public interface OracleXEProductReviewRepository extends ProductReviewRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXEWarehouseInventoryRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXEWarehouseInventoryRepository.java new file mode 100644 index 00000000000..a54514fb28a --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXEWarehouseInventoryRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.oraclexe.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository; + +@JdbcRepository(dialect = Dialect.ORACLE) +public interface OracleXEWarehouseInventoryRepository extends WarehouseInventoryRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/CustomerProfileSequence.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/CustomerProfileSequence.java new file mode 100644 index 00000000000..046f527876b --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/CustomerProfileSequence.java @@ -0,0 +1,77 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.postgres.upsert; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Index; +import io.micronaut.data.annotation.MappedEntity; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; +import org.jspecify.annotations.Nullable; + +@MappedEntity +@Index(columns = "email", unique = true) +public class CustomerProfileSequence { + + @Id + @GeneratedValue(value = GeneratedValue.Type.SEQUENCE) + @Nullable + private Long id; + + @NotBlank + private String email; + + @NotBlank + private String displayName; + + public CustomerProfileSequence() { + } + + public CustomerProfileSequence(String email, String displayName) { + this(null, email, displayName); + } + + public CustomerProfileSequence(@Nullable Long id, String email, String displayName) { + this.id = id; + this.email = email; + this.displayName = displayName; + } + + @Nullable + public Long getId() { + return id; + } + + public void setId(@Nullable Long id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresCustomerProfileRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresCustomerProfileRepository.java new file mode 100644 index 00000000000..615d71912db --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresCustomerProfileRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.postgres.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository; + +@JdbcRepository(dialect = Dialect.POSTGRES) +public interface PostgresCustomerProfileRepository extends CustomerProfileRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresCustomerProfileSequenceRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresCustomerProfileSequenceRepository.java new file mode 100644 index 00000000000..3072b99f854 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresCustomerProfileSequenceRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.postgres.upsert; + +import io.micronaut.data.annotation.Upsert; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +import java.util.List; + +@JdbcRepository(dialect = Dialect.POSTGRES) +public interface PostgresCustomerProfileSequenceRepository extends CrudRepository { + + @Upsert(conflictsOn = "email") + CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); + + @Upsert(conflictsOn = "email") + List upsertAll(Iterable customerProfiles); +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresCustomerProfileUuidRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresCustomerProfileUuidRepository.java new file mode 100644 index 00000000000..573e4699936 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresCustomerProfileUuidRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.postgres.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository; + +@JdbcRepository(dialect = Dialect.POSTGRES) +public interface PostgresCustomerProfileUuidRepository extends CustomerProfileUuidRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresProductReviewRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresProductReviewRepository.java new file mode 100644 index 00000000000..fbc44f10c3d --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresProductReviewRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.postgres.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; + +@JdbcRepository(dialect = Dialect.POSTGRES) +public interface PostgresProductReviewRepository extends ProductReviewRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresWarehouseInventoryRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresWarehouseInventoryRepository.java new file mode 100644 index 00000000000..93421b7a9aa --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresWarehouseInventoryRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.postgres.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository; + +@JdbcRepository(dialect = Dialect.POSTGRES) +public interface PostgresWarehouseInventoryRepository extends WarehouseInventoryRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/CustomerProfileSequence.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/CustomerProfileSequence.java new file mode 100644 index 00000000000..bf2694e2332 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/CustomerProfileSequence.java @@ -0,0 +1,77 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.sqlserver.upsert; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Index; +import io.micronaut.data.annotation.MappedEntity; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; +import org.jspecify.annotations.Nullable; + +@MappedEntity +@Index(columns = "email", unique = true) +public class CustomerProfileSequence { + + @Id + @GeneratedValue(value = GeneratedValue.Type.SEQUENCE) + @Nullable + private Long id; + + @NotBlank + private String email; + + @NotBlank + private String displayName; + + public CustomerProfileSequence() { + } + + public CustomerProfileSequence(String email, String displayName) { + this(null, email, displayName); + } + + public CustomerProfileSequence(@Nullable Long id, String email, String displayName) { + this.id = id; + this.email = email; + this.displayName = displayName; + } + + @Nullable + public Long getId() { + return id; + } + + public void setId(@Nullable Long id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSCustomerProfileRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSCustomerProfileRepository.java new file mode 100644 index 00000000000..8706f1f1952 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSCustomerProfileRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.sqlserver.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository; + +@JdbcRepository(dialect = Dialect.SQL_SERVER) +public interface MSCustomerProfileRepository extends CustomerProfileRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSCustomerProfileSequenceRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSCustomerProfileSequenceRepository.java new file mode 100644 index 00000000000..069b7df5c4f --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSCustomerProfileSequenceRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.sqlserver.upsert; + +import io.micronaut.data.annotation.Upsert; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +import java.util.List; + +@JdbcRepository(dialect = Dialect.SQL_SERVER) +public interface MSCustomerProfileSequenceRepository extends CrudRepository { + + @Upsert(conflictsOn = "email") + CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); + + @Upsert(conflictsOn = "email") + List upsertAll(Iterable customerProfiles); +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSCustomerProfileUuidRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSCustomerProfileUuidRepository.java new file mode 100644 index 00000000000..45a09cfe33b --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSCustomerProfileUuidRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.sqlserver.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository; + +@JdbcRepository(dialect = Dialect.SQL_SERVER) +public interface MSCustomerProfileUuidRepository extends CustomerProfileUuidRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSProductReviewRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSProductReviewRepository.java new file mode 100644 index 00000000000..22d9c629814 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSProductReviewRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.sqlserver.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; + +@JdbcRepository(dialect = Dialect.SQL_SERVER) +public interface MSProductReviewRepository extends ProductReviewRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSWarehouseInventoryRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSWarehouseInventoryRepository.java new file mode 100644 index 00000000000..5b25688ef71 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSWarehouseInventoryRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.sqlserver.upsert; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository; + +@JdbcRepository(dialect = Dialect.SQL_SERVER) +public interface MSWarehouseInventoryRepository extends WarehouseInventoryRepository { +} diff --git a/data-model/src/main/java/io/micronaut/data/annotation/Upsert.java b/data-model/src/main/java/io/micronaut/data/annotation/Upsert.java new file mode 100644 index 00000000000..21f7e74a998 --- /dev/null +++ b/data-model/src/main/java/io/micronaut/data/annotation/Upsert.java @@ -0,0 +1,58 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + *

Lifecycle annotation for repository methods which perform upsert operations.

+ * + *

The {@code Upsert} annotation indicates that the annotated repository method adds the state of one or more + * entities to the database when missing, or updates the existing database state when present. + *

+ *

An {@code Upsert} method accepts an instance or instances of an entity class. The method must have exactly one + * parameter whose type is either: + *

+ *
    + *
  • the class of the entity to be upserted, or
  • + *
  • {@code Iterable} where {@code E} is the class of the entities to be upserted.
  • + *
+ *

The annotated method may be declared {@code void}, return a number type, return the entity type, or return an + * iterable/reactive/asynchronous container producing the entity type, depending on the repository type. + *

+ *

By default, the entity identity is used to determine whether an existing row should be updated. The + * {@link #conflictsOn()} member can be used to select a different property or set of properties as the conflict + * target. + *

+ * + * @since 5.1.0 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Upsert { + + /** + * The persistent entity properties to use as the conflict target. + * + * @return The conflict target properties + */ + String[] conflictsOn() default {}; +} diff --git a/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethod.java b/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethod.java index 1c22e2d7b32..b3b0fa0f844 100644 --- a/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethod.java +++ b/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethod.java @@ -323,5 +323,11 @@ enum OperationType { * An insert returning operation. */ INSERT_RETURNING, + /** + * An upsert operation. + * + * @since 5.1.0 + */ + UPSERT, } } diff --git a/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethodQuery.java b/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethodQuery.java index 5549f6ef0be..f16ac3c9637 100644 --- a/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethodQuery.java +++ b/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethodQuery.java @@ -192,5 +192,11 @@ enum OperationType { * An insert returning operation. */ INSERT_RETURNING, + /** + * An upsert operation. + * + * @since 5.1.0 + */ + UPSERT, } } diff --git a/data-model/src/main/java/io/micronaut/data/model/query/builder/QueryBuilder.java b/data-model/src/main/java/io/micronaut/data/model/query/builder/QueryBuilder.java index a0b1386f08b..898ee56208c 100644 --- a/data-model/src/main/java/io/micronaut/data/model/query/builder/QueryBuilder.java +++ b/data-model/src/main/java/io/micronaut/data/model/query/builder/QueryBuilder.java @@ -57,6 +57,18 @@ public interface QueryBuilder { @Nullable QueryResult buildInsert(AnnotationMetadata repositoryMetadata, InsertQueryDefinition definition); + /** + * Builds an upsert statement for the given entity. + * + * @param repositoryMetadata The repository annotation metadata + * @param definition The definition + * @return The upsert statement + * @since 5.1.0 + */ + default QueryResult buildUpsert(AnnotationMetadata repositoryMetadata, UpsertQueryDefinition definition) { + throw new UnsupportedOperationException("Upsert is not supported by " + getClass().getName()); + } + /** * Encode the given query for the passed annotation metadata and query. * @@ -193,6 +205,35 @@ interface InsertQueryDefinition { } + /** + * The upsert query definition. + * + * @since 5.1.0 + */ + interface UpsertQueryDefinition { + + /** + * @return The persistent entity + */ + + PersistentEntity persistentEntity(); + + /** + * @return The persistent entity properties to use as the conflict target + */ + default List conflictProperties() { + return List.of(); + } + + /** + * @return Should upsert return generated id + */ + default boolean returnGeneratedId() { + return false; + } + + } + /** * The update query definition. */ diff --git a/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlQueryBuilder.java b/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlQueryBuilder.java index f1f935d965a..6d87f147b37 100644 --- a/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlQueryBuilder.java +++ b/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlQueryBuilder.java @@ -823,7 +823,7 @@ private void addTableCreateStatements(List createStatements, SqlTableMap } } if (tableIdentity.isAutoGenerated()) { - column = addGeneratedStatementToColumn(tableIdentity.getGeneratedValueType(), tableIdentity.getDataType(), column, !generatePkAfterColumns); + column = addGeneratedStatementToColumn(table, tableIdentity, column, !generatePkAfterColumns, escape); } columns.add(column); } @@ -843,11 +843,15 @@ private void addTableCreateStatements(List createStatements, SqlTableMap } } if (tableColumn.isAutoGenerated()) { - column = addGeneratedStatementToColumn(tableColumn.getGeneratedValueType(), tableColumn.getDataType(), column, false); + column = addGeneratedStatementToColumn(table, tableColumn, column, false, escape); } columns.add(column); } + if (dialect == Dialect.SQL_SERVER) { + createSequenceStatements(table, escape, createStatements); + } + String tableName = getObjectName(schema, table.name(), escape, true); StringBuilder builder = new StringBuilder("CREATE TABLE ").append(tableName).append(" ("); builder.append(String.join(",", columns)); @@ -860,7 +864,9 @@ private void addTableCreateStatements(List createStatements, SqlTableMap builder.append(");"); } addToCollectionIfNotContains(createStatements, builder.toString()); - createSequenceStatements(table, escape, createStatements); + if (dialect != Dialect.SQL_SERVER) { + createSequenceStatements(table, escape, createStatements); + } createAuxiliaryStatements(table, createStatements); createIndexStatements(table, tableName, escape, createStatements); } @@ -1024,7 +1030,9 @@ protected String getTableAsKeyword() { } @SuppressWarnings("java:S3776") - private String addGeneratedStatementToColumn(GeneratedValue.Type type, DataType dataType, String column, boolean isPk) { + private String addGeneratedStatementToColumn(SqlTableMapping table, SqlColumnMapping columnMapping, String column, boolean isPk, boolean escape) { + GeneratedValue.Type type = columnMapping.getGeneratedValueType(); + DataType dataType = columnMapping.getDataType(); if (type == AUTO) { if (dataType == DataType.UUID) { type = UUID; @@ -1072,6 +1080,7 @@ private String addGeneratedStatementToColumn(GeneratedValue.Type type, DataType if (isPk) { column += " NOT NULL"; } + column += " DEFAULT NEXT VALUE FOR " + getDefaultSequenceName(table, escape); } else { column += " IDENTITY(1,1) NOT NULL"; } @@ -1080,7 +1089,7 @@ private String addGeneratedStatementToColumn(GeneratedValue.Type type, DataType // for Oracle, we use sequences so just add NOT NULL // then alter the table for sequences if (type == UUID) { - column += " NOT NULL DEFAULT SYS_GUID()"; + column += " DEFAULT SYS_GUID() NOT NULL"; } else if (type == IDENTITY) { if (isPk) { column += " GENERATED BY DEFAULT ON NULL AS IDENTITY (MINVALUE 1 START WITH 1 CACHE 100 NOCYCLE)"; @@ -1114,6 +1123,12 @@ private String addGeneratedStatementToColumn(GeneratedValue.Type type, DataType return column; } + private String getDefaultSequenceName(SqlTableMapping table, boolean escape) { + List sequences = table.sequences(); + String definedName = CollectionUtils.isNotEmpty(sequences) && sequences.size() == 1 ? sequences.getFirst().definedName() : null; + return getObjectName(table.schema(), StringUtils.isNotEmpty(definedName) ? Objects.requireNonNull(definedName) : table.name() + SqlQueryBuilderUtils.SEQ_SUFFIX, escape, true); + } + private List resolveJoinTableAssociatedColumns(AnnotationMetadata annotationMetadata, boolean associationOwner, PersistentEntity entity, NamingStrategy namingStrategy) { List joinColumns = SqlQueryBuilderUtils.getJoinedColumns(annotationMetadata, associationOwner, REFERENCED_COLUMN_NAME); if (!joinColumns.isEmpty()) { @@ -1239,32 +1254,7 @@ public JsonDataType getJsonDataType() { String key = String.valueOf(values.size()); String[] path = asStringPath(associations, property); - parameterBindings.add(new QueryParameterBinding() { - @Override - public String getName() { - return key; - } - - @Override - public String getKey() { - return key; - } - - @Override - public DataType getDataType() { - return property.getDataType(); - } - - @Override - public JsonDataType getJsonDataType() { - return property.getJsonDataType(); - } - - @Override - public String[] getPropertyPath() { - return path; - } - }); + parameterBindings.add(createParameterBinding(key, property, path)); String columnName = getMappedName(namingStrategy, associations, property); unescapedColumns.add(columnName); @@ -1353,34 +1343,7 @@ public String[] getPropertyPath() { String key = String.valueOf(values.size()); String[] path = asStringPath(associations, property); - parameterBindings.add(new QueryParameterBinding() { - - @Override - public String getName() { - return key; - } - - @Override - public String getKey() { - return key; - } - - @Override - public DataType getDataType() { - return property.getDataType(); - } - - @Override - public JsonDataType getJsonDataType() { - return property.getJsonDataType(); - } - - @Override - public String[] getPropertyPath() { - return path; - } - }); - + parameterBindings.add(createParameterBinding(key, property, path)); } columns.add(columnName); @@ -1445,7 +1408,41 @@ public DataType getDataType() { Collections.emptyMap()); } - private String[] asStringPath(List associations, PersistentProperty property) { + @Override + public QueryResult buildUpsert(AnnotationMetadata repositoryMetadata, UpsertQueryDefinition definition) { + return new SqlUpsertQueryBuilder(this).build(repositoryMetadata, definition); + } + + final QueryParameterBinding createParameterBinding(String key, PersistentProperty property, String[] path) { + return new QueryParameterBinding() { + @Override + public String getName() { + return key; + } + + @Override + public String getKey() { + return key; + } + + @Override + public DataType getDataType() { + return property.getDataType(); + } + + @Override + public JsonDataType getJsonDataType() { + return property.getJsonDataType(); + } + + @Override + public String[] getPropertyPath() { + return path; + } + }; + } + + final String[] asStringPath(List associations, PersistentProperty property) { if (associations.isEmpty()) { return new String[]{property.getName()}; } @@ -1457,7 +1454,7 @@ private String[] asStringPath(List associations, PersistentProperty return path.toArray(new String[0]); } - private String getSequenceStatement(String unescapedSchemaName, String unescapedTableName, PersistentProperty property) { + final String getSequenceStatement(String unescapedSchemaName, String unescapedTableName, PersistentProperty property) { final String sequenceName = resolveSequenceName(property, unescapedTableName); return switch (dialect) { case ORACLE -> (StringUtils.isEmpty(unescapedSchemaName) ? "" : quote(unescapedSchemaName, true) + DOT) + quote(sequenceName, true) + ".nextval"; @@ -1505,7 +1502,7 @@ private String getObjectName(@Nullable String schema, String objectName, boolean } } - private boolean addWriteExpression(List values, PersistentProperty property) { + final boolean addWriteExpression(List values, PersistentProperty property) { DataType dt = property.getDataType(); String transformer = getDataTransformerWriteValue(null, property).orElse(null); if (transformer != null) { diff --git a/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlUpsertQueryBuilder.java b/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlUpsertQueryBuilder.java new file mode 100644 index 00000000000..d2f142e5aee --- /dev/null +++ b/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlUpsertQueryBuilder.java @@ -0,0 +1,495 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.model.query.builder.sql; + +import io.micronaut.core.annotation.AnnotationMetadata; +import io.micronaut.core.annotation.AnnotationValue; +import io.micronaut.core.util.StringUtils; +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.model.Association; +import io.micronaut.data.model.DataType; +import io.micronaut.data.model.PersistentEntity; +import io.micronaut.data.model.PersistentEntityUtils; +import io.micronaut.data.model.PersistentProperty; +import io.micronaut.data.model.PersistentPropertyPath; +import io.micronaut.data.model.naming.NamingStrategy; +import io.micronaut.data.model.query.builder.QueryBuilder; +import io.micronaut.data.model.query.builder.QueryOutParameterBinding; +import io.micronaut.data.model.query.builder.QueryParameterBinding; +import io.micronaut.data.model.query.builder.QueryResult; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static io.micronaut.data.annotation.GeneratedValue.Type.AUTO; +import static io.micronaut.data.annotation.GeneratedValue.Type.SEQUENCE; + +/** + * Builds dialect-specific SQL and binding metadata for explicitly declared upsert operations. + * + *

This builder resolves the conflict columns from the entity identity or the upsert definition's + * configured conflict properties, then derives the insertable and mutable columns from persistent + * entity metadata. It renders the appropriate native form for each supported SQL dialect, such as + * {@code MERGE}, {@code ON CONFLICT}, or {@code ON DUPLICATE KEY UPDATE}.

+ * + *

The resulting {@link QueryResult} contains both input bindings and, where supported, generated + * identity returning metadata. Runtime repository operations consume that metadata to bind values, + * execute the statement, and apply returned generated values; this class does not execute SQL.

+ * + *

Keep dialect-specific syntax and bind-order decisions here so that compile-time query generation + * remains consistent for JDBC and R2DBC repositories. Validation that depends on repository method + * shape belongs in the method matcher, while driver-specific execution behavior belongs in the + * corresponding repository operations implementation.

+ */ +final class SqlUpsertQueryBuilder { + + private static final char COMMA = ','; + private static final char CLOSE_BRACKET = ')'; + private static final String BLANK_SPACE = " "; + private static final String INSERT_INTO = "INSERT INTO "; + private static final String R2DBC_REPO_ANNOTATION = "io.micronaut.data.r2dbc.annotation.R2dbcRepository"; + + private final SqlQueryBuilder sqlQueryBuilder; + private final Dialect dialect; + + SqlUpsertQueryBuilder(SqlQueryBuilder sqlQueryBuilder) { + this.sqlQueryBuilder = sqlQueryBuilder; + this.dialect = sqlQueryBuilder.getDialect(); + } + + QueryResult build(AnnotationMetadata repositoryMetadata, QueryBuilder.UpsertQueryDefinition definition) { + PersistentEntity entity = definition.persistentEntity(); + if (sqlQueryBuilder.isJsonEntity(repositoryMetadata, entity)) { + throw new IllegalStateException("Upsert is not supported for JSON entity representation: " + entity.getName()); + } + if (definition.conflictProperties().isEmpty() && !entity.hasIdentity() && !entity.hasCompositeIdentity()) { + throw new IllegalStateException("Upsert requires conflict properties or an identity for entity: " + entity.getName()); + } + if (entity.hasVersion()) { + throw new IllegalStateException("Upsert is not supported for versioned entity: " + entity.getName()); + } + + UpsertData data = buildUpsertData(entity, definition.conflictProperties()); + String tableName = sqlQueryBuilder.getTableName(entity); + String query = switch (dialect) { + case H2 -> buildH2Upsert(tableName, data); + case MYSQL -> buildMySqlUpsert(tableName, data); + case POSTGRES, SQLITE -> buildPostgresUpsert(tableName, data); + case SQL_SERVER -> buildSqlServerUpsert(tableName, data); + case ORACLE -> buildOracleUpsert(tableName, data); + case ANSI -> buildAnsiUpsert(tableName, data); + }; + + List parameterBindings = buildParameterBindings(data); + + UpsertReturningColumn returningColumn = findGeneratedIdReturningColumn(entity, definition); + if (returningColumn == null) { + if (dialect == Dialect.SQL_SERVER) { + query = query + ";"; + } + return QueryResult.of(query, parameterBindings); + } + + if (dialect == Dialect.SQL_SERVER) { + query = query + " OUTPUT inserted." + returningColumn.column() + ";"; + } else if (dialect == Dialect.ORACLE) { + String outPlaceholder = sqlQueryBuilder.formatParameter(parameterBindings.size() + 1).name(); + query = query + " RETURNING " + returningColumn.column() + " INTO " + outPlaceholder; + if (repositoryMetadata.hasStereotype(R2DBC_REPO_ANNOTATION)) { + query = "BEGIN " + query + "; END;"; + } + } + List outParameterBindings = buildOutParameterBindings(returningColumn); + return QueryResult.of(query, Collections.emptyList(), parameterBindings, outParameterBindings, Collections.emptyMap()); + } + + @Nullable + private UpsertReturningColumn findGeneratedIdReturningColumn(PersistentEntity entity, QueryBuilder.UpsertQueryDefinition definition) { + if (!definition.returnGeneratedId() || (dialect != Dialect.ORACLE && dialect != Dialect.SQL_SERVER)) { + return null; + } + List returningColumns = findGeneratedIdentityReturningColumns(entity); + if (returningColumns.isEmpty()) { + return null; + } + if (returningColumns.size() > 1) { + String operation = dialect == Dialect.SQL_SERVER ? "SQL Server MERGE ... OUTPUT" : "Oracle MERGE ... RETURNING"; + throw new IllegalStateException(operation + " supports a single generated identity for entity: " + entity.getName()); + } + return returningColumns.getFirst(); + } + + private List findGeneratedIdentityReturningColumns(PersistentEntity entity) { + boolean escape = sqlQueryBuilder.shouldEscape(entity); + NamingStrategy namingStrategy = sqlQueryBuilder.getNamingStrategy(entity); + List columns = new ArrayList<>(); + for (PersistentProperty identity : entity.getIdentityProperties()) { + PersistentEntityUtils.traversePersistentProperties(Collections.emptyList(), identity, (associations, property) -> { + if (!SqlQueryBuilderUtils.isGeneratedProperty(property, associations)) { + return; + } + String columnName = sqlQueryBuilder.getMappedName(namingStrategy, associations, property); + columns.add(new UpsertReturningColumn(escape ? sqlQueryBuilder.quote(columnName) : columnName, columnName, property.getDataType())); + }); + } + return columns; + } + + private List buildParameterBindings(UpsertData data) { + if (dialect != Dialect.MYSQL) { + return data.parameterBindings(); + } + List parameterBindings = new ArrayList<>(data.parameterBindings()); + for (UpsertColumn updateColumn : data.updateColumnsOrConflict()) { + parameterBindings.add(sqlQueryBuilder.createParameterBinding(String.valueOf(parameterBindings.size() + 1), updateColumn.property(), updateColumn.path().toArray(new String[0]))); + } + return parameterBindings; + } + + private List buildOutParameterBindings(UpsertReturningColumn returningColumn) { + List outBindings = new ArrayList<>(1); + outBindings.add(new QueryOutParameterBinding() { + @Override + public String getName() { + return returningColumn.name(); + } + + @Override + public DataType getDataType() { + return returningColumn.dataType(); + } + }); + return outBindings; + } + + private UpsertData buildUpsertData(PersistentEntity entity, List conflictProperties) { + boolean escape = sqlQueryBuilder.shouldEscape(entity); + NamingStrategy namingStrategy = sqlQueryBuilder.getNamingStrategy(entity); + List columns = new ArrayList<>(); + List values = new ArrayList<>(); + List parameterBindings = new ArrayList<>(); + List conflictPropertyPaths = resolveUpsertConflictPropertyPaths(entity, conflictProperties); + final String unescapedTableName = sqlQueryBuilder.getUnescapedTableName(entity); + final String unescapedSchema = SqlQueryBuilderUtils.getSchemaName(entity); + + for (PersistentProperty prop : entity.getPersistentProperties()) { + PersistentEntityUtils.traversePersistentProperties(Collections.emptyList(), prop, (associations, property) -> { + if (SqlQueryBuilderUtils.isGeneratedProperty(property, associations)) { + return; + } + addUpsertColumn(columns, values, parameterBindings, namingStrategy, associations, property, escape, false, conflictPropertyPaths); + }); + } + + boolean identityConflict = conflictProperties.isEmpty(); + for (PersistentProperty identity : entity.getIdentityProperties()) { + PersistentEntityUtils.traversePersistentProperties(Collections.emptyList(), identity, (associations, property) -> { + if (SqlQueryBuilderUtils.isGeneratedProperty(property, associations)) { + if (identityConflict) { + throw new IllegalStateException("Upsert requires a non-generated identity property: " + property.getName()); + } + if (dialect != Dialect.SQL_SERVER && SqlQueryBuilderUtils.isNotForeign(associations) && isSequenceGeneratedProperty(property)) { + addGeneratedUpsertColumn(columns, namingStrategy, associations, property, escape, true, conflictPropertyPaths, sqlQueryBuilder.getSequenceStatement(unescapedSchema, unescapedTableName, property)); + } + return; + } + addUpsertColumn(columns, values, parameterBindings, namingStrategy, associations, property, escape, true, conflictPropertyPaths); + }); + } + + if (columns.isEmpty()) { + throw new IllegalStateException("Upsert requires at least one bindable column for entity: " + entity.getName()); + } + if (columns.stream().noneMatch(UpsertColumn::conflict)) { + throw new IllegalStateException("Upsert requires at least one bindable conflict column for entity: " + entity.getName()); + } + return new UpsertData(columns, parameterBindings); + } + + private void addUpsertColumn(List columns, + List values, + List parameterBindings, + NamingStrategy namingStrategy, + List associations, + PersistentProperty property, + boolean escape, + boolean identity, + List conflictPropertyPaths) { + sqlQueryBuilder.addWriteExpression(values, property); + String key = String.valueOf(values.size()); + String[] path = sqlQueryBuilder.asStringPath(associations, property); + parameterBindings.add(sqlQueryBuilder.createParameterBinding(key, property, path)); + + String columnName = sqlQueryBuilder.getMappedName(namingStrategy, associations, property); + if (escape) { + columnName = sqlQueryBuilder.quote(columnName); + } + + UpsertColumn column = new UpsertColumn( + columnName, + values.getLast(), + "c" + sourceColumnCount(columns), + true, + property, + List.of(path), + identity, + conflictPropertyPaths.contains(toPathString(path))); + columns.add(column); + } + + private void addGeneratedUpsertColumn(List columns, + NamingStrategy namingStrategy, + List associations, + PersistentProperty property, + boolean escape, + boolean identity, + List conflictPropertyPaths, + String value) { + String[] path = sqlQueryBuilder.asStringPath(associations, property); + String columnName = sqlQueryBuilder.getMappedName(namingStrategy, associations, property); + if (escape) { + columnName = sqlQueryBuilder.quote(columnName); + } + + UpsertColumn column = new UpsertColumn( + columnName, + value, + "", + false, + property, + List.of(path), + identity, + conflictPropertyPaths.contains(toPathString(path))); + columns.add(column); + } + + private int sourceColumnCount(List columns) { + return (int) columns.stream() + .filter(UpsertColumn::sourceColumn) + .count(); + } + + private boolean isSequenceGeneratedProperty(PersistentProperty property) { + Optional> generated = property.findAnnotation(GeneratedValue.class); + if (generated.isEmpty()) { + return false; + } + GeneratedValue.Type idGeneratorType = generated + .flatMap(av -> av.enumValue(GeneratedValue.Type.class)) + .orElseGet(() -> sqlQueryBuilder.selectAutoStrategy(property)); + return idGeneratorType == SEQUENCE || (idGeneratorType == AUTO && sqlQueryBuilder.selectAutoStrategy(property) == SEQUENCE); + } + + private List resolveUpsertConflictPropertyPaths(PersistentEntity entity, List conflictProperties) { + List conflictPropertyPaths = new ArrayList<>(); + if (conflictProperties.isEmpty()) { + for (PersistentProperty identity : entity.getIdentityProperties()) { + PersistentEntityUtils.traversePersistentProperties( + Collections.emptyList(), + identity, + (associations, property) -> conflictPropertyPaths.add(toPathString(associations, property))); + } + return conflictPropertyPaths; + } + for (String conflictProperty : conflictProperties) { + if (StringUtils.isEmpty(conflictProperty) || StringUtils.isEmpty(conflictProperty.trim())) { + throw new IllegalStateException("Upsert conflict property cannot be blank"); + } + PersistentPropertyPath propertyPath; + try { + propertyPath = entity.getPropertyPath(conflictProperty); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("Invalid upsert conflict property path: " + conflictProperty, e); + } + if (propertyPath == null) { + throw new IllegalStateException("Upsert conflict property does not exist: " + conflictProperty); + } + PersistentEntityUtils.traversePersistentProperties(propertyPath, (associations, property) -> { + if (SqlQueryBuilderUtils.isGeneratedProperty(property, associations)) { + throw new IllegalStateException("Upsert requires a non-generated conflict property: " + conflictProperty); + } + String path = toPathString(associations, property); + if (!conflictPropertyPaths.contains(path)) { + conflictPropertyPaths.add(path); + } + }); + } + return conflictPropertyPaths; + } + + private String toPathString(List associations, PersistentProperty property) { + return toPathString(sqlQueryBuilder.asStringPath(associations, property)); + } + + private String toPathString(String[] path) { + return String.join(".", path); + } + + private String buildH2Upsert(String tableName, UpsertData data) { + return "MERGE INTO " + tableName + " (" + data.columnNames() + ") KEY(" + data.conflictColumnNames() + ") VALUES (" + data.valueExpressions() + CLOSE_BRACKET; + } + + private String buildMySqlUpsert(String tableName, UpsertData data) { + List updateColumns = data.updateColumnsOrConflict(); + return buildInsertStatement(tableName, data) + + " ON DUPLICATE KEY UPDATE " + + updateColumns.stream() + .map(column -> column.column() + "=" + column.value()) + .collect(Collectors.joining(String.valueOf(COMMA))); + } + + private String buildPostgresUpsert(String tableName, UpsertData data) { + List updateColumns = data.updateColumns(); + String conflict = buildInsertStatement(tableName, data) + " ON CONFLICT (" + data.conflictColumnNames() + CLOSE_BRACKET; + if (updateColumns.isEmpty()) { + return conflict + " DO NOTHING"; + } + return conflict + + " DO UPDATE SET " + + updateColumns.stream() + .map(column -> column.column() + "=EXCLUDED." + column.column()) + .collect(Collectors.joining(String.valueOf(COMMA))); + } + + private String buildSqlServerUpsert(String tableName, UpsertData data) { + return "MERGE INTO " + tableName + " WITH (HOLDLOCK) AS target " + + "USING (VALUES (" + data.sourceValueExpressions() + ")) AS source (" + data.sourceColumns() + ") " + + "ON " + upsertConflictPredicate(data) + + upsertMatchedClause(data) + + upsertInsertClause(data); + } + + private String buildOracleUpsert(String tableName, UpsertData data) { + String sourceSelect = data.columns().stream() + .filter(UpsertColumn::sourceColumn) + .map(column -> column.value() + BLANK_SPACE + column.source()) + .collect(Collectors.joining(String.valueOf(COMMA))); + return "MERGE INTO " + tableName + " target " + + "USING (SELECT " + sourceSelect + " FROM DUAL) source " + + "ON (" + upsertConflictPredicate(data) + CLOSE_BRACKET + + upsertMatchedClause(data) + + upsertInsertClause(data); + } + + private String buildAnsiUpsert(String tableName, UpsertData data) { + return "MERGE INTO " + tableName + " target " + + "USING (VALUES (" + data.sourceValueExpressions() + ")) source (" + data.sourceColumns() + ") " + + "ON (" + upsertConflictPredicate(data) + CLOSE_BRACKET + + upsertMatchedClause(data) + + upsertInsertClause(data); + } + + private String buildInsertStatement(String tableName, UpsertData data) { + return INSERT_INTO + tableName + " (" + data.columnNames() + ") VALUES (" + data.valueExpressions() + CLOSE_BRACKET; + } + + private String upsertConflictPredicate(UpsertData data) { + return data.conflictColumns().stream() + .map(column -> "target." + column.column() + "=source." + column.source()) + .collect(Collectors.joining(" AND ")); + } + + private String upsertMatchedClause(UpsertData data) { + List updateColumns = data.updateColumns(); + if (updateColumns.isEmpty()) { + return ""; + } + return " WHEN MATCHED THEN UPDATE SET " + + updateColumns.stream() + .map(column -> "target." + column.column() + "=source." + column.source()) + .collect(Collectors.joining(String.valueOf(COMMA))); + } + + private String upsertInsertClause(UpsertData data) { + return " WHEN NOT MATCHED THEN INSERT (" + data.columnNames() + ") VALUES (" + + data.columns().stream() + .map(column -> column.sourceColumn() ? "source." + column.source() : column.value()) + .collect(Collectors.joining(String.valueOf(COMMA))) + + CLOSE_BRACKET; + } + + private record UpsertData(List columns, + List parameterBindings) { + + private String columnNames() { + return columns.stream() + .map(UpsertColumn::column) + .collect(Collectors.joining(String.valueOf(COMMA))); + } + + private String valueExpressions() { + return columns.stream() + .map(UpsertColumn::value) + .collect(Collectors.joining(String.valueOf(COMMA))); + } + + private String sourceValueExpressions() { + return columns.stream() + .filter(UpsertColumn::sourceColumn) + .map(UpsertColumn::value) + .collect(Collectors.joining(String.valueOf(COMMA))); + } + + private String sourceColumns() { + return columns.stream() + .filter(UpsertColumn::sourceColumn) + .map(UpsertColumn::source) + .collect(Collectors.joining(String.valueOf(COMMA))); + } + + private List conflictColumns() { + return columns.stream() + .filter(UpsertColumn::conflict) + .toList(); + } + + private String conflictColumnNames() { + return conflictColumns().stream() + .map(UpsertColumn::column) + .collect(Collectors.joining(String.valueOf(COMMA))); + } + + private List updateColumns() { + return columns.stream() + .filter(column -> !column.identity() && !column.conflict()) + .toList(); + } + + private List updateColumnsOrConflict() { + List updateColumns = updateColumns(); + return updateColumns.isEmpty() ? List.of(conflictColumns().getFirst()) : updateColumns; + } + } + + private record UpsertColumn(String column, + String value, + String source, + boolean sourceColumn, + PersistentProperty property, + List path, + boolean identity, + boolean conflict) { + } + + private record UpsertReturningColumn(String column, + String name, + DataType dataType) { + } +} diff --git a/data-model/src/main/java/io/micronaut/data/model/runtime/StoredQuery.java b/data-model/src/main/java/io/micronaut/data/model/runtime/StoredQuery.java index 41608a3a7d2..ce81d9d10c2 100644 --- a/data-model/src/main/java/io/micronaut/data/model/runtime/StoredQuery.java +++ b/data-model/src/main/java/io/micronaut/data/model/runtime/StoredQuery.java @@ -257,5 +257,11 @@ enum OperationType { * An insert returning operation. */ INSERT_RETURNING, + /** + * An upsert operation. + * + * @since 5.1.0 + */ + UPSERT, } } diff --git a/data-processor/src/main/java/io/micronaut/data/processor/visitors/RepositoryTypeElementVisitor.java b/data-processor/src/main/java/io/micronaut/data/processor/visitors/RepositoryTypeElementVisitor.java index c41595c405a..d0405f382c9 100644 --- a/data-processor/src/main/java/io/micronaut/data/processor/visitors/RepositoryTypeElementVisitor.java +++ b/data-processor/src/main/java/io/micronaut/data/processor/visitors/RepositoryTypeElementVisitor.java @@ -43,6 +43,7 @@ import io.micronaut.data.annotation.RepositoryConfiguration; import io.micronaut.data.annotation.TypeRole; import io.micronaut.data.annotation.Update; +import io.micronaut.data.annotation.Upsert; import io.micronaut.data.annotation.sql.Procedure; import io.micronaut.data.intercept.annotation.DataMethod; import io.micronaut.data.intercept.annotation.DataMethodQuery; @@ -998,7 +999,10 @@ private SourcePersistentEntity resolvePersistentEntity(ClassElement repositoryCl private SourcePersistentEntity resolvePersistentEntityFromLifecycleMethods(MethodElement element, List parametersNotInRole, Function entityResolver) { - if (element.hasStereotype(Insert.class) || element.hasStereotype(Update.class) || element.hasStereotype(Delete.class)) { + if (element.hasStereotype(Insert.class) + || element.hasStereotype(Update.class) + || element.hasStereotype(Upsert.class) + || element.hasStereotype(Delete.class)) { if (!parametersNotInRole.isEmpty()) { ClassElement type = parametersNotInRole.iterator().next().getGenericType(); if (type.isArray()) { diff --git a/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/FindersUtils.java b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/FindersUtils.java index 95ea62823fd..096153d38f2 100644 --- a/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/FindersUtils.java +++ b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/FindersUtils.java @@ -176,7 +176,7 @@ static FindersUtils.InterceptorMatch resolveInterceptorTypeByOperationType(boole yield updateEntry; } } - case UPDATE -> { + case UPDATE, UPSERT -> { InterceptorMatch updateEntry; if (hasMultipleEntityParameter) { updateEntry = pickUpdateAllEntitiesInterceptor(matchContext, returnType); diff --git a/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/UpsertMethodMatcher.java b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/UpsertMethodMatcher.java new file mode 100644 index 00000000000..6cc7973132c --- /dev/null +++ b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/UpsertMethodMatcher.java @@ -0,0 +1,264 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.processor.visitors.finders; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.util.StringUtils; +import io.micronaut.data.annotation.DataAnnotationUtils; +import io.micronaut.data.annotation.TypeRole; +import io.micronaut.data.annotation.Upsert; +import io.micronaut.data.intercept.annotation.DataMethod; +import io.micronaut.data.model.PersistentEntityUtils; +import io.micronaut.data.model.PersistentProperty; +import io.micronaut.data.model.PersistentPropertyPath; +import io.micronaut.data.model.query.builder.QueryBuilder; +import io.micronaut.data.model.query.builder.QueryResult; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.model.query.builder.sql.SqlQueryBuilder; +import io.micronaut.data.processor.model.SourcePersistentEntity; +import io.micronaut.data.processor.visitors.MatchFailedException; +import io.micronaut.data.processor.visitors.MethodMatchContext; +import io.micronaut.inject.annotation.AnnotationMetadataHierarchy; +import io.micronaut.inject.ast.ClassElement; +import io.micronaut.inject.ast.MethodElement; +import io.micronaut.inject.ast.ParameterElement; +import io.micronaut.inject.processing.ProcessingException; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Upsert method matcher. + * + * @since 5.1.0 + */ +@Internal +public final class UpsertMethodMatcher extends AbstractMethodMatcher { + + /** + * The default constructor. + */ + public UpsertMethodMatcher() { + super(MethodNameParser.builder() + .match(QueryMatchId.PREFIX, "upsert") + .tryMatch(QueryMatchId.ALL_OR_ONE, ALL) + .build()); + } + + @Override + @Nullable + public MethodMatch match(MethodMatchContext matchContext) { + if (matchContext.getMethodElement().hasStereotype(Upsert.class)) { + if (!matchContext.hasRootEntity()) { + matchContext.findImplicitRootEntity(); + } + if (!matchContext.hasRootEntity()) { + throw new ProcessingException(matchContext.getMethodElement(), "Repository does not have a well-defined primary entity type"); + } + return match(matchContext, List.of()); + } + return super.match(matchContext); + } + + @Override + @Nullable + protected MethodMatch match(MethodMatchContext matchContext, List matches) { + if (!(matchContext.getQueryBuilder() instanceof SqlQueryBuilder) || matchContext.supportsImplicitQueries()) { + if (matchContext.getMethodElement().hasStereotype(Upsert.class)) { + throw new ProcessingException(matchContext.getMethodElement(), "Cannot implement explicit upsert query: upsert is only supported for explicit SQL repositories"); + } + return null; + } + MethodElement methodElement = matchContext.getMethodElement(); + boolean producesAnEntity = doesMethodProduceEntityOrIterableOfEntity(methodElement); + if (!TypeUtils.doesReturnVoid(methodElement) + && !TypeUtils.doesMethodProducesANumber(methodElement) + && !producesAnEntity) { + ClassElement producingItem = TypeUtils.getMethodProducingItemType(methodElement); + if (producingItem == null) { + throw new ProcessingException(methodElement, "Unsupported return type for an upsert method: " + methodElement.getReturnType()); + } + throw new ProcessingException(methodElement, "Unsupported return type for an upsert method: " + producingItem.getName()); + } + + if (matchContext.getParameters().length == 0) { + throw new ProcessingException(methodElement, "Upsert method requires parameters"); + } + if (matchContext.getParametersNotInRole().size() != 1) { + throw new ProcessingException(methodElement, "Upsert method requires exactly one entity or iterable entity parameter"); + } + if (matchContext.getParametersNotInRole().stream().allMatch(p -> TypeUtils.isIterableOfEntity(p.getGenericType()) || TypeUtils.isEntity(p.getGenericType()))) { + String unsupportedReason = explicitUpsertUnsupportedReason(matchContext); + if (unsupportedReason != null) { + throw new ProcessingException(methodElement, "Cannot implement explicit upsert query: " + unsupportedReason); + } + return upsertEntity(); + } + throw new MatchFailedException("Cannot implement upsert method for specified arguments and return type", methodElement); + } + + @Nullable + private String explicitUpsertUnsupportedReason(MethodMatchContext matchContext) { + if (!matchContext.hasRootEntity()) { + return "repository does not have a well-defined primary entity type"; + } + SourcePersistentEntity rootEntity = matchContext.getRootEntity(); + if (DataAnnotationUtils.hasJsonEntityRepresentationAnnotation(matchContext.getAnnotationMetadata())) { + return "JSON entity representation is not supported"; + } + if (DataAnnotationUtils.hasJsonEntityRepresentationAnnotation(rootEntity.getAnnotationMetadata())) { + return "JSON entity representation is not supported"; + } + if (rootEntity.hasVersion()) { + return "versioned entities are not supported"; + } + List conflictProperties = conflictProperties(matchContext); + if (conflictProperties.isEmpty()) { + if (!rootEntity.hasIdentity() && !rootEntity.hasCompositeIdentity()) { + return "entity does not define an identity and no conflict properties were specified"; + } + if (rootEntity.getIdentityProperties().stream().anyMatch(PersistentProperty::isGenerated)) { + return "generated identity properties are not supported"; + } + } + return validateConflictProperties(rootEntity, conflictProperties); + } + + @Nullable + private String validateConflictProperties(SourcePersistentEntity rootEntity, List conflictProperties) { + for (String conflictProperty : conflictProperties) { + if (StringUtils.isEmpty(conflictProperty) || StringUtils.isEmpty(conflictProperty.trim())) { + return "conflict property cannot be blank"; + } + PersistentPropertyPath propertyPath; + try { + propertyPath = rootEntity.getPropertyPath(conflictProperty); + } catch (IllegalArgumentException e) { + return "invalid conflict property path: " + conflictProperty; + } + if (propertyPath == null) { + return "conflict property does not exist: " + conflictProperty; + } + List generatedProperties = new ArrayList<>(); + PersistentEntityUtils.traversePersistentProperties(propertyPath, (associations, property) -> { + if (property.isGenerated()) { + generatedProperties.add(property); + } + }); + if (!generatedProperties.isEmpty()) { + return "generated conflict properties are not supported"; + } + } + return null; + } + + private MethodMatch upsertEntity() { + return mc -> { + ParameterElement[] parameters = mc.getParameters(); + ParameterElement entityParameter = Arrays.stream(parameters).filter(p -> TypeUtils.isEntity(p.getGenericType())).findFirst().orElse(null); + ParameterElement entitiesParameter = Arrays.stream(parameters).filter(p -> TypeUtils.isIterableOfEntity(p.getGenericType())).findFirst().orElse(null); + if (entityParameter == null && entitiesParameter == null) { + throw new MatchFailedException("Cannot implement upsert method for specified arguments and return type", mc.getMethodElement()); + } + if (entityParameter != null && entitiesParameter != null) { + throw new MatchFailedException("Cannot implement upsert method with both entity and iterable entity parameters", mc.getMethodElement()); + } + + FindersUtils.InterceptorMatch entry = FindersUtils.resolveInterceptorTypeByOperationType( + entityParameter != null, + entitiesParameter != null, + DataMethod.OperationType.UPSERT, + mc + ); + MethodMatchInfo methodMatchInfo = new MethodMatchInfo( + DataMethod.OperationType.UPSERT, + entry.returnType(), + entry.interceptor() + ); + + AnnotationMetadataHierarchy annotationMetadataHierarchy = new AnnotationMetadataHierarchy( + mc.getRepositoryClass().getAnnotationMetadata(), + mc.getAnnotationMetadata() + ); + List conflictProperties = conflictProperties(mc); + boolean returnGeneratedId = shouldUseGeneratedIdReturning(mc, entityParameter); + QueryResult queryResult = mc.getQueryBuilder().buildUpsert(annotationMetadataHierarchy, new QueryBuilder.UpsertQueryDefinition() { + @Override + public SourcePersistentEntity persistentEntity() { + return mc.getRootEntity(); + } + + @Override + public List conflictProperties() { + return conflictProperties; + } + + @Override + public boolean returnGeneratedId() { + return returnGeneratedId; + } + }); + + methodMatchInfo + .encodeEntityParameters(true) + .queryResult(queryResult); + if (entitiesParameter != null) { + methodMatchInfo.addParameterRole(entitiesParameter, TypeRole.ENTITIES); + } + if (entityParameter != null) { + methodMatchInfo.addParameterRole(entityParameter, TypeRole.ENTITY); + } + return methodMatchInfo; + }; + } + + private boolean shouldUseGeneratedIdReturning(MethodMatchContext matchContext, + @Nullable ParameterElement entityParameter) { + boolean entityUpsert = entityParameter != null; + SourcePersistentEntity rootEntity = matchContext.getRootEntity(); + if (!rootEntity.hasIdentity() || rootEntity.getIdentityProperties().stream().noneMatch(PersistentProperty::isGenerated)) { + return false; + } + if (!(matchContext.getQueryBuilder() instanceof SqlQueryBuilder sqlQueryBuilder)) { + return false; + } + Dialect dialect = sqlQueryBuilder.getDialect(); + if (dialect != Dialect.ORACLE && dialect != Dialect.SQL_SERVER) { + return false; + } + if (TypeUtils.doesReturnVoid(matchContext.getMethodElement())) { + return true; + } + ClassElement returnType = TypeUtils.getMethodProducingItemType(matchContext.getMethodElement()); + return returnType != null + && (entityUpsert ? TypeUtils.isEntity(returnType) : producesEntityOrIterableOfEntity(returnType)); + } + + private boolean doesMethodProduceEntityOrIterableOfEntity(MethodElement methodElement) { + return producesEntityOrIterableOfEntity(TypeUtils.getMethodProducingItemType(methodElement)); + } + + private boolean producesEntityOrIterableOfEntity(@Nullable ClassElement type) { + return TypeUtils.isEntity(type) || TypeUtils.isIterableOfEntity(type); + } + + private List conflictProperties(MethodMatchContext matchContext) { + return Arrays.asList(matchContext.getAnnotationMetadata().stringValues(Upsert.class, "conflictsOn")); + } + +} diff --git a/data-processor/src/main/resources/META-INF/services/io.micronaut.data.processor.visitors.finders.MethodMatcher b/data-processor/src/main/resources/META-INF/services/io.micronaut.data.processor.visitors.finders.MethodMatcher index 4eca158d36e..ddd83cde3c4 100644 --- a/data-processor/src/main/resources/META-INF/services/io.micronaut.data.processor.visitors.finders.MethodMatcher +++ b/data-processor/src/main/resources/META-INF/services/io.micronaut.data.processor.visitors.finders.MethodMatcher @@ -9,6 +9,7 @@ io.micronaut.data.processor.visitors.finders.DeleteMethodMatcher io.micronaut.data.processor.visitors.finders.FindMethodMatcher io.micronaut.data.processor.visitors.finders.CountMethodMatcher io.micronaut.data.processor.visitors.finders.UpdateMethodMatcher +io.micronaut.data.processor.visitors.finders.UpsertMethodMatcher io.micronaut.data.processor.visitors.finders.SaveMethodMatcher io.micronaut.data.processor.visitors.finders.VectorSearchMethodMatcher io.micronaut.data.processor.visitors.finders.ProcedureMethodMatcher diff --git a/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildInsertSpec.groovy b/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildInsertSpec.groovy index 82bed2ee54d..93853e0cc35 100644 --- a/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildInsertSpec.groovy +++ b/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildInsertSpec.groovy @@ -16,7 +16,13 @@ package io.micronaut.data.processor.sql import io.micronaut.data.intercept.InsertEntityInterceptor +import io.micronaut.data.intercept.UpdateAllEntitiesInterceptor +import io.micronaut.data.intercept.UpdateEntityInterceptor +import io.micronaut.data.intercept.async.UpdateAllEntriesAsyncInterceptor +import io.micronaut.data.intercept.async.UpdateEntityAsyncInterceptor import io.micronaut.data.intercept.annotation.DataMethod +import io.micronaut.data.intercept.reactive.UpdateAllEntitiesReactiveInterceptor +import io.micronaut.data.intercept.reactive.UpdateEntityReactiveInterceptor import io.micronaut.data.model.DataType import io.micronaut.data.model.entities.Person import io.micronaut.data.model.query.builder.sql.Dialect @@ -195,7 +201,7 @@ class Test { where: dialect | query - Dialect.ORACLE | 'CREATE TABLE "TEST" ("ID" VARCHAR(36) NOT NULL DEFAULT SYS_GUID() PRIMARY KEY,"NAME" VARCHAR(255) NOT NULL)' + Dialect.ORACLE | 'CREATE TABLE "TEST" ("ID" VARCHAR(36) DEFAULT SYS_GUID() NOT NULL PRIMARY KEY,"NAME" VARCHAR(255) NOT NULL)' Dialect.H2 | 'CREATE TABLE `test` (`id` UUID NOT NULL DEFAULT random_uuid() PRIMARY KEY,`name` VARCHAR(255) NOT NULL);' Dialect.POSTGRES | 'CREATE TABLE "test" ("id" UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4(),"name" VARCHAR(255) NOT NULL);' Dialect.SQL_SERVER | 'CREATE TABLE [test] ([id] UNIQUEIDENTIFIER PRIMARY KEY NOT NULL DEFAULT newid(),[name] VARCHAR(255) NOT NULL);' @@ -422,6 +428,653 @@ interface MyInterface extends GenericRepository { getDataInterceptor(save) == "io.micronaut.data.intercept.SaveOneInterceptor" } + @Unroll + void "test build upsert for dialect - #dialect"() { + given: + BeanDefinition beanDefinition = buildRepository('test.MyInterface', """ +import io.micronaut.data.annotation.*; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.GenericRepository; +import java.util.concurrent.CompletionStage; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@JdbcRepository(dialect=Dialect.${dialect.name()}) +@io.micronaut.context.annotation.Executable +interface MyInterface extends GenericRepository { + Test upsert(Test test); + + @Upsert + Test put(Test test); + + @Upsert + java.util.List putAll(java.util.List tests); + + @Upsert + CompletionStage putAsync(Test test); + + @Upsert + CompletionStage> putAllAsync(java.util.List tests); + + @Upsert + Mono putReactive(Test test); + + @Upsert + Flux putAllReactive(java.util.List tests); +} + +@MappedEntity("upsert_test") +class Test { + @Id + private Long id; + private String name; + private Integer pages; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getPages() { + return pages; + } + + public void setPages(Integer pages) { + this.pages = pages; + } +} +""") + + when: + def upsertMethod = beanDefinition.findPossibleMethods("upsert").findFirst().get() + def putMethod = beanDefinition.findPossibleMethods("put").findFirst().get() + def putAllMethod = beanDefinition.findPossibleMethods("putAll").findFirst().get() + def putAsyncMethod = beanDefinition.findPossibleMethods("putAsync").findFirst().get() + def putAllAsyncMethod = beanDefinition.findPossibleMethods("putAllAsync").findFirst().get() + def putReactiveMethod = beanDefinition.findPossibleMethods("putReactive").findFirst().get() + def putAllReactiveMethod = beanDefinition.findPossibleMethods("putAllReactive").findFirst().get() + + then: + getOperationType(upsertMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(upsertMethod) == UpdateEntityInterceptor.name + getQuery(upsertMethod) == query + getParameterPropertyPaths(upsertMethod) == parameterPropertyPaths as String[] + getOperationType(putMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(putMethod) == UpdateEntityInterceptor.name + getQuery(putMethod) == query + getParameterPropertyPaths(putMethod) == parameterPropertyPaths as String[] + getOperationType(putAllMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(putAllMethod) == UpdateAllEntitiesInterceptor.name + getQuery(putAllMethod) == query + getParameterPropertyPaths(putAllMethod) == parameterPropertyPaths as String[] + getOperationType(putAsyncMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(putAsyncMethod) == UpdateEntityAsyncInterceptor.name + getQuery(putAsyncMethod) == query + getParameterPropertyPaths(putAsyncMethod) == parameterPropertyPaths as String[] + getOperationType(putAllAsyncMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(putAllAsyncMethod) == UpdateAllEntriesAsyncInterceptor.name + getQuery(putAllAsyncMethod) == query + getParameterPropertyPaths(putAllAsyncMethod) == parameterPropertyPaths as String[] + getOperationType(putReactiveMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(putReactiveMethod) == UpdateEntityReactiveInterceptor.name + getQuery(putReactiveMethod) == query + getParameterPropertyPaths(putReactiveMethod) == parameterPropertyPaths as String[] + getOperationType(putAllReactiveMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(putAllReactiveMethod) == UpdateAllEntitiesReactiveInterceptor.name + getQuery(putAllReactiveMethod) == query + getParameterPropertyPaths(putAllReactiveMethod) == parameterPropertyPaths as String[] + + where: + dialect | query | parameterPropertyPaths + Dialect.ANSI | 'MERGE INTO "upsert_test" target USING (VALUES (?,?,?)) source (c0,c1,c2) ON (target."id"=source.c2) WHEN MATCHED THEN UPDATE SET target."name"=source.c0,target."pages"=source.c1 WHEN NOT MATCHED THEN INSERT ("name","pages","id") VALUES (source.c0,source.c1,source.c2)' | ["name", "pages", "id"] + Dialect.H2 | 'MERGE INTO `upsert_test` (`name`,`pages`,`id`) KEY(`id`) VALUES (?,?,?)' | ["name", "pages", "id"] + Dialect.MYSQL | 'INSERT INTO `upsert_test` (`name`,`pages`,`id`) VALUES (?,?,?) ON DUPLICATE KEY UPDATE `name`=?,`pages`=?' | ["name", "pages", "id", "name", "pages"] + Dialect.ORACLE | 'MERGE INTO "UPSERT_TEST" target USING (SELECT ? c0,? c1,? c2 FROM DUAL) source ON (target."ID"=source.c2) WHEN MATCHED THEN UPDATE SET target."NAME"=source.c0,target."PAGES"=source.c1 WHEN NOT MATCHED THEN INSERT ("NAME","PAGES","ID") VALUES (source.c0,source.c1,source.c2)' | ["name", "pages", "id"] + Dialect.POSTGRES | 'INSERT INTO "upsert_test" ("name","pages","id") VALUES (?,?,?) ON CONFLICT ("id") DO UPDATE SET "name"=EXCLUDED."name","pages"=EXCLUDED."pages"' | ["name", "pages", "id"] + Dialect.SQLITE | 'INSERT INTO "upsert_test" ("name","pages","id") VALUES (?,?,?) ON CONFLICT ("id") DO UPDATE SET "name"=EXCLUDED."name","pages"=EXCLUDED."pages"' | ["name", "pages", "id"] + Dialect.SQL_SERVER | 'MERGE INTO [upsert_test] WITH (HOLDLOCK) AS target USING (VALUES (?,?,?)) AS source (c0,c1,c2) ON target.[id]=source.c2 WHEN MATCHED THEN UPDATE SET target.[name]=source.c0,target.[pages]=source.c1 WHEN NOT MATCHED THEN INSERT ([name],[pages],[id]) VALUES (source.c0,source.c1,source.c2);' | ["name", "pages", "id"] + } + + @Unroll + void "test build upsert with conflict properties for dialect - #dialect"() { + given: + BeanDefinition beanDefinition = buildRepository('test.MyInterface', """ +import io.micronaut.data.annotation.*; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.GenericRepository; +import java.util.List; + +@JdbcRepository(dialect=Dialect.${dialect.name()}) +@io.micronaut.context.annotation.Executable +interface MyInterface extends GenericRepository { + @Upsert(conflictsOn = "name") + Test put(Test test); + + @Upsert(conflictsOn = "name") + List putAll(List tests); +} + +@MappedEntity("upsert_test") +class Test { + @Id + private Long id; + private String name; + private Integer pages; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getPages() { + return pages; + } + + public void setPages(Integer pages) { + this.pages = pages; + } +} +""") + + when: + def putMethod = beanDefinition.findPossibleMethods("put").findFirst().get() + + then: + getOperationType(putMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(putMethod) == UpdateEntityInterceptor.name + getQuery(putMethod) == query + getParameterPropertyPaths(putMethod) == parameterPropertyPaths as String[] + + where: + dialect | query | parameterPropertyPaths + Dialect.ANSI | 'MERGE INTO "upsert_test" target USING (VALUES (?,?,?)) source (c0,c1,c2) ON (target."name"=source.c0) WHEN MATCHED THEN UPDATE SET target."pages"=source.c1 WHEN NOT MATCHED THEN INSERT ("name","pages","id") VALUES (source.c0,source.c1,source.c2)' | ["name", "pages", "id"] + Dialect.H2 | 'MERGE INTO `upsert_test` (`name`,`pages`,`id`) KEY(`name`) VALUES (?,?,?)' | ["name", "pages", "id"] + Dialect.MYSQL | 'INSERT INTO `upsert_test` (`name`,`pages`,`id`) VALUES (?,?,?) ON DUPLICATE KEY UPDATE `pages`=?' | ["name", "pages", "id", "pages"] + Dialect.ORACLE | 'MERGE INTO "UPSERT_TEST" target USING (SELECT ? c0,? c1,? c2 FROM DUAL) source ON (target."NAME"=source.c0) WHEN MATCHED THEN UPDATE SET target."PAGES"=source.c1 WHEN NOT MATCHED THEN INSERT ("NAME","PAGES","ID") VALUES (source.c0,source.c1,source.c2)' | ["name", "pages", "id"] + Dialect.POSTGRES | 'INSERT INTO "upsert_test" ("name","pages","id") VALUES (?,?,?) ON CONFLICT ("name") DO UPDATE SET "pages"=EXCLUDED."pages"' | ["name", "pages", "id"] + Dialect.SQLITE | 'INSERT INTO "upsert_test" ("name","pages","id") VALUES (?,?,?) ON CONFLICT ("name") DO UPDATE SET "pages"=EXCLUDED."pages"' | ["name", "pages", "id"] + Dialect.SQL_SERVER | 'MERGE INTO [upsert_test] WITH (HOLDLOCK) AS target USING (VALUES (?,?,?)) AS source (c0,c1,c2) ON target.[name]=source.c0 WHEN MATCHED THEN UPDATE SET target.[pages]=source.c1 WHEN NOT MATCHED THEN INSERT ([name],[pages],[id]) VALUES (source.c0,source.c1,source.c2);' | ["name", "pages", "id"] + } + + @Unroll + void "test build upsert with generated identity and conflict properties for dialect - #dialect"() { + given: + BeanDefinition beanDefinition = buildRepository('test.MyInterface', """ +import io.micronaut.data.annotation.*; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.GenericRepository; +import java.util.List; + +@JdbcRepository(dialect=Dialect.${dialect.name()}) +@io.micronaut.context.annotation.Executable +interface MyInterface extends GenericRepository { + @Upsert(conflictsOn = "name") + Test put(Test test); + + @Upsert(conflictsOn = "name") + List putAll(List tests); +} + +@MappedEntity("upsert_test") +class Test { + @Id + @GeneratedValue + private Long id; + private String name; + private Integer pages; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getPages() { + return pages; + } + + public void setPages(Integer pages) { + this.pages = pages; + } +} +""") + + when: + def putMethod = beanDefinition.findPossibleMethods("put").findFirst().get() + def outBindingParameters = getOutBindingParameters(putMethod) + def putAllMethod = beanDefinition.findPossibleMethods("putAll").findFirst().get() + def putAllOutBindingParameters = getOutBindingParameters(putAllMethod) + + then: + getOperationType(putMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(putMethod) == UpdateEntityInterceptor.name + getQuery(putMethod) == query + getParameterPropertyPaths(putMethod) == parameterPropertyPaths as String[] + outBindingParameters.length == outBindingParameterNames.size() + outBindingParameters*.name == outBindingParameterNames + outBindingParameters*.dataType == outBindingParameterDataTypes + getOperationType(putAllMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(putAllMethod) == UpdateAllEntitiesInterceptor.name + getQuery(putAllMethod) == query + getParameterPropertyPaths(putAllMethod) == parameterPropertyPaths as String[] + putAllOutBindingParameters.length == outBindingParameterNames.size() + putAllOutBindingParameters*.name == outBindingParameterNames + putAllOutBindingParameters*.dataType == outBindingParameterDataTypes + + where: + dialect | query | parameterPropertyPaths | outBindingParameterNames | outBindingParameterDataTypes + Dialect.ANSI | 'MERGE INTO "upsert_test" target USING (VALUES (?,?)) source (c0,c1) ON (target."name"=source.c0) WHEN MATCHED THEN UPDATE SET target."pages"=source.c1 WHEN NOT MATCHED THEN INSERT ("name","pages") VALUES (source.c0,source.c1)' | ["name", "pages"] | [] | [] + Dialect.H2 | 'MERGE INTO `upsert_test` (`name`,`pages`) KEY(`name`) VALUES (?,?)' | ["name", "pages"] | [] | [] + Dialect.MYSQL | 'INSERT INTO `upsert_test` (`name`,`pages`) VALUES (?,?) ON DUPLICATE KEY UPDATE `pages`=?' | ["name", "pages", "pages"] | [] | [] + Dialect.ORACLE | 'MERGE INTO "UPSERT_TEST" target USING (SELECT ? c0,? c1 FROM DUAL) source ON (target."NAME"=source.c0) WHEN MATCHED THEN UPDATE SET target."PAGES"=source.c1 WHEN NOT MATCHED THEN INSERT ("NAME","PAGES","ID") VALUES (source.c0,source.c1,"UPSERT_TEST_SEQ".nextval) RETURNING "ID" INTO ?' | ["name", "pages"] | ["id"] | [DataType.LONG] + Dialect.POSTGRES | 'INSERT INTO "upsert_test" ("name","pages") VALUES (?,?) ON CONFLICT ("name") DO UPDATE SET "pages"=EXCLUDED."pages"' | ["name", "pages"] | [] | [] + Dialect.SQLITE | 'INSERT INTO "upsert_test" ("name","pages") VALUES (?,?) ON CONFLICT ("name") DO UPDATE SET "pages"=EXCLUDED."pages"' | ["name", "pages"] | [] | [] + Dialect.SQL_SERVER | 'MERGE INTO [upsert_test] WITH (HOLDLOCK) AS target USING (VALUES (?,?)) AS source (c0,c1) ON target.[name]=source.c0 WHEN MATCHED THEN UPDATE SET target.[pages]=source.c1 WHEN NOT MATCHED THEN INSERT ([name],[pages]) VALUES (source.c0,source.c1) OUTPUT inserted.[id];' | ["name", "pages"] | ["id"] | [DataType.LONG] + } + + @Unroll + void "test build Oracle upsert with generated #generationType identity and conflict properties"() { + given: + BeanDefinition beanDefinition = buildRepository('test.MyInterface', """ +import io.micronaut.data.annotation.*; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.GenericRepository; +import java.util.List; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@JdbcRepository(dialect=Dialect.ORACLE) +@io.micronaut.context.annotation.Executable +interface MyInterface extends GenericRepository { + @Upsert(conflictsOn = "name") + Test put(Test test); + + @Upsert(conflictsOn = "name") + Mono putMono(Test test); + + @Upsert(conflictsOn = "name") + Flux putFlux(List tests); + + @Upsert(conflictsOn = "name") + void putNoResult(Test test); +} + +@MappedEntity("upsert_test") +class Test { + @Id + @GeneratedValue(value = GeneratedValue.Type.${generationType}) + private Long id; + private String name; + private Integer pages; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getPages() { + return pages; + } + + public void setPages(Integer pages) { + this.pages = pages; + } +} +""") + + when: + def putMethod = beanDefinition.findPossibleMethods("put").findFirst().get() + def putMonoMethod = beanDefinition.findPossibleMethods("putMono").findFirst().get() + def putFluxMethod = beanDefinition.findPossibleMethods("putFlux").findFirst().get() + def putNoResultMethod = beanDefinition.findPossibleMethods("putNoResult").findFirst().get() + + then: + [putMethod, putMonoMethod, putFluxMethod, putNoResultMethod].each { method -> + assert getQuery(method) == 'MERGE INTO "UPSERT_TEST" target USING (SELECT ? c0,? c1 FROM DUAL) source ON (target."NAME"=source.c0) WHEN MATCHED THEN UPDATE SET target."PAGES"=source.c1 WHEN NOT MATCHED THEN INSERT ("NAME","PAGES","ID") VALUES (source.c0,source.c1,"UPSERT_TEST_SEQ".nextval) RETURNING "ID" INTO ?' + assert getParameterPropertyPaths(method) == ["name", "pages"] as String[] + assert getOutBindingParameters(method)*.name == ["id"] + } + + where: + generationType << ["AUTO", "SEQUENCE"] + } + + @Unroll + void "test build upsert with multiple conflict properties for dialect - #dialect"() { + given: + BeanDefinition beanDefinition = buildRepository('test.MyInterface', """ +import io.micronaut.data.annotation.*; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.GenericRepository; + +@JdbcRepository(dialect=Dialect.${dialect.name()}) +@io.micronaut.context.annotation.Executable +interface MyInterface extends GenericRepository { + @Upsert(conflictsOn = {"name", "pages"}) + Test put(Test test); +} + +@MappedEntity("upsert_test") +class Test { + @Id + private Long id; + private String name; + private Integer pages; + private String description; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getPages() { + return pages; + } + + public void setPages(Integer pages) { + this.pages = pages; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} +""") + + when: + def putMethod = beanDefinition.findPossibleMethods("put").findFirst().get() + + then: + getOperationType(putMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(putMethod) == UpdateEntityInterceptor.name + getQuery(putMethod) == query + getParameterPropertyPaths(putMethod) == parameterPropertyPaths as String[] + + where: + dialect | query | parameterPropertyPaths + Dialect.ANSI | 'MERGE INTO "upsert_test" target USING (VALUES (?,?,?,?)) source (c0,c1,c2,c3) ON (target."name"=source.c0 AND target."pages"=source.c1) WHEN MATCHED THEN UPDATE SET target."description"=source.c2 WHEN NOT MATCHED THEN INSERT ("name","pages","description","id") VALUES (source.c0,source.c1,source.c2,source.c3)' | ["name", "pages", "description", "id"] + Dialect.H2 | 'MERGE INTO `upsert_test` (`name`,`pages`,`description`,`id`) KEY(`name`,`pages`) VALUES (?,?,?,?)' | ["name", "pages", "description", "id"] + Dialect.MYSQL | 'INSERT INTO `upsert_test` (`name`,`pages`,`description`,`id`) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE `description`=?' | ["name", "pages", "description", "id", "description"] + Dialect.ORACLE | 'MERGE INTO "UPSERT_TEST" target USING (SELECT ? c0,? c1,? c2,? c3 FROM DUAL) source ON (target."NAME"=source.c0 AND target."PAGES"=source.c1) WHEN MATCHED THEN UPDATE SET target."DESCRIPTION"=source.c2 WHEN NOT MATCHED THEN INSERT ("NAME","PAGES","DESCRIPTION","ID") VALUES (source.c0,source.c1,source.c2,source.c3)' | ["name", "pages", "description", "id"] + Dialect.POSTGRES | 'INSERT INTO "upsert_test" ("name","pages","description","id") VALUES (?,?,?,?) ON CONFLICT ("name","pages") DO UPDATE SET "description"=EXCLUDED."description"' | ["name", "pages", "description", "id"] + Dialect.SQLITE | 'INSERT INTO "upsert_test" ("name","pages","description","id") VALUES (?,?,?,?) ON CONFLICT ("name","pages") DO UPDATE SET "description"=EXCLUDED."description"' | ["name", "pages", "description", "id"] + Dialect.SQL_SERVER | 'MERGE INTO [upsert_test] WITH (HOLDLOCK) AS target USING (VALUES (?,?,?,?)) AS source (c0,c1,c2,c3) ON target.[name]=source.c0 AND target.[pages]=source.c1 WHEN MATCHED THEN UPDATE SET target.[description]=source.c2 WHEN NOT MATCHED THEN INSERT ([name],[pages],[description],[id]) VALUES (source.c0,source.c1,source.c2,source.c3);' | ["name", "pages", "description", "id"] + } + + void "test annotated upsert on repository without base interface"() { + given: + BeanDefinition beanDefinition = buildRepository('test.MyInterface', """ +import io.micronaut.data.annotation.*; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import java.util.List; + +@JdbcRepository(dialect=Dialect.H2) +@io.micronaut.context.annotation.Executable +interface MyInterface { + @Upsert + Test put(Test test); + + @Upsert + List putAll(List tests); +} + +@MappedEntity("upsert_test") +class Test { + @Id + private Long id; + private String name; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} +""") + + when: + def putMethod = beanDefinition.findPossibleMethods("put").findFirst().get() + def putAllMethod = beanDefinition.findPossibleMethods("putAll").findFirst().get() + + then: + getOperationType(putMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(putMethod) == UpdateEntityInterceptor.name + getQuery(putMethod) == 'MERGE INTO `upsert_test` (`name`,`id`) KEY(`id`) VALUES (?,?)' + getParameterPropertyPaths(putMethod) == ["name", "id"] as String[] + getOperationType(putAllMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(putAllMethod) == UpdateAllEntitiesInterceptor.name + getQuery(putAllMethod) == 'MERGE INTO `upsert_test` (`name`,`id`) KEY(`id`) VALUES (?,?)' + getParameterPropertyPaths(putAllMethod) == ["name", "id"] as String[] + } + + @Unroll + void "test build upsert fails for unsupported explicit upsert - #description"() { + when: + buildRepository('test.MyInterface', """ +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.GenericRepository; + +@JdbcRepository(dialect=Dialect.H2) +@io.micronaut.context.annotation.Executable +interface MyInterface extends GenericRepository { + ${methodRepresentation} + Test upsert(Test test); +} + +${entityRepresentation} +@MappedEntity("upsert_test") +class Test { + ${idAnnotation} + private Long id; + private String name; + ${versionAnnotation} + private Long version; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getVersion() { + return version; + } + + public void setVersion(Long version) { + this.version = version; + } +} +""") + + then: + def ex = thrown(RuntimeException) + ex.message.contains("Cannot implement explicit upsert query: ${message}") + + where: + description | methodRepresentation | entityRepresentation | idAnnotation | versionAnnotation | message + "method JSON representation" | "@EntityRepresentation(type = EntityRepresentation.Type.COLUMN, columnType = EntityRepresentation.ColumnType.JSON)" | "" | "@Id" | "" | "JSON entity representation is not supported" + "entity JSON representation" | "" | "@EntityRepresentation(type = EntityRepresentation.Type.COLUMN, columnType = EntityRepresentation.ColumnType.JSON)" | "@Id" | "" | "JSON entity representation is not supported" + "missing identity" | "" | "" | "" | "" | "entity does not define an identity" + "versioned entity" | "" | "" | "@Id" | "@Version" | "versioned entities are not supported" + "generated identity" | "" | "" | "@Id\n @GeneratedValue" | "" | "generated identity properties are not supported" + "blank conflict property" | "@Upsert(conflictsOn = \"\")" | "" | "@Id" | "" | "conflict property cannot be blank" + "unknown conflict property" | "@Upsert(conflictsOn = \"missing\")" | "" | "@Id" | "" | "conflict property does not exist: missing" + } + + void "test explicit upsert fails for non sql repository"() { + when: +buildJpaRepository('test.MyInterface', """ +@Repository +interface MyInterface extends GenericRepository { + @io.micronaut.data.annotation.Upsert(conflictsOn = "name") + Test put(Test test); +} + +@io.micronaut.data.annotation.MappedEntity("upsert_test") +class Test { + @Id + private Long id; + private String name; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} +""") + + then: + def ex = thrown(RuntimeException) + ex.message.contains("Cannot implement explicit upsert query: upsert is only supported for explicit SQL repositories") + } + + @Unroll + void "test build upsert fails with invalid entity parameter count - #description"() { + when: + buildRepository('test.MyInterface', """ +import io.micronaut.data.annotation.*; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.GenericRepository; +import java.util.List; + +@JdbcRepository(dialect=Dialect.H2) +@io.micronaut.context.annotation.Executable +interface MyInterface extends GenericRepository { + @Upsert + ${method} +} + +@MappedEntity("upsert_test") +class Test { + @Id + private Long id; + private String name; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} +""") + + then: + def ex = thrown(RuntimeException) + ex.message.contains("Upsert method requires exactly one entity or iterable entity parameter") + + where: + description | method + "two entity parameters" | "Test put(Test test, Test other);" + "entity and iterable" | "List put(Test test, List tests);" + } + void "POSTGRES test build save returning "() { given: def repository = buildRepository('test.BookRepository', """ diff --git a/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildTableSpec.groovy b/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildTableSpec.groovy index a1b4f5361fa..fde246c70bd 100644 --- a/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildTableSpec.groovy +++ b/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildTableSpec.groovy @@ -144,6 +144,44 @@ class Test { sql == 'CREATE TABLE `test` (`id` BIGINT PRIMARY KEY AUTO_INCREMENT,`date_created` TIMESTAMP WITH TIME ZONE);' } + void "test build create table for SQL Server sequence generation"() { + given: + def entity = buildJpaEntity('test.Test', ''' +import io.micronaut.data.annotation.GeneratedValue; + +@Entity +class Test { + + @javax.persistence.Id + @GeneratedValue(value = GeneratedValue.Type.SEQUENCE) + private Long id; + + private String name; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} +''') + SqlQueryBuilder builder = new SqlQueryBuilder(Dialect.SQL_SERVER) + + expect: + builder.buildBatchCreateTableStatement(List.of(), entity) == 'CREATE SEQUENCE [test_seq] AS BIGINT MINVALUE 1 START WITH 1 INCREMENT BY 1' + System.lineSeparator() + + 'CREATE TABLE [test] ([id] BIGINT PRIMARY KEY NOT NULL DEFAULT NEXT VALUE FOR [test_seq],[name] VARCHAR(255) NOT NULL);' + } + void "test custom parent entity with generics"() { given: def entity = buildJpaEntity('test.Test', ''' diff --git a/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/DefaultR2dbcRepositoryOperations.java b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/DefaultR2dbcRepositoryOperations.java index f7be4eea175..880cbe029c3 100644 --- a/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/DefaultR2dbcRepositoryOperations.java +++ b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/DefaultR2dbcRepositoryOperations.java @@ -19,6 +19,7 @@ import io.micronaut.context.ApplicationContext; import io.micronaut.context.annotation.EachBean; import io.micronaut.context.annotation.Parameter; +import io.micronaut.context.annotation.Requires; import io.micronaut.core.annotation.AnnotationMetadata; import io.micronaut.core.annotation.Internal; import io.micronaut.data.model.runtime.convert.DatabaseType; @@ -158,8 +159,9 @@ * @since 1.0.0 */ @EachBean(ConnectionFactory.class) +@Requires(condition = DefaultR2dbcRepositoryOperationsCondition.class) @Internal -final class DefaultR2dbcRepositoryOperations extends AbstractSqlRepositoryOperations +class DefaultR2dbcRepositoryOperations extends AbstractSqlRepositoryOperations implements BlockingExecutorReactorRepositoryOperations, R2dbcRepositoryOperations, R2dbcOperations, DeleteReturningRepositoryOperations, ReactiveCascadeOperations.ReactiveCascadeOperationsHelper { @@ -308,7 +310,7 @@ public List deleteAllReturning(DeleteReturningBatchOperation ope @Override public Mono persistOne(R2dbcOperationContext ctx, T value, RuntimePersistentEntity persistentEntity) { SqlStoredQuery storedQuery = resolveEntityInsert(ctx.annotationMetadata, ctx.repositoryType, (Class) value.getClass(), persistentEntity); - R2dbcEntityOperations op = new R2dbcEntityOperations<>(ctx, storedQuery, persistentEntity, value, true); + R2dbcEntityOperations op = getR2dbcEntityOperations(ctx, persistentEntity, value, storedQuery, true); op.persist(); return op.getEntity(); } @@ -321,7 +323,7 @@ public Flux persistBatch(R2dbcOperationContext ctx, Iterable values, R persistentEntity.getIntrospection().getBeanType(), persistentEntity ); - R2dbcEntitiesOperations op = new R2dbcEntitiesOperations<>(ctx, storedQuery, persistentEntity, values, true); + R2dbcEntitiesOperations op = getR2dbcEntitiesOperations(ctx, persistentEntity, values, storedQuery, true); if (predicate != null) { op.veto(predicate); } @@ -332,7 +334,7 @@ public Flux persistBatch(R2dbcOperationContext ctx, Iterable values, R @Override public Mono updateOne(R2dbcOperationContext ctx, T value, RuntimePersistentEntity persistentEntity) { SqlStoredQuery storedQuery = resolveEntityUpdate(ctx.annotationMetadata, ctx.repositoryType, (Class) value.getClass(), persistentEntity); - R2dbcEntityOperations op = new R2dbcEntityOperations<>(ctx, persistentEntity, value, storedQuery); + R2dbcEntityOperations op = getR2dbcEntityOperations(ctx, persistentEntity, value, storedQuery); op.update(); return op.getEntity(); } @@ -343,7 +345,7 @@ public Mono persistManyAssociation(R2dbcOperationContext ctx, Object value, RuntimePersistentEntity persistentEntity, Object child, RuntimePersistentEntity childPersistentEntity) { SqlStoredQuery storedQuery = resolveSqlInsertAssociation(ctx.repositoryType, runtimeAssociation, persistentEntity, value); - R2dbcEntityOperations assocEntityOp = new R2dbcEntityOperations<>(ctx, childPersistentEntity, child, storedQuery); + R2dbcEntityOperations assocEntityOp = getR2dbcEntityOperations(ctx, childPersistentEntity, child, storedQuery); try { assocEntityOp.execute(); } catch (Exception e1) { @@ -359,7 +361,7 @@ public Mono persistManyAssociationBatch(R2dbcOperationContext ctx, Iterable child, RuntimePersistentEntity childPersistentEntity, Predicate veto) { SqlStoredQuery storedQuery = resolveSqlInsertAssociation(ctx.repositoryType, runtimeAssociation, persistentEntity, value); - R2dbcEntitiesOperations assocEntitiesOp = new R2dbcEntitiesOperations<>(ctx, childPersistentEntity, child, storedQuery); + R2dbcEntitiesOperations assocEntitiesOp = getR2dbcEntitiesOperations(ctx, childPersistentEntity, child, storedQuery); assocEntitiesOp.veto(veto); try { assocEntitiesOp.execute(); @@ -369,6 +371,36 @@ public Mono persistManyAssociationBatch(R2dbcOperationContext ctx, return assocEntitiesOp.getEntities().then(); } + protected R2dbcEntityOperations getR2dbcEntityOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + T entity, + SqlStoredQuery storedQuery) { + return getR2dbcEntityOperations(ctx, persistentEntity, entity, storedQuery, false); + } + + protected R2dbcEntityOperations getR2dbcEntityOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + T entity, + SqlStoredQuery storedQuery, + boolean insert) { + return new R2dbcEntityOperations<>(ctx, storedQuery, persistentEntity, entity, insert); + } + + protected R2dbcEntitiesOperations getR2dbcEntitiesOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + Iterable entities, + SqlStoredQuery storedQuery) { + return getR2dbcEntitiesOperations(ctx, persistentEntity, entities, storedQuery, false); + } + + protected R2dbcEntitiesOperations getR2dbcEntitiesOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + Iterable entities, + SqlStoredQuery storedQuery, + boolean insert) { + return new R2dbcEntitiesOperations<>(ctx, storedQuery, persistentEntity, entities, insert); + } + private Mono sum(Stream> stream) { return stream.reduce((m1, m2) -> m1.zipWith(m2).map(t -> t.getT1().longValue() + t.getT2().longValue())).orElse(Mono.empty()); } @@ -490,7 +522,7 @@ private DataAccessException mapR2dbcException(R2dbcException r2dbcException, Dia return null; } - private static Flux executeAndMapEachRow(Statement statement, Function mapper) { + protected static Flux executeAndMapEachRow(Statement statement, Function mapper) { return Flux.from(statement.execute()) .flatMap(result -> Flux.from(result.map((row, rowMetadata) -> mapper.apply(row)))); } @@ -543,7 +575,7 @@ private static Flux executeAndGetRowsUpdated(Statement statement) { .map((Number n) -> n.longValue()); } - private Function> errorHandler(Dialect dialect) { + protected Function> errorHandler(Dialect dialect) { return throwable -> { if (throwable instanceof R2dbcException r2dbcException) { DataAccessException dataAccessException = mapR2dbcException(r2dbcException, dialect); @@ -555,7 +587,7 @@ private static Flux executeAndGetRowsUpdated(Statement statement) { }; } - private OracleReturningMetadata getOracleReturningMetadata(SqlStoredQuery storedQuery) { + protected OracleReturningMetadata getOracleReturningMetadata(SqlStoredQuery storedQuery) { List outParameterBindings = storedQuery.getOutParameterBindings(); List columnNames = new ArrayList<>(outParameterBindings.size()); for (QueryOutParameterBinding outParameterBinding : outParameterBindings) { @@ -573,7 +605,7 @@ private Flux executeOracleReturningPreparedQuery(Statement statement, Sql .onErrorResume(errorHandler(preparedQuery.getDialect())); } - private boolean isOracleReturningQuery(SqlStoredQuery storedQuery) { + protected boolean isOracleReturningQuery(SqlStoredQuery storedQuery) { OperationType operationType = storedQuery.getOperationType(); return storedQuery.getDialect() == Dialect.ORACLE && (operationType == OperationType.INSERT_RETURNING @@ -581,6 +613,36 @@ private boolean isOracleReturningQuery(SqlStoredQuery storedQuery) { || operationType == OperationType.DELETE_RETURNING); } + protected boolean isUpsertOperation(SqlStoredQuery storedQuery) { + return storedQuery.getOperationType() == OperationType.UPSERT; + } + + @SuppressWarnings("unchecked") + protected SqlStoredQuery prepareStoredQuery(SqlStoredQuery storedQuery, T entity) { + if (storedQuery instanceof SqlPreparedQuery sqlPreparedQuery) { + SqlStoredQuery typedStoredQuery = (SqlStoredQuery) storedQuery; + SqlPreparedQuery typedPreparedQuery = (SqlPreparedQuery) sqlPreparedQuery; + DefaultSqlPreparedQuery entityPreparedQuery = new DefaultSqlPreparedQuery<>(typedPreparedQuery, typedStoredQuery); + entityPreparedQuery.prepare(entity); + return entityPreparedQuery; + } + return storedQuery; + } + + protected @Nullable Object mapOracleOutValue(Readable readable, + Class targetType, + ColumnNameByIndexR2dbcResultReader resultReader, + QueryOutParameterBinding out) { + Object value = resultReader.readDynamic(readable, out.name(), out.dataType()); + if (value == null) { + return null; + } + if (targetType.isInstance(value)) { + return targetType.cast(value); + } + return conversionService.convert(value, targetType).orElse(null); + } + @SuppressWarnings({"unchecked", "rawtypes"}) private SqlTypeMapper createOracleReturningMapper(SqlStoredQuery storedQuery) { OracleReturningMetadata metadata = getOracleReturningMetadata(storedQuery); @@ -641,7 +703,7 @@ public boolean hasNext(Readable resultSet) { ); } if (storedQuery.getOutParameterBindings().size() == 1) { - QueryOutParameterBinding out = storedQuery.getOutParameterBindings().get(0); + QueryOutParameterBinding out = storedQuery.getOutParameterBindings().getFirst(); return new SqlTypeMapper<>() { @Override public boolean hasNext(Readable resultSet) { @@ -650,14 +712,7 @@ public boolean hasNext(Readable resultSet) { @Override public @Nullable R map(Readable object, Class type) throws DataAccessException { - Object value = resultReader.readDynamic(object, out.name(), out.dataType()); - if (value == null) { - return null; - } - if (type.isInstance(value)) { - return type.cast(value); - } - return conversionService.convert(value, type).orElse(null); + return (R) mapOracleOutValue(object, type, resultReader, out); } @Override @@ -674,7 +729,7 @@ private SqlResultEntityTypeMapper getOracleReturningEntityMappe return (SqlResultEntityTypeMapper) createOracleReturningMapper(storedQuery); } - private Statement bindOracleReturningOutParameters(Statement statement, SqlStoredQuery storedQuery, int startIndex) { + protected Statement bindOracleReturningOutParameters(Statement statement, SqlStoredQuery storedQuery, int startIndex) { List outParameterBindings = storedQuery.getOutParameterBindings(); if (CollectionUtils.isEmpty(outParameterBindings)) { throw new DataAccessException("Missing OUT parameter metadata for Oracle RETURNING. SqlQueryBuilder must attach QueryOutParameterBinding list."); @@ -720,14 +775,14 @@ private R2dbcType findR2dbcType(DataType dataType) { }; } - private Mono executeAndMapOracleReturningSingle(Statement statement, Dialect dialect, Function mapper) { + protected Mono executeAndMapOracleReturningSingle(Statement statement, Dialect dialect, Function mapper) { return Flux.from(statement.execute()) .flatMap(result -> Flux.from(result.map(mapper))) .onErrorResume(errorHandler(dialect)) .as(DefaultR2dbcRepositoryOperations::toSingleResult); } - private Mono executeAndMapOracleReturningSingleNullable(Statement statement, Dialect dialect, Function mapper) { + protected Mono executeAndMapOracleReturningSingleNullable(Statement statement, Dialect dialect, Function mapper) { return Flux.from(statement.execute()) .flatMap(result -> Flux.from(result.map(readable -> Mono.justOrEmpty(mapper.apply(readable)))).flatMap(t -> t)) .onErrorResume(errorHandler(dialect)) @@ -913,7 +968,7 @@ public Mono delete(@NonNull DeleteOperation operation) { return executeWriteMono(operation, status -> { final SqlStoredQuery storedQuery = getSqlStoredQuery(operation.getStoredQuery()); final R2dbcOperationContext ctx = createContext(operation, status, storedQuery); - R2dbcEntityOperations op = new R2dbcEntityOperations<>(ctx, storedQuery.getPersistentEntity(), operation.getEntity(), storedQuery); + R2dbcEntityOperations op = getR2dbcEntityOperations(ctx, storedQuery.getPersistentEntity(), operation.getEntity(), storedQuery); op.delete(); return op.getRowsUpdated(); }); @@ -982,13 +1037,13 @@ public Flux persistAll(@NonNull InsertBatchOperation operation) { return concatMono( operation.split().stream() .map(persistOp -> { - R2dbcEntityOperations op = new R2dbcEntityOperations<>(ctx, storedQuery, persistentEntity, persistOp.getEntity(), true); + R2dbcEntityOperations op = getR2dbcEntityOperations(ctx, persistentEntity, persistOp.getEntity(), storedQuery, true); op.persist(); return op.getEntity(); }) ); } else { - R2dbcEntitiesOperations op = new R2dbcEntitiesOperations<>(ctx, storedQuery, persistentEntity, operation, true); + R2dbcEntitiesOperations op = getR2dbcEntitiesOperations(ctx, persistentEntity, operation, storedQuery, true); op.persist(); return op.getEntities(); } @@ -1007,7 +1062,7 @@ public Mono persist(@NonNull InsertOperation operation) { return executeWriteMono(operation, status -> { final SqlStoredQuery storedQuery = getSqlStoredQuery(operation.getStoredQuery()); final R2dbcOperationContext ctx = createContext(operation, status, storedQuery); - R2dbcEntityOperations op = new R2dbcEntityOperations<>(ctx, storedQuery, storedQuery.getPersistentEntity(), operation.getEntity(), true); + R2dbcEntityOperations op = getR2dbcEntityOperations(ctx, storedQuery.getPersistentEntity(), operation.getEntity(), storedQuery, true); op.persist(); return op.getEntity(); }); @@ -1019,7 +1074,7 @@ public Mono update(@NonNull UpdateOperation operation) { return executeWriteMono(operation, status -> { final SqlStoredQuery storedQuery = getSqlStoredQuery(operation.getStoredQuery()); final R2dbcOperationContext ctx = createContext(operation, status, storedQuery); - R2dbcEntityOperations op = new R2dbcEntityOperations<>(ctx, storedQuery.getPersistentEntity(), operation.getEntity(), storedQuery); + R2dbcEntityOperations op = getR2dbcEntityOperations(ctx, storedQuery.getPersistentEntity(), operation.getEntity(), storedQuery, isUpsertOperation(storedQuery)); op.update(); return op.getEntity(); }); @@ -1103,14 +1158,14 @@ public Mono deleteAll(DeleteBatchOperation operation) { RuntimePersistentEntity persistentEntity = storedQuery.getPersistentEntity(); final R2dbcOperationContext ctx = createContext(operation, connection, storedQuery); if (isSupportsBatchDelete(persistentEntity, storedQuery.getDialect())) { - R2dbcEntitiesOperations op = new R2dbcEntitiesOperations<>(ctx, persistentEntity, operation, storedQuery); + R2dbcEntitiesOperations op = getR2dbcEntitiesOperations(ctx, persistentEntity, operation, storedQuery); op.delete(); return op.getRowsUpdated(); } return sum( operation.split().stream() .map(deleteOp -> { - R2dbcEntityOperations op = new R2dbcEntityOperations<>(ctx, persistentEntity, deleteOp.getEntity(), storedQuery); + R2dbcEntityOperations op = getR2dbcEntityOperations(ctx, persistentEntity, deleteOp.getEntity(), storedQuery); op.delete(); return op.getRowsUpdated(); }) @@ -1129,13 +1184,13 @@ public Flux updateAll(@NonNull UpdateBatchOperation operation) { return concatMono( operation.split().stream() .map(updateOp -> { - R2dbcEntityOperations op = new R2dbcEntityOperations<>(ctx, persistentEntity, updateOp.getEntity(), storedQuery); + R2dbcEntityOperations op = getR2dbcEntityOperations(ctx, persistentEntity, updateOp.getEntity(), storedQuery, isUpsertOperation(storedQuery)); op.update(); return op.getEntity(); }) ); } - R2dbcEntitiesOperations op = new R2dbcEntitiesOperations<>(ctx, persistentEntity, operation, storedQuery); + R2dbcEntitiesOperations op = getR2dbcEntitiesOperations(ctx, persistentEntity, operation, storedQuery, isUpsertOperation(storedQuery)); op.update(); return op.getEntities(); }); @@ -1206,7 +1261,7 @@ public ConversionService getConversionService() { } } - private final class R2dbcParameterBinder implements BindableParametersStoredQuery.Binder { + protected final class R2dbcParameterBinder implements BindableParametersStoredQuery.Binder { private final Connection connection; private final Statement ps; @@ -1214,11 +1269,11 @@ private final class R2dbcParameterBinder implements BindableParametersStoredQuer private int index = 0; - private R2dbcParameterBinder(R2dbcOperationContext ctx, Statement ps, SqlStoredQuery sqlStoredQuery) { + R2dbcParameterBinder(R2dbcOperationContext ctx, Statement ps, SqlStoredQuery sqlStoredQuery) { this(ctx.connection, ps, sqlStoredQuery); } - private R2dbcParameterBinder(Connection connection, Statement ps, SqlStoredQuery sqlStoredQuery) { + R2dbcParameterBinder(Connection connection, Statement ps, SqlStoredQuery sqlStoredQuery) { this.connection = connection; this.ps = ps; this.sqlStoredQuery = sqlStoredQuery; @@ -1288,14 +1343,14 @@ public int currentIndex() { } } - private final class R2dbcEntityOperations extends AbstractReactiveEntityOperations { - private final SqlStoredQuery storedQuery; + protected class R2dbcEntityOperations extends AbstractReactiveEntityOperations { + protected final SqlStoredQuery storedQuery; - private R2dbcEntityOperations(R2dbcOperationContext ctx, RuntimePersistentEntity persistentEntity, T entity, SqlStoredQuery storedQuery) { + protected R2dbcEntityOperations(R2dbcOperationContext ctx, RuntimePersistentEntity persistentEntity, T entity, SqlStoredQuery storedQuery) { this(ctx, storedQuery, persistentEntity, entity, false); } - private R2dbcEntityOperations(R2dbcOperationContext ctx, SqlStoredQuery storedQuery, RuntimePersistentEntity persistentEntity, T entity, boolean insert) { + protected R2dbcEntityOperations(R2dbcOperationContext ctx, SqlStoredQuery storedQuery, RuntimePersistentEntity persistentEntity, T entity, boolean insert) { super(ctx, DefaultR2dbcRepositoryOperations.this.cascadeOperations, DefaultR2dbcRepositoryOperations.this.conversionService, @@ -1407,7 +1462,8 @@ protected void execute() throws RuntimeException { .onErrorResume(errorHandler(ctx.dialect)).map(idMapper).last(); } else { return executeAndMapEachRowSingle(statement, ctx.dialect, row -> columnIndexResultSetReader.readDynamic(row, 0, identity.getDataType())) - .map(idMapper); + .map(idMapper) + .switchIfEmpty(isUpsertOperation(storedQuery) ? Mono.just(d) : Mono.empty()); } }); } else { @@ -1430,15 +1486,15 @@ protected void execute() throws RuntimeException { } } - private final class R2dbcEntitiesOperations extends AbstractReactiveEntitiesOperations { + protected class R2dbcEntitiesOperations extends AbstractReactiveEntitiesOperations { - private final SqlStoredQuery storedQuery; + protected final SqlStoredQuery storedQuery; - private R2dbcEntitiesOperations(R2dbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery) { + protected R2dbcEntitiesOperations(R2dbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery) { this(ctx, storedQuery, persistentEntity, entities, false); } - private R2dbcEntitiesOperations(R2dbcOperationContext ctx, SqlStoredQuery storedQuery, RuntimePersistentEntity persistentEntity, Iterable entities, boolean insert) { + protected R2dbcEntitiesOperations(R2dbcOperationContext ctx, SqlStoredQuery storedQuery, RuntimePersistentEntity persistentEntity, Iterable entities, boolean insert) { super(ctx, DefaultR2dbcRepositoryOperations.this.cascadeOperations, DefaultR2dbcRepositoryOperations.this.conversionService, @@ -1495,16 +1551,7 @@ protected void execute() throws RuntimeException { if (d.vetoed) { return Mono.just(d); } - SqlStoredQuery entityStoredQuery = storedQuery; - if (storedQuery instanceof SqlPreparedQuery sqlPreparedQuery) { - @SuppressWarnings("unchecked") - SqlStoredQuery typedStoredQuery = (SqlStoredQuery) storedQuery; - @SuppressWarnings("unchecked") - SqlPreparedQuery typedPreparedQuery = (SqlPreparedQuery) sqlPreparedQuery; - DefaultSqlPreparedQuery entityPreparedQuery = new DefaultSqlPreparedQuery<>(typedPreparedQuery, typedStoredQuery); - entityPreparedQuery.prepare(d.entity); - entityStoredQuery = entityPreparedQuery; - } + SqlStoredQuery entityStoredQuery = prepareStoredQuery(storedQuery, d.entity); Statement statement = ctx.connection.createStatement(entityStoredQuery.getQuery()); R2dbcParameterBinder binder = new R2dbcParameterBinder(ctx, statement, entityStoredQuery); entityStoredQuery.bindParameters(binder, ctx.invocationContext, d.entity, d.previousValues); @@ -1563,7 +1610,9 @@ protected void execute() throws RuntimeException { while (resultIterator.hasNext()) { Data d = resultIterator.next(); if (!iterator.hasNext()) { - throw new DataAccessException("Failed to generate ID for entity: " + d.entity); + if (!isUpsertOperation(storedQuery)) { + throw new DataAccessException("Failed to generate ID for entity: " + d.entity); + } } else { Object id = iterator.next(); d.entity = updateEntityId(identity.getProperty(), d.entity, id); @@ -1618,6 +1667,15 @@ public R2dbcOperationContext(AnnotationMetadata annotationMetadata, @Nullable In this.connection = connection; this.invocationContext = invocationContext; } + + Connection getConnection() { + return connection; + } + + @Nullable + InvocationContext getInvocationContext() { + return invocationContext; + } } private static final class RuntimePersistentPropertyR2dbcCC extends R2dbcConversionContextImpl implements RuntimePersistentPropertyConversionContext { diff --git a/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/OracleR2dbcRepositoryOperations.java b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/OracleR2dbcRepositoryOperations.java new file mode 100644 index 00000000000..66aa4091dcc --- /dev/null +++ b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/OracleR2dbcRepositoryOperations.java @@ -0,0 +1,272 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.operations; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.annotation.EachBean; +import io.micronaut.context.annotation.Parameter; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.beans.BeanProperty; +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.data.connection.reactive.ReactorConnectionOperations; +import io.micronaut.data.exceptions.DataAccessException; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.model.runtime.AttributeConverterRegistry; +import io.micronaut.data.model.runtime.QueryOutParameterBinding; +import io.micronaut.data.model.runtime.QueryParameterBinding; +import io.micronaut.data.model.runtime.RuntimeEntityRegistry; +import io.micronaut.data.model.runtime.RuntimePersistentEntity; +import io.micronaut.data.r2dbc.config.DataR2dbcConfiguration; +import io.micronaut.data.r2dbc.mapper.ColumnNameByIndexR2dbcResultReader; +import io.micronaut.data.r2dbc.transaction.R2dbcReactorTransactionOperations; +import io.micronaut.data.runtime.convert.DataConversionService; +import io.micronaut.data.runtime.convert.DatabaseConversionContextFactory; +import io.micronaut.data.runtime.date.DateTimeProvider; +import io.micronaut.data.runtime.multitenancy.SchemaTenantResolver; +import io.micronaut.data.runtime.operations.internal.sql.OracleReturningMetadata; +import io.micronaut.data.runtime.operations.internal.sql.SqlJsonColumnMapperProvider; +import io.micronaut.data.runtime.operations.internal.sql.SqlStoredQuery; +import io.micronaut.json.JsonMapper; +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.Row; +import io.r2dbc.spi.Statement; +import jakarta.inject.Named; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; + +/** + * Oracle-specific R2DBC repository operations. + * + *

This implementation extends {@link DefaultR2dbcRepositoryOperations} so Oracle can reuse the + * standard R2DBC repository behavior and only replace the entity operation objects that need Oracle + * upsert generated-id returning. Oracle R2DBC exposes DML {@code RETURNING ... INTO} values as OUT + * parameters, so Oracle upsert with generated IDs must bind the OUT parameters from the stored query + * metadata and map the returned readable result instead of relying on generic + * {@link Statement#returnGeneratedValues(String...)} handling.

+ */ +@EachBean(ConnectionFactory.class) +@Requires(condition = OracleR2dbcRepositoryOperationsCondition.class) +@Internal +public final class OracleR2dbcRepositoryOperations extends DefaultR2dbcRepositoryOperations { + + /** + * Default constructor. + * + * @param dataSourceName The data source name + * @param connectionFactory The associated connection factory + * @param dateTimeProvider The date time provider + * @param runtimeEntityRegistry The runtime entity registry + * @param applicationContext The bean context + * @param executorService The executor + * @param conversionService The conversion service + * @param attributeConverterRegistry The attribute converter registry + * @param schemaTenantResolver The schema tenant resolver + * @param schemaHandler The schema handler + * @param configuration The configuration + * @param jsonMapper The JSON mapper + * @param sqlJsonColumnMapperProvider The SQL JSON column mapper provider + * @param r2dbcExceptionMapperList The R2DBC exception mapper list + * @param vectorBindSupports The vector bind supports + * @param transactionOperations The transaction operations + * @param connectionOperations The connection operations + * @param conversionContextFactory The conversion context factory + */ + @Internal + @SuppressWarnings("ParameterNumber") + OracleR2dbcRepositoryOperations( + @Parameter String dataSourceName, + ConnectionFactory connectionFactory, + @NonNull DateTimeProvider dateTimeProvider, + RuntimeEntityRegistry runtimeEntityRegistry, + ApplicationContext applicationContext, + @Nullable @Named("io") ExecutorService executorService, + DataConversionService conversionService, + AttributeConverterRegistry attributeConverterRegistry, + @Nullable SchemaTenantResolver schemaTenantResolver, + R2dbcSchemaHandler schemaHandler, + @Parameter DataR2dbcConfiguration configuration, + @Nullable JsonMapper jsonMapper, + SqlJsonColumnMapperProvider sqlJsonColumnMapperProvider, + List r2dbcExceptionMapperList, + List vectorBindSupports, + @Parameter R2dbcReactorTransactionOperations transactionOperations, + @Parameter ReactorConnectionOperations connectionOperations, + @Parameter DatabaseConversionContextFactory conversionContextFactory) { + super( + dataSourceName, + connectionFactory, + dateTimeProvider, + runtimeEntityRegistry, + applicationContext, + executorService, + conversionService, + attributeConverterRegistry, + schemaTenantResolver, + schemaHandler, + configuration, + jsonMapper, + sqlJsonColumnMapperProvider, + r2dbcExceptionMapperList, + vectorBindSupports, + transactionOperations, + connectionOperations, + conversionContextFactory + ); + } + + @Override + protected R2dbcEntityOperations getR2dbcEntityOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + T entity, + SqlStoredQuery storedQuery) { + return getR2dbcEntityOperations(ctx, persistentEntity, entity, storedQuery, false); + } + + @Override + protected R2dbcEntityOperations getR2dbcEntityOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + T entity, + SqlStoredQuery storedQuery, + boolean insert) { + return new OracleR2dbcEntityOperations<>(ctx, persistentEntity, entity, storedQuery, insert); + } + + @Override + protected R2dbcEntitiesOperations getR2dbcEntitiesOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + Iterable entities, + SqlStoredQuery storedQuery) { + return getR2dbcEntitiesOperations(ctx, persistentEntity, entities, storedQuery, false); + } + + @Override + protected R2dbcEntitiesOperations getR2dbcEntitiesOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + Iterable entities, + SqlStoredQuery storedQuery, + boolean insert) { + return new OracleR2dbcEntitiesOperations<>(ctx, persistentEntity, entities, storedQuery, insert); + } + + private boolean shouldUseOracleUpsertReturning(SqlStoredQuery storedQuery) { + return storedQuery.getDialect() == Dialect.ORACLE + && isUpsertOperation(storedQuery) + && CollectionUtils.isNotEmpty(storedQuery.getOutParameterBindings()); + } + + private Mono executeUpsertReturningId(R2dbcOperationContext ctx, + SqlStoredQuery storedQuery, + T entity, + Class identityType, + @Nullable Map previousValues) { + SqlStoredQuery entityStoredQuery = prepareStoredQuery(storedQuery, entity); + Statement statement = ctx.getConnection().createStatement(entityStoredQuery.getQuery()); + R2dbcParameterBinder binder = new R2dbcParameterBinder(ctx, statement, entityStoredQuery); + entityStoredQuery.bindParameters(binder, ctx.getInvocationContext(), entity, previousValues); + statement = bindOracleReturningOutParameters(statement, entityStoredQuery, binder.currentIndex()); + List outParameterBindings = entityStoredQuery.getOutParameterBindings(); + if (outParameterBindings.size() != 1) { + return Mono.error(new DataAccessException("Oracle upsert RETURNING requires exactly one generated identity OUT parameter, but got: " + outParameterBindings.size())); + } + QueryOutParameterBinding out = outParameterBindings.getFirst(); + OracleReturningMetadata metadata = getOracleReturningMetadata(entityStoredQuery); + ColumnNameByIndexR2dbcResultReader resultReader = new ColumnNameByIndexR2dbcResultReader(conversionService, metadata.columnIndexesByName()); + return executeAndMapOracleReturningSingleNullable(statement, entityStoredQuery.getDialect(), + readable -> mapOracleOutValue(readable, identityType, resultReader, out)); + } + + protected class OracleR2dbcEntityOperations extends R2dbcEntityOperations { + + protected OracleR2dbcEntityOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + T entity, + SqlStoredQuery storedQuery, + boolean insert) { + super(ctx, storedQuery, persistentEntity, entity, insert); + } + + @Override + protected void execute() throws RuntimeException { + if (shouldUseOracleUpsertReturning(storedQuery)) { + executeUpsertReturning(); + } else { + super.execute(); + } + } + + private void executeUpsertReturning() { + QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); + BeanProperty identityProperty = persistentEntity.getIdentity().getProperty(); + data = data.flatMap(d -> { + if (d.vetoed) { + return Mono.just(d); + } + return executeUpsertReturningId(ctx, storedQuery, d.entity, identityProperty.getType(), d.previousValues) + .map(id -> { + d.entity = updateEntityId(identityProperty, d.entity, id); + return d; + }) + .switchIfEmpty(Mono.just(d)); + }); + } + } + + protected class OracleR2dbcEntitiesOperations extends R2dbcEntitiesOperations { + + protected OracleR2dbcEntitiesOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + Iterable entities, + SqlStoredQuery storedQuery, + boolean insert) { + super(ctx, storedQuery, persistentEntity, entities, insert); + } + + @Override + protected void execute() throws RuntimeException { + if (shouldUseOracleUpsertReturning(storedQuery)) { + executeUpsertReturning(); + } else { + super.execute(); + } + } + + private void executeUpsertReturning() { + QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); + BeanProperty identityProperty = persistentEntity.getIdentity().getProperty(); + entities = entities.flatMap(list -> Flux.fromIterable(list) + .concatMap(d -> { + if (d.vetoed) { + return Mono.just(d); + } + return executeUpsertReturningId(ctx, storedQuery, d.entity, identityProperty.getType(), d.previousValues) + .map(id -> { + d.entity = updateEntityId(identityProperty, d.entity, id); + return d; + }) + .switchIfEmpty(Mono.just(d)); + }) + .collectList()); + } + } +} diff --git a/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/R2dbcRepositoryOperationsConditions.java b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/R2dbcRepositoryOperationsConditions.java new file mode 100644 index 00000000000..54edc812230 --- /dev/null +++ b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/R2dbcRepositoryOperationsConditions.java @@ -0,0 +1,160 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.operations; + +import io.micronaut.context.condition.Condition; +import io.micronaut.context.condition.ConditionContext; +import io.micronaut.core.annotation.Internal; +import io.micronaut.data.runtime.support.DataSourceConfigurationUtils; +import io.micronaut.data.r2dbc.config.DataR2dbcConfiguration; + +import java.util.List; +import java.util.Optional; + +/** + * Condition that enables the default R2DBC repository operations for datasources without a specialized implementation. + */ +@Internal +final class DefaultR2dbcRepositoryOperationsCondition implements Condition { + + /** + * Checks whether the current datasource is not configured with a dialect that has specialized R2DBC operations. + * + * @param context The condition context + * @return {@code true} when default R2DBC operations should be enabled + */ + @Override + public boolean matches(ConditionContext context) { + return R2dbcRepositoryOperationsConditions.isDefaultOperationsDialect(context); + } +} + +/** + * Condition that enables Oracle-specific R2DBC repository operations for Oracle datasources. + */ +@Internal +final class OracleR2dbcRepositoryOperationsCondition implements Condition { + + /** + * Checks whether the current datasource is configured with the Oracle dialect. + * + * @param context The condition context + * @return {@code true} when Oracle R2DBC operations should be enabled + */ + @Override + public boolean matches(ConditionContext context) { + return R2dbcRepositoryOperationsConditions.isOracleDialect(context); + } +} + +/** + * Condition that enables SQL Server-specific R2DBC repository operations for SQL Server datasources. + */ +@Internal +final class SqlServerR2dbcRepositoryOperationsCondition implements Condition { + + /** + * Checks whether the current datasource is configured with the SQL Server dialect. + * + * @param context The condition context + * @return {@code true} when SQL Server R2DBC operations should be enabled + */ + @Override + public boolean matches(ConditionContext context) { + return R2dbcRepositoryOperationsConditions.isSqlServerDialect(context); + } +} + +/** + * Shared condition utilities for selecting the R2DBC repository operations bean. + */ +@Internal +final class R2dbcRepositoryOperationsConditions { + + private static final String DATASOURCES = "r2dbc.datasources"; + private static final String DIALECT = "dialect"; + private static final String ORACLE_DIALECT = "ORACLE"; + private static final String SQL_SERVER_DIALECT = "SQL_SERVER"; + private static final String DEFAULT = "default"; + + private R2dbcRepositoryOperationsConditions() { + } + + /** + * Checks whether the datasource associated with the current bean resolution uses the Oracle dialect. + * + * @param context The condition context + * @return {@code true} when the datasource is configured with {@code r2dbc.datasources..dialect=ORACLE} + */ + static boolean isOracleDialect(ConditionContext context) { + return isDialect(context, ORACLE_DIALECT); + } + + /** + * Checks whether the datasource associated with the current bean resolution uses the SQL Server dialect. + * + * @param context The condition context + * @return {@code true} when the datasource is configured with {@code r2dbc.datasources..dialect=SQL_SERVER} + */ + static boolean isSqlServerDialect(ConditionContext context) { + return isDialect(context, SQL_SERVER_DIALECT); + } + + /** + * Checks whether default R2DBC operations should remain available for the current condition evaluation. + * + *

When the condition is evaluated for a specific {@code @EachBean(ConnectionFactory)} instance, + * the current datasource qualifier is available and only that datasource is considered. When + * Micronaut evaluates the bean definition before a datasource qualifier is available, all + * configured datasources are considered so mixed datasource applications can still create default + * operations for non-special datasources while Oracle or SQL Server operations handle their own + * datasources.

+ * + * @param context The condition context + * @return {@code true} when at least the current or one configured datasource should use default operations + */ + static boolean isDefaultOperationsDialect(ConditionContext context) { + Optional dataSourceName = DataSourceConfigurationUtils.resolveDataSourceName(context); + if (dataSourceName.isPresent()) { + return !isDialect(context, dataSourceName.get(), ORACLE_DIALECT) + && !isDialect(context, dataSourceName.get(), SQL_SERVER_DIALECT); + } + List dataSourceNames = DataSourceConfigurationUtils.resolveConfiguredDataSourceNames(context, DATASOURCES, DataR2dbcConfiguration.class); + if (dataSourceNames.isEmpty()) { + return true; + } + return dataSourceNames.stream() + .anyMatch(name -> !isDialect(context, name, ORACLE_DIALECT) && !isDialect(context, name, SQL_SERVER_DIALECT)); + } + + private static boolean isDialect(ConditionContext context, String expectedDialect) { + Optional dataSourceName = DataSourceConfigurationUtils.resolveDataSourceName(context); + if (dataSourceName.isPresent()) { + return isDialect(context, dataSourceName.get(), expectedDialect); + } + List dataSourceNames = DataSourceConfigurationUtils.resolveConfiguredDataSourceNames(context, DATASOURCES, DataR2dbcConfiguration.class); + if (dataSourceNames.isEmpty()) { + return isDialect(context, DEFAULT, expectedDialect); + } + return dataSourceNames.stream().anyMatch(name -> isDialect(context, name, expectedDialect)); + } + + private static boolean isDialect(ConditionContext context, String dataSourceName, String expectedDialect) { + String dialectProperty = DATASOURCES + '.' + dataSourceName + '.' + DIALECT; + String dialect = context.getProperty(dialectProperty, String.class).orElse(null); + return expectedDialect.equalsIgnoreCase(dialect); + } +} diff --git a/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/SqlServerR2dbcRepositoryOperations.java b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/SqlServerR2dbcRepositoryOperations.java new file mode 100644 index 00000000000..249d20d0b15 --- /dev/null +++ b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/SqlServerR2dbcRepositoryOperations.java @@ -0,0 +1,273 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.operations; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.annotation.EachBean; +import io.micronaut.context.annotation.Parameter; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.beans.BeanProperty; +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.data.connection.reactive.ReactorConnectionOperations; +import io.micronaut.data.exceptions.DataAccessException; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.model.runtime.AttributeConverterRegistry; +import io.micronaut.data.model.runtime.QueryOutParameterBinding; +import io.micronaut.data.model.runtime.QueryParameterBinding; +import io.micronaut.data.model.runtime.RuntimeEntityRegistry; +import io.micronaut.data.model.runtime.RuntimePersistentEntity; +import io.micronaut.data.r2dbc.config.DataR2dbcConfiguration; +import io.micronaut.data.r2dbc.transaction.R2dbcReactorTransactionOperations; +import io.micronaut.data.runtime.convert.DataConversionService; +import io.micronaut.data.runtime.convert.DatabaseConversionContextFactory; +import io.micronaut.data.runtime.date.DateTimeProvider; +import io.micronaut.data.runtime.multitenancy.SchemaTenantResolver; +import io.micronaut.data.runtime.operations.internal.sql.SqlJsonColumnMapperProvider; +import io.micronaut.data.runtime.operations.internal.sql.SqlStoredQuery; +import io.micronaut.json.JsonMapper; +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.Row; +import io.r2dbc.spi.Statement; +import jakarta.inject.Named; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; + +/** + * SQL Server-specific R2DBC repository operations. + * + *

This implementation extends {@link DefaultR2dbcRepositoryOperations} so SQL Server can reuse + * the standard R2DBC repository behavior and only replace the entity operation objects that need + * SQL Server-specific upsert generated-id handling. SQL Server {@code MERGE ... OUTPUT inserted.id} + * returns generated IDs as a result row, so the upsert path needs to read that row instead of using + * generic {@link Statement#returnGeneratedValues(String...)} handling.

+ */ +@EachBean(ConnectionFactory.class) +@Requires(condition = SqlServerR2dbcRepositoryOperationsCondition.class) +@Internal +public final class SqlServerR2dbcRepositoryOperations extends DefaultR2dbcRepositoryOperations { + + /** + * Default constructor. + * + * @param dataSourceName The data source name + * @param connectionFactory The associated connection factory + * @param dateTimeProvider The date time provider + * @param runtimeEntityRegistry The runtime entity registry + * @param applicationContext The bean context + * @param executorService The executor + * @param conversionService The conversion service + * @param attributeConverterRegistry The attribute converter registry + * @param schemaTenantResolver The schema tenant resolver + * @param schemaHandler The schema handler + * @param configuration The configuration + * @param jsonMapper The JSON mapper + * @param sqlJsonColumnMapperProvider The SQL JSON column mapper provider + * @param r2dbcExceptionMapperList The R2DBC exception mapper list + * @param vectorBindSupports The vector bind supports + * @param transactionOperations The transaction operations + * @param connectionOperations The connection operations + * @param conversionContextFactory The conversion context factory + */ + @Internal + @SuppressWarnings("ParameterNumber") + SqlServerR2dbcRepositoryOperations( + @Parameter String dataSourceName, + ConnectionFactory connectionFactory, + @NonNull DateTimeProvider dateTimeProvider, + RuntimeEntityRegistry runtimeEntityRegistry, + ApplicationContext applicationContext, + @Nullable @Named("io") ExecutorService executorService, + DataConversionService conversionService, + AttributeConverterRegistry attributeConverterRegistry, + @Nullable SchemaTenantResolver schemaTenantResolver, + R2dbcSchemaHandler schemaHandler, + @Parameter DataR2dbcConfiguration configuration, + @Nullable JsonMapper jsonMapper, + SqlJsonColumnMapperProvider sqlJsonColumnMapperProvider, + List r2dbcExceptionMapperList, + List vectorBindSupports, + @Parameter R2dbcReactorTransactionOperations transactionOperations, + @Parameter ReactorConnectionOperations connectionOperations, + @Parameter DatabaseConversionContextFactory conversionContextFactory) { + super( + dataSourceName, + connectionFactory, + dateTimeProvider, + runtimeEntityRegistry, + applicationContext, + executorService, + conversionService, + attributeConverterRegistry, + schemaTenantResolver, + schemaHandler, + configuration, + jsonMapper, + sqlJsonColumnMapperProvider, + r2dbcExceptionMapperList, + vectorBindSupports, + transactionOperations, + connectionOperations, + conversionContextFactory + ); + } + + @Override + protected R2dbcEntityOperations getR2dbcEntityOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + T entity, + SqlStoredQuery storedQuery) { + return getR2dbcEntityOperations(ctx, persistentEntity, entity, storedQuery, false); + } + + @Override + protected R2dbcEntityOperations getR2dbcEntityOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + T entity, + SqlStoredQuery storedQuery, + boolean insert) { + return new SqlServerR2dbcEntityOperations<>(ctx, persistentEntity, entity, storedQuery, insert); + } + + @Override + protected R2dbcEntitiesOperations getR2dbcEntitiesOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + Iterable entities, + SqlStoredQuery storedQuery) { + return getR2dbcEntitiesOperations(ctx, persistentEntity, entities, storedQuery, false); + } + + @Override + protected R2dbcEntitiesOperations getR2dbcEntitiesOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + Iterable entities, + SqlStoredQuery storedQuery, + boolean insert) { + return new SqlServerR2dbcEntitiesOperations<>(ctx, persistentEntity, entities, storedQuery, insert); + } + + private boolean shouldUseSqlServerUpsertReturning(SqlStoredQuery storedQuery) { + return storedQuery.getDialect() == Dialect.SQL_SERVER + && isUpsertOperation(storedQuery) + && CollectionUtils.isNotEmpty(storedQuery.getOutParameterBindings()); + } + + private Mono executeUpsertReturningId(R2dbcOperationContext ctx, + SqlStoredQuery storedQuery, + T entity, + @Nullable Map previousValues) { + SqlStoredQuery entityStoredQuery = prepareStoredQuery(storedQuery, entity); + Statement statement = ctx.getConnection().createStatement(entityStoredQuery.getQuery()); + R2dbcParameterBinder binder = new R2dbcParameterBinder(ctx, statement, entityStoredQuery); + entityStoredQuery.bindParameters(binder, ctx.getInvocationContext(), entity, previousValues); + List outParameterBindings = entityStoredQuery.getOutParameterBindings(); + if (outParameterBindings.size() != 1) { + return Mono.error(new DataAccessException("SQL Server upsert OUTPUT requires exactly one generated identity OUT parameter, but got: " + outParameterBindings.size())); + } + QueryOutParameterBinding out = outParameterBindings.getFirst(); + return executeAndMapEachRow(statement, row -> columnIndexResultSetReader.readDynamic(row, 0, out.dataType())) + .onErrorResume(errorHandler(entityStoredQuery.getDialect())) + .collectList() + .flatMap(ids -> { + if (ids.isEmpty()) { + return Mono.error(new DataAccessException("SQL Server upsert OUTPUT clause produced no generated ID for entity: " + entity)); + } + if (ids.size() != 1) { + return Mono.error(new DataAccessException("SQL Server upsert OUTPUT clause produced " + ids.size() + " generated IDs for a single entity: " + entity)); + } + return Mono.just(ids.getFirst()); + }); + } + + protected class SqlServerR2dbcEntityOperations extends R2dbcEntityOperations { + + protected SqlServerR2dbcEntityOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + T entity, + SqlStoredQuery storedQuery, + boolean insert) { + super(ctx, storedQuery, persistentEntity, entity, insert); + } + + @Override + protected void execute() throws RuntimeException { + if (shouldUseSqlServerUpsertReturning(storedQuery)) { + executeUpsertReturning(); + } else { + super.execute(); + } + } + + private void executeUpsertReturning() { + QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); + BeanProperty identityProperty = persistentEntity.getIdentity().getProperty(); + data = data.flatMap(d -> { + if (d.vetoed) { + return Mono.just(d); + } + return executeUpsertReturningId(ctx, storedQuery, d.entity, d.previousValues) + .map(id -> { + d.entity = updateEntityId(identityProperty, d.entity, id); + return d; + }); + }); + } + } + + protected class SqlServerR2dbcEntitiesOperations extends R2dbcEntitiesOperations { + + protected SqlServerR2dbcEntitiesOperations(R2dbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + Iterable entities, + SqlStoredQuery storedQuery, + boolean insert) { + super(ctx, storedQuery, persistentEntity, entities, insert); + } + + @Override + protected void execute() throws RuntimeException { + if (shouldUseSqlServerUpsertReturning(storedQuery)) { + executeUpsertReturning(); + } else { + super.execute(); + } + } + + private void executeUpsertReturning() { + QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); + BeanProperty identityProperty = persistentEntity.getIdentity().getProperty(); + entities = entities.flatMap(list -> Flux.fromIterable(list) + .concatMap(d -> { + if (d.vetoed) { + return Mono.just(d); + } + return executeUpsertReturningId(ctx, storedQuery, d.entity, d.previousValues) + .map(id -> { + d.entity = updateEntityId(identityProperty, d.entity, id); + return d; + }); + }) + .collectList()); + } + } +} diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2UpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2UpsertSpec.groovy new file mode 100644 index 00000000000..703f9c31959 --- /dev/null +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2UpsertSpec.groovy @@ -0,0 +1,54 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.h2 + +import io.micronaut.data.r2dbc.h2.upsert.H2CustomerProfileRepository +import io.micronaut.data.r2dbc.h2.upsert.H2CustomerProfileUuidRepository +import io.micronaut.data.r2dbc.h2.upsert.H2ProductReviewRepository +import io.micronaut.data.r2dbc.h2.upsert.H2WarehouseInventoryRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class H2UpsertSpec extends AbstractUpsertSpec implements H2TestPropertyProvider { + + @Override + ProductReviewRepository getProductReviewRepository() { + return context.getBean(H2ProductReviewRepository) + } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(H2CustomerProfileRepository) + } + + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(H2CustomerProfileUuidRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(H2WarehouseInventoryRepository) + } + + @Override + List packages() { + return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert") + } +} diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaDbUpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaDbUpsertSpec.groovy new file mode 100644 index 00000000000..db11aa41e65 --- /dev/null +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaDbUpsertSpec.groovy @@ -0,0 +1,59 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.mariadb + +import io.micronaut.data.r2dbc.mysql.upsert.MySqlCustomerProfileRepository +import io.micronaut.data.r2dbc.mysql.upsert.MySqlCustomerProfileUuidRepository +import io.micronaut.data.r2dbc.mysql.upsert.MySqlProductReviewRepository +import io.micronaut.data.r2dbc.mysql.upsert.MySqlWarehouseInventoryRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class MariaDbUpsertSpec extends AbstractUpsertSpec implements MariaDbTestPropertyProvider { + + @Override + ProductReviewRepository getProductReviewRepository() { + return context.getBean(MySqlProductReviewRepository) + } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(MySqlCustomerProfileRepository) + } + + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(MySqlCustomerProfileUuidRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(MySqlWarehouseInventoryRepository) + } + + @Override + List packages() { + return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert") + } + + @Override + protected boolean supportsGeneratedUuidReturning() { + return false + } +} diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlUpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlUpsertSpec.groovy new file mode 100644 index 00000000000..6c9d5464a21 --- /dev/null +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlUpsertSpec.groovy @@ -0,0 +1,59 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.mysql + +import io.micronaut.data.r2dbc.mysql.upsert.MySqlCustomerProfileRepository +import io.micronaut.data.r2dbc.mysql.upsert.MySqlCustomerProfileUuidRepository +import io.micronaut.data.r2dbc.mysql.upsert.MySqlProductReviewRepository +import io.micronaut.data.r2dbc.mysql.upsert.MySqlWarehouseInventoryRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class MySqlUpsertSpec extends AbstractUpsertSpec implements MySqlTestPropertyProvider { + + @Override + ProductReviewRepository getProductReviewRepository() { + return context.getBean(MySqlProductReviewRepository) + } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(MySqlCustomerProfileRepository) + } + + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(MySqlCustomerProfileUuidRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(MySqlWarehouseInventoryRepository) + } + + @Override + List packages() { + return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert") + } + + @Override + protected boolean supportsGeneratedUuidReturning() { + return false + } +} diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/operations/R2dbcRepositoryOperationsConditionsSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/operations/R2dbcRepositoryOperationsConditionsSpec.groovy new file mode 100644 index 00000000000..2b83f6b324c --- /dev/null +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/operations/R2dbcRepositoryOperationsConditionsSpec.groovy @@ -0,0 +1,176 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.operations + +import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Factory +import io.micronaut.context.annotation.Requires +import io.micronaut.inject.BeanDefinitionReference +import io.micronaut.inject.qualifiers.Qualifiers +import io.r2dbc.spi.Connection +import io.r2dbc.spi.ConnectionFactory +import io.r2dbc.spi.ConnectionFactoryMetadata +import jakarta.inject.Named +import jakarta.inject.Singleton +import org.reactivestreams.Publisher +import reactor.core.publisher.Mono +import spock.lang.Specification + +class R2dbcRepositoryOperationsConditionsSpec extends Specification { + + void "default operations are selected for non-special dialect #dialect"() { + given: + ApplicationContext context = contextWithConnectionFactory('default', dialect) + + expect: + context.getBean(R2dbcOperations, Qualifiers.byName('default')) instanceof DefaultR2dbcRepositoryOperations + + cleanup: + context.close() + + where: + dialect << ['H2', 'MYSQL'] + } + + void "oracle operations are selected for oracle dialect #dialect"() { + given: + ApplicationContext context = contextWithConnectionFactory('default', dialect) + + expect: + context.getBean(R2dbcOperations, Qualifiers.byName('default')) instanceof OracleR2dbcRepositoryOperations + + cleanup: + context.close() + + where: + dialect << ['ORACLE', 'oracle'] + } + + void "sql server operations are selected for sql server dialect #dialect"() { + given: + ApplicationContext context = contextWithConnectionFactory('default', dialect) + + expect: + context.getBean(R2dbcOperations, Qualifiers.byName('default')) instanceof SqlServerR2dbcRepositoryOperations + + cleanup: + context.close() + + where: + dialect << ['SQL_SERVER', 'sql_server'] + } + + void "operations condition uses named datasource dialect #dialect"() { + given: + ApplicationContext context = applicationContextBuilder([ + 'r2dbc.datasources.default.dialect': 'H2', + 'r2dbc.datasources.default.enabled': true, + 'r2dbc.datasources.mdb.enabled' : true, + 'r2dbc.datasources.mdb.dialect' : dialect + ]) + context.start() + + expect: + context.getBean(R2dbcOperations, Qualifiers.byName('default')) instanceof DefaultR2dbcRepositoryOperations + context.getBean(R2dbcOperations, Qualifiers.byName('mdb')).class == operationsType + + cleanup: + context.close() + + where: + dialect | operationsType + 'ORACLE' | OracleR2dbcRepositoryOperations + 'SQL_SERVER' | SqlServerR2dbcRepositoryOperations + } + + void "default operations are selected for named non-special datasource when default is #specialDialect"() { + given: + ApplicationContext context = applicationContextBuilder([ + 'r2dbc.datasources.default.dialect': specialDialect, + 'r2dbc.datasources.default.enabled': true, + 'r2dbc.datasources.mdb.enabled' : true, + 'r2dbc.datasources.mdb.dialect' : 'H2' + ]) + context.start() + + expect: + context.getBean(R2dbcOperations, Qualifiers.byName('default')).class == specialOperationsType + context.getBean(R2dbcOperations, Qualifiers.byName('mdb')) instanceof DefaultR2dbcRepositoryOperations + + cleanup: + context.close() + + where: + specialDialect | specialOperationsType + 'ORACLE' | OracleR2dbcRepositoryOperations + 'SQL_SERVER' | SqlServerR2dbcRepositoryOperations + } + + private ApplicationContext contextWithConnectionFactory(String dataSourceName, String dialect) { + ApplicationContext context = applicationContextBuilder([ + ('r2dbc.datasources.' + dataSourceName + '.enabled'): true, + ('r2dbc.datasources.' + dataSourceName + '.dialect') : dialect + ]) + context.start() + return context + } + + private ApplicationContext applicationContextBuilder(Map properties) { + return ApplicationContext.builder(properties + [ + 'micronaut.test.resources.enabled' : false, + 'r2dbc.repository.operations.conditions.stub-factories': true + ]).beansPredicate(beanType -> { + if (beanType instanceof BeanDefinitionReference) { + String beanDefinitionName = beanType.beanDefinitionName + return !beanDefinitionName.contains('io.micronaut.r2dbc.$DefaultBasicR2dbcProperties') + && !beanDefinitionName.contains('io.micronaut.r2dbc.$R2dbcConnectionFactoryBean') + } + return true + }).build() + } + + @Factory + @Requires(property = 'r2dbc.repository.operations.conditions.stub-factories', value = 'true') + static class StubConnectionFactoryFactory { + + @Singleton + @Named('default') + @Requires(property = 'r2dbc.datasources.default.enabled', value = 'true') + ConnectionFactory defaultConnectionFactory() { + return new StubConnectionFactory() + } + + @Singleton + @Named('mdb') + @Requires(property = 'r2dbc.datasources.mdb.enabled', value = 'true') + ConnectionFactory mdbConnectionFactory() { + return new StubConnectionFactory() + } + } + + private static final class StubConnectionFactory implements ConnectionFactory { + + @Override + Publisher create() { + return Mono.error(new UnsupportedOperationException()) + } + + @Override + ConnectionFactoryMetadata getMetadata() { + return () -> 'stub' + } + } +} diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/OracleXEUpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/OracleXEUpsertSpec.groovy new file mode 100644 index 00000000000..f81df33f78b --- /dev/null +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/OracleXEUpsertSpec.groovy @@ -0,0 +1,153 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.oraclexe + +import io.micronaut.data.r2dbc.oraclexe.upsert.CustomerProfileSequence +import io.micronaut.data.r2dbc.oraclexe.upsert.OracleXECustomerProfileRepository +import io.micronaut.data.r2dbc.oraclexe.upsert.OracleXECustomerProfileSequenceRepository +import io.micronaut.data.r2dbc.oraclexe.upsert.OracleXECustomerProfileUuidRepository +import io.micronaut.data.r2dbc.oraclexe.upsert.OracleXEProductReviewRepository +import io.micronaut.data.r2dbc.oraclexe.upsert.OracleXEWarehouseInventoryRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleXETestPropertyProvider { + + @Override + ProductReviewRepository getProductReviewRepository() { + return context.getBean(OracleXEProductReviewRepository) + } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(OracleXECustomerProfileRepository) + } + + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(OracleXECustomerProfileUuidRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(OracleXEWarehouseInventoryRepository) + } + + OracleXECustomerProfileSequenceRepository getCustomerProfileSequenceRepository() { + return context.getBean(OracleXECustomerProfileSequenceRepository) + } + + @Override + List packages() { + return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert", "io.micronaut.data.r2dbc.oraclexe.upsert") + } + + @Override + protected void cleanupAdditionalRepositories() { + customerProfileSequenceRepository.deleteAll() + } + + void "upsert by email conflict returns entity when sequence id is used"() { + given: + CustomerProfileSequence cp = new CustomerProfileSequence("test@example.com", "test") + + when: + CustomerProfileSequence inserted = customerProfileSequenceRepository.upsert(cp) + + then: + inserted.id != null + inserted == cp + + when: + CustomerProfileSequence found = customerProfileSequenceRepository.findById(cp.id).get() + + then: + assertCustomerProfileSequence(cp, found) + + when: + cp.setDisplayName("test modified") + CustomerProfileSequence updated = customerProfileSequenceRepository.upsert(cp) + + then: + updated == cp + + when: + found = customerProfileSequenceRepository.findById(cp.id).get() + + then: + assertCustomerProfileSequence(cp, found) + } + + void "upsertAll by email conflict returns entities when sequence id is used"() { + given: + CustomerProfileSequence cp1 = new CustomerProfileSequence("test1@example.com", "test 1") + CustomerProfileSequence cp2 = new CustomerProfileSequence("test2@example.com", "test 2") + + when: + List inserted = customerProfileSequenceRepository.upsertAll([cp1, cp2]) + + then: + inserted.size() == 2 + inserted.get(0).id != null + inserted.get(1).id != null + inserted.get(0) == cp1 + inserted.get(1) == cp2 + + when: + CustomerProfileSequence found1 = customerProfileSequenceRepository.findById(cp1.id).get() + CustomerProfileSequence found2 = customerProfileSequenceRepository.findById(cp2.id).get() + + then: + assertCustomerProfileSequence(found1, cp1) + assertCustomerProfileSequence(found2, cp2) + + when: + cp1.setDisplayName("test 1 modified") + cp2.setDisplayName("test 2 modified") + CustomerProfileSequence cp3 = new CustomerProfileSequence("test3@example.com", "test 3") + CustomerProfileSequence cp4 = new CustomerProfileSequence("test4@example.com", "test 4") + List updated = customerProfileSequenceRepository.upsertAll([cp1, cp2, cp3, cp4]) + + then: + updated.size() == 4 + updated.get(0) == cp1 + updated.get(1) == cp2 + updated.get(2).id != null + updated.get(3).id != null + updated.get(2) == cp3 + updated.get(3) == cp4 + + when: + found1 = customerProfileSequenceRepository.findById(cp1.id).get() + found2 = customerProfileSequenceRepository.findById(cp2.id).get() + CustomerProfileSequence found3 = customerProfileSequenceRepository.findById(cp3.id).get() + CustomerProfileSequence found4 = customerProfileSequenceRepository.findById(cp4.id).get() + + then: + assertCustomerProfileSequence(found1, cp1) + assertCustomerProfileSequence(found2, cp2) + assertCustomerProfileSequence(found3, cp3) + assertCustomerProfileSequence(found4, cp4) + } + + private static void assertCustomerProfileSequence(CustomerProfileSequence customerProfile1, CustomerProfileSequence customerProfile2) { + assert customerProfile1.email == customerProfile2.email + assert customerProfile1.displayName == customerProfile2.displayName + } +} diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresDbInit.java b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresDbInit.java index 8fc20c7a6d8..4257f64f93d 100644 --- a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresDbInit.java +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresDbInit.java @@ -15,15 +15,17 @@ */ package io.micronaut.data.r2dbc.postgres; -import io.micronaut.context.annotation.Requires; +import io.micronaut.context.annotation.Value; import io.micronaut.context.event.BeanCreatedEvent; import io.micronaut.context.event.BeanCreatedEventListener; import io.micronaut.core.order.Ordered; +import io.micronaut.core.util.StringUtils; import io.micronaut.r2dbc.DefaultBasicR2dbcProperties; import io.r2dbc.spi.ConnectionFactoryOptions; import io.r2dbc.spi.Option; import jakarta.inject.Singleton; +import java.sql.CallableStatement; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; @@ -46,10 +48,19 @@ * R2DBC connection factory. It is intentionally disabled in GitHub Actions, * where the timing issue has not been observed. */ -@Requires(missingProperty = "github.workflow") @Singleton public class PostgresDbInit implements BeanCreatedEventListener, Ordered { + private final String githubWorkflow; + + private final String specName; + + public PostgresDbInit(@Value("${github.workflow:}") String githubWorkflow, + @Value("${spec.name:}") String specName) { + this.githubWorkflow = githubWorkflow; + this.specName = specName; + } + @Override public int getOrder() { return -10; @@ -58,10 +69,6 @@ public int getOrder() { @Override public DefaultBasicR2dbcProperties onCreated(BeanCreatedEvent event) { DefaultBasicR2dbcProperties configuration = event.getBean(); - // Mirror the bean-level guard with the raw environment variable used by test specs. - if (System.getenv("GITHUB_WORKFLOW") != null) { - return configuration; - } ConnectionFactoryOptions options = configuration.getBuilder().build(); Object driver = options.getValue(Option.valueOf("driver")); @@ -79,7 +86,35 @@ public DefaultBasicR2dbcProperties onCreated(BeanCreatedEvent 0) { @@ -100,7 +135,6 @@ public DefaultBasicR2dbcProperties onCreated(BeanCreatedEvent T requireOption(ConnectionFactoryOptions options, String optionName, Class type) { diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresUpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresUpsertSpec.groovy new file mode 100644 index 00000000000..1de4cd9281a --- /dev/null +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresUpsertSpec.groovy @@ -0,0 +1,160 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.postgres + +import io.micronaut.data.r2dbc.postgres.upsert.CustomerProfileSequence +import io.micronaut.data.r2dbc.postgres.upsert.PostgresCustomerProfileRepository +import io.micronaut.data.r2dbc.postgres.upsert.PostgresCustomerProfileSequenceRepository +import io.micronaut.data.r2dbc.postgres.upsert.PostgresCustomerProfileUuidRepository +import io.micronaut.data.r2dbc.postgres.upsert.PostgresProductReviewRepository +import io.micronaut.data.r2dbc.postgres.upsert.PostgresWarehouseInventoryRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec +import io.micronaut.test.support.TestPropertyProviderFactory + +class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPropertyProvider { + + @Override + ProductReviewRepository getProductReviewRepository() { + return context.getBean(PostgresProductReviewRepository) + } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(PostgresCustomerProfileRepository) + } + + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(PostgresCustomerProfileUuidRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(PostgresWarehouseInventoryRepository) + } + + PostgresCustomerProfileSequenceRepository getCustomerProfileSequenceRepository() { + return context.getBean(PostgresCustomerProfileSequenceRepository) + } + + @Override + Map getProperties() { + def props = getDataSourceProperties("default") + ServiceLoader.load(TestPropertyProviderFactory).stream() + .forEach { + props.putAll(it.get().create(props, this.class).get()) + } + props['spec.name'] = 'PostgresUpsertSpec' + return props + } + + @Override + List packages() { + return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert", "io.micronaut.data.r2dbc.postgres.upsert") + } + + void "upsert by email conflict returns entity when sequence id is used"() { + given: + CustomerProfileSequence cp = new CustomerProfileSequence("test@example.com", "test") + + when: + CustomerProfileSequence inserted = customerProfileSequenceRepository.upsert(cp) + + then: + inserted.id != null + inserted == cp + + when: + CustomerProfileSequence found = customerProfileSequenceRepository.findById(cp.id).get() + + then: + assertCustomerProfileSequence(cp, found) + + when: + cp.setDisplayName("test modified") + CustomerProfileSequence updated = customerProfileSequenceRepository.upsert(cp) + + then: + updated == cp + + when: + found = customerProfileSequenceRepository.findById(cp.id).get() + + then: + assertCustomerProfileSequence(cp, found) + } + + void "upsertAll by email conflict returns entities when sequence id is used"() { + given: + CustomerProfileSequence cp1 = new CustomerProfileSequence("test1@example.com", "test 1") + CustomerProfileSequence cp2 = new CustomerProfileSequence("test2@example.com", "test 2") + + when: + List inserted = customerProfileSequenceRepository.upsertAll([cp1, cp2]) + + then: + inserted.size() == 2 + inserted.get(0).id != null + inserted.get(1).id != null + inserted.get(0) == cp1 + inserted.get(1) == cp2 + + when: + CustomerProfileSequence found1 = customerProfileSequenceRepository.findById(cp1.id).get() + CustomerProfileSequence found2 = customerProfileSequenceRepository.findById(cp2.id).get() + + then: + assertCustomerProfileSequence(found1, cp1) + assertCustomerProfileSequence(found2, cp2) + + when: + cp1.setDisplayName("test 1 modified") + cp2.setDisplayName("test 2 modified") + CustomerProfileSequence cp3 = new CustomerProfileSequence("test3@example.com", "test 3") + CustomerProfileSequence cp4 = new CustomerProfileSequence("test4@example.com", "test 4") + List updated = customerProfileSequenceRepository.upsertAll([cp1, cp2, cp3, cp4]) + + then: + updated.size() == 4 + updated.get(0) == cp1 + updated.get(1) == cp2 + updated.get(2).id != null + updated.get(3).id != null + updated.get(2) == cp3 + updated.get(3) == cp4 + + when: + found1 = customerProfileSequenceRepository.findById(cp1.id).get() + found2 = customerProfileSequenceRepository.findById(cp2.id).get() + CustomerProfileSequence found3 = customerProfileSequenceRepository.findById(cp3.id).get() + CustomerProfileSequence found4 = customerProfileSequenceRepository.findById(cp4.id).get() + + then: + assertCustomerProfileSequence(found1, cp1) + assertCustomerProfileSequence(found2, cp2) + assertCustomerProfileSequence(found3, cp3) + assertCustomerProfileSequence(found4, cp4) + } + + private static void assertCustomerProfileSequence(CustomerProfileSequence customerProfile1, CustomerProfileSequence customerProfile2) { + assert customerProfile1.email == customerProfile2.email + assert customerProfile1.displayName == customerProfile2.displayName + } +} diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/vector/PostgresDbInit.java b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/vector/PostgresDbInit.java deleted file mode 100644 index 0e16af4df22..00000000000 --- a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/vector/PostgresDbInit.java +++ /dev/null @@ -1,74 +0,0 @@ -package io.micronaut.data.r2dbc.postgres.vector; - -import io.micronaut.context.annotation.Context; -import io.micronaut.context.annotation.Requires; -import io.micronaut.context.event.BeanCreatedEvent; -import io.micronaut.context.event.BeanCreatedEventListener; -import io.r2dbc.spi.ConnectionFactoryOptions; -import io.r2dbc.spi.Option; -import jakarta.inject.Singleton; - -import java.sql.CallableStatement; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.SQLException; -import java.util.Properties; -import java.util.concurrent.TimeUnit; - -@Context -@Singleton -@Requires(property = "spec.name", value = "PostgresR2dbcVectorEntitySpec") -public class PostgresDbInit implements BeanCreatedEventListener { - - @Override - public ConnectionFactoryOptions onCreated(BeanCreatedEvent event) { - ConnectionFactoryOptions configuration = event.getBean(); - - final Properties info = new Properties(); - String user = requireOption(configuration, "user", String.class); - String password = requireOption(configuration, "password", String.class); - String host = requireOption(configuration, "host", String.class); - Integer port = requireOption(configuration, "port", Integer.class); - String database = requireOption(configuration, "database", String.class); - info.put("user", user); - info.put("password", password); - - String url = "jdbc:postgresql://" + host + ":" + port + "/" + database; - - int attempts = 30; - SQLException last = null; - while (attempts-- > 0) { - try (Connection connection = DriverManager.getConnection(url, info)) { - // Ensure pgvector extension and demo table for vector tests - try (CallableStatement st = connection.prepareCall("CREATE EXTENSION IF NOT EXISTS vector;")) { - st.execute(); - } - last = null; - break; - } catch (SQLException e) { - last = e; - try { - TimeUnit.SECONDS.sleep(1); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - throw new RuntimeException(ie); - } - } - } - if (last != null) { - throw new RuntimeException(last); - } - return configuration; - } - - private static T requireOption(ConnectionFactoryOptions configuration, String optionName, Class type) { - Object value = configuration.getValue(Option.valueOf(optionName)); - if (value == null) { - throw new IllegalStateException("Missing required R2DBC option: " + optionName); - } - if (!type.isInstance(value)) { - throw new IllegalStateException("Invalid R2DBC option type for " + optionName + ": " + value.getClass().getName()); - } - return type.cast(value); - } -} diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/sqlserver/SqlServerUpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/sqlserver/SqlServerUpsertSpec.groovy new file mode 100644 index 00000000000..7b039d93a54 --- /dev/null +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/sqlserver/SqlServerUpsertSpec.groovy @@ -0,0 +1,148 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.sqlserver + +import io.micronaut.data.r2dbc.sqlserver.upsert.CustomerProfileSequence +import io.micronaut.data.r2dbc.sqlserver.upsert.MSCustomerProfileRepository +import io.micronaut.data.r2dbc.sqlserver.upsert.MSCustomerProfileSequenceRepository +import io.micronaut.data.r2dbc.sqlserver.upsert.MSCustomerProfileUuidRepository +import io.micronaut.data.r2dbc.sqlserver.upsert.MSProductReviewRepository +import io.micronaut.data.r2dbc.sqlserver.upsert.MSWarehouseInventoryRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class SqlServerUpsertSpec extends AbstractUpsertSpec implements SqlServerTestPropertyProvider { + + @Override + ProductReviewRepository getProductReviewRepository() { + return context.getBean(MSProductReviewRepository) + } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(MSCustomerProfileRepository) + } + + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(MSCustomerProfileUuidRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(MSWarehouseInventoryRepository) + } + + MSCustomerProfileSequenceRepository getCustomerProfileSequenceRepository() { + return context.getBean(MSCustomerProfileSequenceRepository) + } + + @Override + List packages() { + return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert", "io.micronaut.data.r2dbc.sqlserver.upsert") + } + + void "upsert by email conflict returns entity when sequence id is used"() { + given: + CustomerProfileSequence cp = new CustomerProfileSequence("test@example.com", "test") + + when: + CustomerProfileSequence inserted = customerProfileSequenceRepository.upsert(cp) + + then: + inserted.id != null + inserted == cp + + when: + CustomerProfileSequence found = customerProfileSequenceRepository.findById(cp.id).get() + + then: + assertCustomerProfileSequence(cp, found) + + when: + cp.setDisplayName("test modified") + CustomerProfileSequence updated = customerProfileSequenceRepository.upsert(cp) + + then: + updated == cp + + when: + found = customerProfileSequenceRepository.findById(cp.id).get() + + then: + assertCustomerProfileSequence(cp, found) + } + + void "upsertAll by email conflict returns entities when sequence id is used"() { + given: + CustomerProfileSequence cp1 = new CustomerProfileSequence("test1@example.com", "test 1") + CustomerProfileSequence cp2 = new CustomerProfileSequence("test2@example.com", "test 2") + + when: + List inserted = customerProfileSequenceRepository.upsertAll([cp1, cp2]) + + then: + inserted.size() == 2 + inserted.get(0).id != null + inserted.get(1).id != null + inserted.get(0) == cp1 + inserted.get(1) == cp2 + + when: + CustomerProfileSequence found1 = customerProfileSequenceRepository.findById(cp1.id).get() + CustomerProfileSequence found2 = customerProfileSequenceRepository.findById(cp2.id).get() + + then: + assertCustomerProfileSequence(found1, cp1) + assertCustomerProfileSequence(found2, cp2) + + when: + cp1.setDisplayName("test 1 modified") + cp2.setDisplayName("test 2 modified") + CustomerProfileSequence cp3 = new CustomerProfileSequence("test3@example.com", "test 3") + CustomerProfileSequence cp4 = new CustomerProfileSequence("test4@example.com", "test 4") + List updated = customerProfileSequenceRepository.upsertAll([cp1, cp2, cp3, cp4]) + + then: + updated.size() == 4 + updated.get(0) == cp1 + updated.get(1) == cp2 + updated.get(2).id != null + updated.get(3).id != null + updated.get(2) == cp3 + updated.get(3) == cp4 + + when: + found1 = customerProfileSequenceRepository.findById(cp1.id).get() + found2 = customerProfileSequenceRepository.findById(cp2.id).get() + CustomerProfileSequence found3 = customerProfileSequenceRepository.findById(cp3.id).get() + CustomerProfileSequence found4 = customerProfileSequenceRepository.findById(cp4.id).get() + + then: + assertCustomerProfileSequence(found1, cp1) + assertCustomerProfileSequence(found2, cp2) + assertCustomerProfileSequence(found3, cp3) + assertCustomerProfileSequence(found4, cp4) + } + + private static void assertCustomerProfileSequence(CustomerProfileSequence customerProfile1, CustomerProfileSequence customerProfile2) { + assert customerProfile1.email == customerProfile2.email + assert customerProfile1.displayName == customerProfile2.displayName + } +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2CustomerProfileRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2CustomerProfileRepository.java new file mode 100644 index 00000000000..57bf8e073c8 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2CustomerProfileRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.h2.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository; + +@R2dbcRepository(dialect = Dialect.H2) +public interface H2CustomerProfileRepository extends CustomerProfileRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2CustomerProfileUuidRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2CustomerProfileUuidRepository.java new file mode 100644 index 00000000000..c21f7779b23 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2CustomerProfileUuidRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.h2.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository; + +@R2dbcRepository(dialect = Dialect.H2) +public interface H2CustomerProfileUuidRepository extends CustomerProfileUuidRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2ProductReviewRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2ProductReviewRepository.java new file mode 100644 index 00000000000..25f2b496e2f --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2ProductReviewRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.h2.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; + +@R2dbcRepository(dialect = Dialect.H2) +public interface H2ProductReviewRepository extends ProductReviewRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2WarehouseInventoryRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2WarehouseInventoryRepository.java new file mode 100644 index 00000000000..9481d7306fe --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2WarehouseInventoryRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.h2.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository; + +@R2dbcRepository(dialect = Dialect.H2) +public interface H2WarehouseInventoryRepository extends WarehouseInventoryRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlCustomerProfileRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlCustomerProfileRepository.java new file mode 100644 index 00000000000..ed530c858a7 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlCustomerProfileRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.mysql.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository; + +@R2dbcRepository(dialect = Dialect.MYSQL) +public interface MySqlCustomerProfileRepository extends CustomerProfileRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlCustomerProfileUuidRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlCustomerProfileUuidRepository.java new file mode 100644 index 00000000000..a4c9ffa8d36 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlCustomerProfileUuidRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.mysql.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository; + +@R2dbcRepository(dialect = Dialect.MYSQL) +public interface MySqlCustomerProfileUuidRepository extends CustomerProfileUuidRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlProductReviewRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlProductReviewRepository.java new file mode 100644 index 00000000000..26c2ad1bfe0 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlProductReviewRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.mysql.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; + +@R2dbcRepository(dialect = Dialect.MYSQL) +public interface MySqlProductReviewRepository extends ProductReviewRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlWarehouseInventoryRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlWarehouseInventoryRepository.java new file mode 100644 index 00000000000..a9b438ab95a --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlWarehouseInventoryRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.mysql.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository; + +@R2dbcRepository(dialect = Dialect.MYSQL) +public interface MySqlWarehouseInventoryRepository extends WarehouseInventoryRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/CustomerProfileSequence.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/CustomerProfileSequence.java new file mode 100644 index 00000000000..fb5b9923910 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/CustomerProfileSequence.java @@ -0,0 +1,77 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.oraclexe.upsert; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Index; +import io.micronaut.data.annotation.MappedEntity; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; +import org.jspecify.annotations.Nullable; + +@MappedEntity +@Index(columns = "email", unique = true) +public class CustomerProfileSequence { + + @Id + @GeneratedValue(value = GeneratedValue.Type.SEQUENCE) + @Nullable + private Long id; + + @NotBlank + private String email; + + @NotBlank + private String displayName; + + public CustomerProfileSequence() { + } + + public CustomerProfileSequence(String email, String displayName) { + this(null, email, displayName); + } + + public CustomerProfileSequence(@Nullable Long id, String email, String displayName) { + this.id = id; + this.email = email; + this.displayName = displayName; + } + + @Nullable + public Long getId() { + return id; + } + + public void setId(@Nullable Long id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXECustomerProfileRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXECustomerProfileRepository.java new file mode 100644 index 00000000000..0eecbfe425a --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXECustomerProfileRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.oraclexe.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository; + +@R2dbcRepository(dialect = Dialect.ORACLE) +public interface OracleXECustomerProfileRepository extends CustomerProfileRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXECustomerProfileSequenceRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXECustomerProfileSequenceRepository.java new file mode 100644 index 00000000000..57c10637a40 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXECustomerProfileSequenceRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.oraclexe.upsert; + +import io.micronaut.data.annotation.Upsert; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.repository.CrudRepository; + +import java.util.List; + +@R2dbcRepository(dialect = Dialect.ORACLE) +public interface OracleXECustomerProfileSequenceRepository extends CrudRepository { + + @Upsert(conflictsOn = "email") + CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); + + @Upsert(conflictsOn = "email") + List upsertAll(Iterable customerProfiles); +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXECustomerProfileUuidRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXECustomerProfileUuidRepository.java new file mode 100644 index 00000000000..2ae99dc47e4 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXECustomerProfileUuidRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.oraclexe.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository; + +@R2dbcRepository(dialect = Dialect.ORACLE) +public interface OracleXECustomerProfileUuidRepository extends CustomerProfileUuidRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXEProductReviewRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXEProductReviewRepository.java new file mode 100644 index 00000000000..169e887c3f7 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXEProductReviewRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.oraclexe.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; + +@R2dbcRepository(dialect = Dialect.ORACLE) +public interface OracleXEProductReviewRepository extends ProductReviewRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXEWarehouseInventoryRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXEWarehouseInventoryRepository.java new file mode 100644 index 00000000000..f9f6354c75f --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXEWarehouseInventoryRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.oraclexe.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository; + +@R2dbcRepository(dialect = Dialect.ORACLE) +public interface OracleXEWarehouseInventoryRepository extends WarehouseInventoryRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/CustomerProfileSequence.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/CustomerProfileSequence.java new file mode 100644 index 00000000000..da09ec4f176 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/CustomerProfileSequence.java @@ -0,0 +1,77 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.postgres.upsert; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Index; +import io.micronaut.data.annotation.MappedEntity; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; +import org.jspecify.annotations.Nullable; + +@MappedEntity +@Index(columns = "email", unique = true) +public class CustomerProfileSequence { + + @Id + @GeneratedValue(value = GeneratedValue.Type.SEQUENCE) + @Nullable + private Long id; + + @NotBlank + private String email; + + @NotBlank + private String displayName; + + public CustomerProfileSequence() { + } + + public CustomerProfileSequence(String email, String displayName) { + this(null, email, displayName); + } + + public CustomerProfileSequence(@Nullable Long id, String email, String displayName) { + this.id = id; + this.email = email; + this.displayName = displayName; + } + + @Nullable + public Long getId() { + return id; + } + + public void setId(@Nullable Long id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresCustomerProfileRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresCustomerProfileRepository.java new file mode 100644 index 00000000000..84dbd72ae3e --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresCustomerProfileRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.postgres.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository; + +@R2dbcRepository(dialect = Dialect.POSTGRES) +public interface PostgresCustomerProfileRepository extends CustomerProfileRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresCustomerProfileSequenceRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresCustomerProfileSequenceRepository.java new file mode 100644 index 00000000000..a41fb50561d --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresCustomerProfileSequenceRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.postgres.upsert; + +import io.micronaut.data.annotation.Upsert; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.repository.CrudRepository; + +import java.util.List; + +@R2dbcRepository(dialect = Dialect.POSTGRES) +public interface PostgresCustomerProfileSequenceRepository extends CrudRepository { + + @Upsert(conflictsOn = "email") + CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); + + @Upsert(conflictsOn = "email") + List upsertAll(Iterable customerProfiles); +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresCustomerProfileUuidRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresCustomerProfileUuidRepository.java new file mode 100644 index 00000000000..83bf106f0b9 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresCustomerProfileUuidRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.postgres.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository; + +@R2dbcRepository(dialect = Dialect.POSTGRES) +public interface PostgresCustomerProfileUuidRepository extends CustomerProfileUuidRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresProductReviewRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresProductReviewRepository.java new file mode 100644 index 00000000000..af1caf65419 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresProductReviewRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.postgres.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; + +@R2dbcRepository(dialect = Dialect.POSTGRES) +public interface PostgresProductReviewRepository extends ProductReviewRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresWarehouseInventoryRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresWarehouseInventoryRepository.java new file mode 100644 index 00000000000..71d100b4fe7 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresWarehouseInventoryRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.postgres.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository; + +@R2dbcRepository(dialect = Dialect.POSTGRES) +public interface PostgresWarehouseInventoryRepository extends WarehouseInventoryRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/CustomerProfileSequence.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/CustomerProfileSequence.java new file mode 100644 index 00000000000..ff8edfd738d --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/CustomerProfileSequence.java @@ -0,0 +1,77 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.sqlserver.upsert; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Index; +import io.micronaut.data.annotation.MappedEntity; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; +import org.jspecify.annotations.Nullable; + +@MappedEntity +@Index(columns = "email", unique = true) +public class CustomerProfileSequence { + + @Id + @GeneratedValue(value = GeneratedValue.Type.SEQUENCE) + @Nullable + private Long id; + + @NotBlank + private String email; + + @NotBlank + private String displayName; + + public CustomerProfileSequence() { + } + + public CustomerProfileSequence(String email, String displayName) { + this(null, email, displayName); + } + + public CustomerProfileSequence(@Nullable Long id, String email, String displayName) { + this.id = id; + this.email = email; + this.displayName = displayName; + } + + @Nullable + public Long getId() { + return id; + } + + public void setId(@Nullable Long id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSCustomerProfileRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSCustomerProfileRepository.java new file mode 100644 index 00000000000..4a8667c2dfa --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSCustomerProfileRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.sqlserver.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository; + +@R2dbcRepository(dialect = Dialect.SQL_SERVER) +public interface MSCustomerProfileRepository extends CustomerProfileRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSCustomerProfileSequenceRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSCustomerProfileSequenceRepository.java new file mode 100644 index 00000000000..c9c47597d2e --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSCustomerProfileSequenceRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.sqlserver.upsert; + +import io.micronaut.data.annotation.Upsert; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.repository.CrudRepository; + +import java.util.List; + +@R2dbcRepository(dialect = Dialect.SQL_SERVER) +public interface MSCustomerProfileSequenceRepository extends CrudRepository { + + @Upsert(conflictsOn = "email") + CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); + + @Upsert(conflictsOn = "email") + List upsertAll(Iterable customerProfiles); +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSCustomerProfileUuidRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSCustomerProfileUuidRepository.java new file mode 100644 index 00000000000..9f7ddbdd42b --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSCustomerProfileUuidRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.sqlserver.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository; + +@R2dbcRepository(dialect = Dialect.SQL_SERVER) +public interface MSCustomerProfileUuidRepository extends CustomerProfileUuidRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSProductReviewRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSProductReviewRepository.java new file mode 100644 index 00000000000..0a283c787e2 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSProductReviewRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.sqlserver.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; + +@R2dbcRepository(dialect = Dialect.SQL_SERVER) +public interface MSProductReviewRepository extends ProductReviewRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSWarehouseInventoryRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSWarehouseInventoryRepository.java new file mode 100644 index 00000000000..56da60a6eb3 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSWarehouseInventoryRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.r2dbc.sqlserver.upsert; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository; + +@R2dbcRepository(dialect = Dialect.SQL_SERVER) +public interface MSWarehouseInventoryRepository extends WarehouseInventoryRepository { +} diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/AbstractQueryInterceptor.java b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/AbstractQueryInterceptor.java index e1b4cefe0d6..653781741ed 100644 --- a/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/AbstractQueryInterceptor.java +++ b/data-runtime/src/main/java/io/micronaut/data/runtime/intercept/AbstractQueryInterceptor.java @@ -590,6 +590,7 @@ private StoredQuery.OperationType resolveInsertOperationType(MethodInvocationCon private StoredQuery.OperationType resolveUpdateOperationType(MethodInvocationContext context) { return switch (getDataMethodOperationType(context)) { case UPDATE_RETURNING, INSERT_RETURNING -> StoredQuery.OperationType.UPDATE_RETURNING; + case UPSERT -> StoredQuery.OperationType.UPSERT; default -> StoredQuery.OperationType.UPDATE; }; } diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/support/DataSourceConfigurationUtils.java b/data-runtime/src/main/java/io/micronaut/data/runtime/support/DataSourceConfigurationUtils.java new file mode 100644 index 00000000000..fe7cf090f3b --- /dev/null +++ b/data-runtime/src/main/java/io/micronaut/data/runtime/support/DataSourceConfigurationUtils.java @@ -0,0 +1,134 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.runtime.support; + +import io.micronaut.context.BeanContext; +import io.micronaut.context.BeanResolutionContext; +import io.micronaut.context.Qualifier; +import io.micronaut.context.condition.ConditionContext; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.naming.Named; +import io.micronaut.inject.BeanDefinition; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Utilities for resolving datasource configuration metadata. + */ +@Internal +public final class DataSourceConfigurationUtils { + + private static final String CONFIGURED_DATASOURCE_NAMES_CACHE = DataSourceConfigurationUtils.class.getName() + ".configuredDataSourceNames"; + + private DataSourceConfigurationUtils() { + } + + /** + * Resolves the datasource name from the current condition qualifier. + * + *

This method answers which datasource the current bean resolution is creating or resolving. + * If the condition is evaluated before a specific datasource-qualified bean, + * no current datasource exists and this method returns empty.

+ * + * @param context The condition context + * @return The datasource name, or empty when the condition is being evaluated without a datasource qualifier + */ + public static Optional resolveDataSourceName(ConditionContext context) { + BeanResolutionContext beanResolutionContext = context.getBeanResolutionContext(); + Qualifier currentQualifier = null; + if (beanResolutionContext != null) { + currentQualifier = beanResolutionContext.getCurrentQualifier(); + if (currentQualifier == null) { + currentQualifier = beanResolutionContext.getPath() + .currentSegment() + .map(BeanResolutionContext.Segment::getDeclaringTypeQualifier) + .orElse(null); + } + } + if (currentQualifier == null && context.getComponent() instanceof BeanDefinition definition) { + currentQualifier = definition.getDeclaredQualifier(); + } + if (currentQualifier instanceof Named named) { + return Optional.of(named.getName()); + } + return Optional.empty(); + } + + /** + * Resolves all configured datasource names visible to the condition context. + * + *

Property entries are used first. If no entries are available, the method falls back to + * datasource configuration bean definitions, which may already have been produced by + * Micronaut's configuration binding. Results are cached per bean context, configuration + * prefix and configuration bean type.

+ * + * @param context The condition context + * @param configurationPrefix The datasource configuration prefix + * @param configurationType The datasource configuration bean type + * @return The configured datasource names + */ + public static List resolveConfiguredDataSourceNames(ConditionContext context, + String configurationPrefix, + Class configurationType) { + BeanContext beanContext = context.getBeanContext(); + CacheKey cacheKey = new CacheKey(configurationPrefix, configurationType); + Map> contextCache = contextCache(beanContext); + return contextCache.computeIfAbsent( + cacheKey, + ignored -> resolveConfiguredDataSourceNamesUncached(context, configurationPrefix, configurationType)); + } + + @SuppressWarnings("unchecked") + private static Map> contextCache(BeanContext beanContext) { + return beanContext.getAttribute(CONFIGURED_DATASOURCE_NAMES_CACHE, Map.class) + .map(cache -> (Map>) cache) + .orElseGet(() -> { + Map> cache = new ConcurrentHashMap<>(); + beanContext.setAttribute(CONFIGURED_DATASOURCE_NAMES_CACHE, cache); + return cache; + }); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static List resolveConfiguredDataSourceNamesUncached(ConditionContext context, + String configurationPrefix, + Class configurationType) { + Collection dataSourceNames = context.getPropertyEntries(configurationPrefix); + if (!dataSourceNames.isEmpty()) { + return List.copyOf(dataSourceNames); + } + Collection> beanDefinitions = context.findBeanDefinitions((Class) configurationType); + if (beanDefinitions.isEmpty()) { + return List.of(); + } + List names = new ArrayList<>(beanDefinitions.size()); + for (BeanDefinition beanDefinition : beanDefinitions) { + Qualifier qualifier = beanDefinition.getDeclaredQualifier(); + if (qualifier instanceof Named named) { + names.add(named.getName()); + } + } + return List.copyOf(names); + } + + private record CacheKey(String configurationPrefix, Class configurationType) { + } +} diff --git a/data-runtime/src/test/groovy/io/micronaut/data/runtime/support/DataSourceConfigurationUtilsSpec.groovy b/data-runtime/src/test/groovy/io/micronaut/data/runtime/support/DataSourceConfigurationUtilsSpec.groovy new file mode 100644 index 00000000000..39a07fad2ce --- /dev/null +++ b/data-runtime/src/test/groovy/io/micronaut/data/runtime/support/DataSourceConfigurationUtilsSpec.groovy @@ -0,0 +1,149 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.runtime.support + +import io.micronaut.context.ApplicationContext +import io.micronaut.context.BeanResolutionContext +import io.micronaut.context.condition.ConditionContext +import io.micronaut.inject.BeanDefinition +import io.micronaut.inject.qualifiers.Qualifiers +import spock.lang.Specification + +class DataSourceConfigurationUtilsSpec extends Specification { + + void "configured datasource names are resolved from property entries"() { + given: + ApplicationContext beanContext = ApplicationContext.run() + ConditionContext conditionContext = Stub(ConditionContext) { + getBeanContext() >> beanContext + getPropertyEntries('datasources') >> ['default', 'mdb'] + } + + expect: + DataSourceConfigurationUtils.resolveConfiguredDataSourceNames(conditionContext, 'datasources', TestConfiguration) == ['default', 'mdb'] + + cleanup: + beanContext.close() + } + + void "configured datasource names fall back to configuration bean definitions"() { + given: + ApplicationContext beanContext = ApplicationContext.run() + BeanDefinition defaultDefinition = beanDefinition('default') + BeanDefinition mdbDefinition = beanDefinition('mdb') + ConditionContext conditionContext = Stub(ConditionContext) { + getBeanContext() >> beanContext + getPropertyEntries('datasources') >> [] + findBeanDefinitions(TestConfiguration) >> [defaultDefinition, mdbDefinition] + } + + expect: + DataSourceConfigurationUtils.resolveConfiguredDataSourceNames(conditionContext, 'datasources', TestConfiguration) == ['default', 'mdb'] + + cleanup: + beanContext.close() + } + + void "configured datasource names are cached per bean context prefix and configuration type"() { + given: + ApplicationContext beanContext = ApplicationContext.run() + int propertyEntryLookups = 0 + ConditionContext conditionContext = Stub(ConditionContext) { + getBeanContext() >> beanContext + getPropertyEntries('datasources') >> { + propertyEntryLookups++ + ['default'] + } + } + + when: + List first = DataSourceConfigurationUtils.resolveConfiguredDataSourceNames(conditionContext, 'datasources', TestConfiguration) + List second = DataSourceConfigurationUtils.resolveConfiguredDataSourceNames(conditionContext, 'datasources', TestConfiguration) + + then: + first == ['default'] + second == ['default'] + propertyEntryLookups == 1 + + cleanup: + beanContext.close() + } + + void "datasource name is resolved from current bean resolution qualifier"() { + given: + BeanResolutionContext beanResolutionContext = Stub(BeanResolutionContext) { + getCurrentQualifier() >> Qualifiers.byName('mdb') + } + ConditionContext conditionContext = Stub(ConditionContext) { + getBeanResolutionContext() >> beanResolutionContext + } + + expect: + DataSourceConfigurationUtils.resolveDataSourceName(conditionContext).get() == 'mdb' + } + + void "datasource name is resolved from current path segment qualifier"() { + given: + BeanResolutionContext.Segment segment = Stub(BeanResolutionContext.Segment) { + getDeclaringTypeQualifier() >> Qualifiers.byName('mdb') + } + BeanResolutionContext.Path path = Stub(BeanResolutionContext.Path) { + currentSegment() >> Optional.of(segment) + } + BeanResolutionContext beanResolutionContext = Stub(BeanResolutionContext) { + getCurrentQualifier() >> null + getPath() >> path + } + ConditionContext conditionContext = Stub(ConditionContext) { + getBeanResolutionContext() >> beanResolutionContext + } + + expect: + DataSourceConfigurationUtils.resolveDataSourceName(conditionContext).get() == 'mdb' + } + + void "datasource name falls back to component qualifier"() { + given: + BeanDefinition beanDefinition = beanDefinition('mdb') + ConditionContext conditionContext = Stub(ConditionContext) { + getBeanResolutionContext() >> null + getComponent() >> beanDefinition + } + + expect: + DataSourceConfigurationUtils.resolveDataSourceName(conditionContext).get() == 'mdb' + } + + void "datasource name is empty when there is no qualifier"() { + given: + ConditionContext conditionContext = Stub(ConditionContext) { + getBeanResolutionContext() >> null + getComponent() >> null + } + + expect: + DataSourceConfigurationUtils.resolveDataSourceName(conditionContext).isEmpty() + } + + private BeanDefinition beanDefinition(String name) { + Stub(BeanDefinition) { + getDeclaredQualifier() >> Qualifiers.byName(name) + } + } + + private static final class TestConfiguration { + } +} diff --git a/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractUpsertSpec.groovy b/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractUpsertSpec.groovy new file mode 100644 index 00000000000..99f6ea5271d --- /dev/null +++ b/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractUpsertSpec.groovy @@ -0,0 +1,508 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.tck.tests + +import io.micronaut.context.ApplicationContext +import io.micronaut.data.tck.jdbc.entities.upsert.CustomerProfile +import io.micronaut.data.tck.jdbc.entities.upsert.CustomerProfileUuid +import io.micronaut.data.tck.jdbc.entities.upsert.ProductReview +import io.micronaut.data.tck.jdbc.entities.upsert.WarehouseInventory +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.lang.Specification + +import static org.junit.jupiter.api.Assumptions.assumeTrue + +abstract class AbstractUpsertSpec extends Specification { + + abstract ProductReviewRepository getProductReviewRepository() + + abstract CustomerProfileRepository getCustomerProfileRepository() + + abstract CustomerProfileUuidRepository getCustomerProfileUuidRepository() + + abstract WarehouseInventoryRepository getWarehouseInventoryRepository() + + abstract Map getProperties() + + @AutoCleanup + @Shared + ApplicationContext context = ApplicationContext.run(properties) + + ApplicationContext getApplicationContext() { + return context + } + + void cleanup() { + productReviewRepository.deleteAll() + customerProfileRepository.deleteAll() + customerProfileUuidRepository.deleteAll() + warehouseInventoryRepository.deleteAll() + cleanupAdditionalRepositories() + } + + protected void cleanupAdditionalRepositories() { + } + + void "#methodName inserts and updates product review by assigned ID"() { + given: + ProductReview pr = new ProductReview(1L, "title new", "content new") + + when: + ProductReview inserted = upsertMethod(pr) + + then: + inserted == pr + + when: + ProductReview found = productReviewRepository.findById(pr.id).get() + + then: + assertProductReview(found, pr) + + when: + pr.setTitle("title modified") + pr.setContent("content modified") + ProductReview updated = upsertMethod(pr) + + then: + updated == pr + + when: + found = productReviewRepository.findById(pr.id).get() + + then: + assertProductReview(found, pr) + + where: + methodName | upsertMethod + "upsert" | { ProductReview review -> productReviewRepository.upsert(review) } + "put" | { ProductReview review -> productReviewRepository.put(review) } + } + + void "#methodName inserts and updates product reviews by assigned ID"() { + given: + ProductReview pr1 = new ProductReview(1L, "title 1", "content 1") + ProductReview pr2 = new ProductReview(2L, "title 2", "content 2") + + when: + List insertedList = upsertMethod([pr1, pr2]) + + then: + insertedList.size() == 2 + insertedList.get(0) == pr1 + insertedList.get(1) == pr2 + + when: + ProductReview found1 = productReviewRepository.findById(1L).get() + ProductReview found2 = productReviewRepository.findById(2L).get() + + then: + assertProductReview(found1, pr1) + assertProductReview(found2, pr2) + + when: + pr1.setTitle("title 1 modified") + pr1.setContent("content 1 modified") + pr2.setTitle("title 2 modified") + pr2.setContent("content 2 modified") + List updatedList = upsertMethod([pr1, pr2]) + + then: + updatedList.size() == 2 + updatedList.get(0) == pr1 + updatedList.get(1) == pr2 + + when: + found1 = productReviewRepository.findById(1L).get() + found2 = productReviewRepository.findById(2L).get() + + then: + assertProductReview(found1, pr1) + assertProductReview(found2, pr2) + + where: + methodName | upsertMethod + "upsertAll" | { Iterable reviews -> productReviewRepository.upsertAll(reviews) } + "putAll" | { Iterable reviews -> productReviewRepository.putAll(reviews) } + } + + void "#methodName by email conflict returns entity"() { + given: + CustomerProfile cp = new CustomerProfile("test@example.com", "test") + + when: + CustomerProfile inserted = upsertMethod(cp) + + then: + inserted.id != null + inserted == cp + + when: + CustomerProfile found = customerProfileRepository.findById(cp.id).get() + + then: + assertCustomerProfile(found, cp) + + when: + cp.setDisplayName("test modified") + CustomerProfile updated = upsertMethod(cp) + + then: + updated == cp + + when: + found = customerProfileRepository.findById(cp.id).get() + + then: + assertCustomerProfile(found, cp) + + where: + methodName | upsertMethod + "upsert" | { CustomerProfile profile -> customerProfileRepository.upsert(profile) } + "upsertMono" | { CustomerProfile profile -> customerProfileRepository.upsertMono(profile).block() } + "upsertFuture" | { CustomerProfile profile -> customerProfileRepository.upsertFuture(profile).get() } + } + + void "#methodName by email conflict does not return entity"() { + given: + CustomerProfile cp = new CustomerProfile("test@example.com", "test") + + when: + upsertMethod(cp) + + then: + cp.id != null + + when: + CustomerProfile found = customerProfileRepository.findById(cp.id).get() + + then: + assertCustomerProfile(found, cp) + + when: + cp.setDisplayName("test modified") + upsertMethod(cp) + found = customerProfileRepository.findById(cp.id).get() + + then: + assertCustomerProfile(found, cp) + + where: + methodName | upsertMethod + "upsertNoResult" | { CustomerProfile profile -> customerProfileRepository.upsertNoResult(profile) } + "upsertMonoNoResult" | { CustomerProfile profile -> customerProfileRepository.upsertMonoNoResult(profile).block() } + "upsertFutureNoResult" | { CustomerProfile profile -> customerProfileRepository.upsertFutureNoResult(profile).get() } + } + + void "#methodName by email conflict returns entities"() { + given: + CustomerProfile cp1 = new CustomerProfile("test1@example.com", "test 1") + CustomerProfile cp2 = new CustomerProfile("test2@example.com", "test 2") + + when: + List inserted = upsertMethod([cp1, cp2]) + + then: + inserted.size() == 2 + inserted.get(0).id != null + inserted.get(1).id != null + inserted.get(0) == cp1 + inserted.get(1) == cp2 + + when: + CustomerProfile found1 = customerProfileRepository.findById(cp1.id).get() + CustomerProfile found2 = customerProfileRepository.findById(cp2.id).get() + + then: + assertCustomerProfile(found1, cp1) + assertCustomerProfile(found2, cp2) + + when: + cp1.setDisplayName("test 1 modified") + cp2.setDisplayName("test 2 modified") + CustomerProfile cp3 = new CustomerProfile("test3@example.com", "test 3") + CustomerProfile cp4 = new CustomerProfile("test4@example.com", "test 4") + List updated = upsertMethod([cp1, cp2, cp3, cp4]) + + then: + updated.size() == 4 + updated.get(0) == cp1 + updated.get(1) == cp2 + updated.get(2).id != null + updated.get(3).id != null + updated.get(2) == cp3 + updated.get(3) == cp4 + + when: + found1 = customerProfileRepository.findById(cp1.id).get() + found2 = customerProfileRepository.findById(cp2.id).get() + CustomerProfile found3 = customerProfileRepository.findById(cp3.id).get() + CustomerProfile found4 = customerProfileRepository.findById(cp4.id).get() + + then: + assertCustomerProfile(found1, cp1) + assertCustomerProfile(found2, cp2) + assertCustomerProfile(found3, cp3) + assertCustomerProfile(found4, cp4) + + where: + methodName | upsertMethod + "upsertAll" | { Iterable profiles -> customerProfileRepository.upsertAll(profiles) } + "upsertAllFlux" | { Iterable profiles -> customerProfileRepository.upsertAllFlux(profiles).collectList().block() } + "upsertAllFuture" | { Iterable profiles -> customerProfileRepository.upsertAllFuture(profiles).get() } + } + + void "#methodName by email conflict does not return entities"() { + given: + CustomerProfile cp1 = new CustomerProfile("test1@example.com", "test 1") + CustomerProfile cp2 = new CustomerProfile("test2@example.com", "test 2") + + when: + upsertMethod([cp1, cp2]) + + then: + cp1.id != null + cp2.id != null + + when: + CustomerProfile found1 = customerProfileRepository.findById(cp1.id).get() + CustomerProfile found2 = customerProfileRepository.findById(cp2.id).get() + + then: + assertCustomerProfile(found1, cp1) + assertCustomerProfile(found2, cp2) + + when: + cp1.setDisplayName("test 1 modified") + cp2.setDisplayName("test 2 modified") + CustomerProfile cp3 = new CustomerProfile("test3@example.com", "test 3") + CustomerProfile cp4 = new CustomerProfile("test4@example.com", "test 4") + upsertMethod([cp1, cp2, cp3, cp4]) + + then: + cp3.id != null + cp4.id != null + + when: + found1 = customerProfileRepository.findById(cp1.id).get() + found2 = customerProfileRepository.findById(cp2.id).get() + CustomerProfile found3 = customerProfileRepository.findById(cp3.id).get() + CustomerProfile found4 = customerProfileRepository.findById(cp4.id).get() + + then: + assertCustomerProfile(found1, cp1) + assertCustomerProfile(found2, cp2) + assertCustomerProfile(found3, cp3) + assertCustomerProfile(found4, cp4) + + where: + methodName | upsertMethod + "upsertAllNoResult" | { Iterable profiles -> customerProfileRepository.upsertAllNoResult(profiles) } + "upsertAllFluxNoResult" | { Iterable profiles -> customerProfileRepository.upsertAllFluxNoResult(profiles).collectList().block() } + "upsertAllFutureNoResult" | { Iterable profiles -> customerProfileRepository.upsertAllFutureNoResult(profiles).get() } + } + + void "upsert by email conflict returns entity when uuid is used"() { + assumeTrue(supportsGeneratedUuidReturning()) + + given: + CustomerProfileUuid cp = new CustomerProfileUuid("test@example.com", "test") + + when: + CustomerProfileUuid inserted = customerProfileUuidRepository.upsert(cp) + + then: + inserted.id != null + inserted == cp + + when: + CustomerProfileUuid found = customerProfileUuidRepository.findById(cp.id).get() + + then: + assertCustomerProfileUuid(cp, found) + + when: + cp.setDisplayName("test modified") + CustomerProfileUuid updated = customerProfileUuidRepository.upsert(cp) + + then: + updated == cp + + when: + found = customerProfileUuidRepository.findById(cp.id).get() + + then: + assertCustomerProfileUuid(cp, found) + } + + void "upsertAll by email conflict returns entities when uuid is used"() { + assumeTrue(supportsGeneratedUuidReturning()) + + given: + CustomerProfileUuid cp1 = new CustomerProfileUuid("test1@example.com", "test 1") + CustomerProfileUuid cp2 = new CustomerProfileUuid("test2@example.com", "test 2") + + when: + List inserted = customerProfileUuidRepository.upsertAll([cp1, cp2]) + + then: + inserted.size() == 2 + inserted.get(0).id != null + inserted.get(1).id != null + inserted.get(0) == cp1 + inserted.get(1) == cp2 + + when: + CustomerProfileUuid found1 = customerProfileUuidRepository.findById(cp1.id).get() + CustomerProfileUuid found2 = customerProfileUuidRepository.findById(cp2.id).get() + + then: + assertCustomerProfileUuid(found1, cp1) + assertCustomerProfileUuid(found2, cp2) + + when: + cp1.setDisplayName("test 1 modified") + cp2.setDisplayName("test 2 modified") + CustomerProfileUuid cp3 = new CustomerProfileUuid("test3@example.com", "test 3") + CustomerProfileUuid cp4 = new CustomerProfileUuid("test4@example.com", "test 4") + List updated = customerProfileUuidRepository.upsertAll([cp1, cp2, cp3, cp4]) + + then: + updated.size() == 4 + updated.get(0) == cp1 + updated.get(1) == cp2 + updated.get(2).id != null + updated.get(3).id != null + updated.get(2) == cp3 + updated.get(3) == cp4 + + when: + found1 = customerProfileUuidRepository.findById(cp1.id).get() + found2 = customerProfileUuidRepository.findById(cp2.id).get() + CustomerProfileUuid found3 = customerProfileUuidRepository.findById(cp3.id).get() + CustomerProfileUuid found4 = customerProfileUuidRepository.findById(cp4.id).get() + + then: + assertCustomerProfileUuid(found1, cp1) + assertCustomerProfileUuid(found2, cp2) + assertCustomerProfileUuid(found3, cp3) + assertCustomerProfileUuid(found4, cp4) + } + + void "upsert by sku and warehouse conflict properties"() { + given: + WarehouseInventory wh = new WarehouseInventory("SKU-100", "Berlin", 12) + + when: + WarehouseInventory inserted = warehouseInventoryRepository.upsert(wh) + + then: + inserted.id != null + inserted == wh + + when: + WarehouseInventory found = warehouseInventoryRepository.findById(wh.id).get() + + then: + assertWarehouseInventory(found, wh) + + when: + wh.setQuantity(18) + WarehouseInventory updated = warehouseInventoryRepository.upsert(wh) + + then: + updated == wh + + when: + found = warehouseInventoryRepository.findById(wh.id).get() + + then: + assertWarehouseInventory(found, wh) + } + + void "upsertAll by sku and warehouse conflict properties"() { + given: + WarehouseInventory wh1 = new WarehouseInventory("SKU-200", "Berlin", 5) + WarehouseInventory wh2 = new WarehouseInventory("SKU-200", "Paris", 8) + + when: + List inserted = warehouseInventoryRepository.upsertAll([wh1, wh2]).toList() + + then: + inserted.size() == 2 + inserted.get(0).id != null + inserted.get(1).id != null + inserted.get(0) == wh1 + inserted.get(1) == wh2 + + when: + WarehouseInventory found1 = warehouseInventoryRepository.findById(wh1.id).get() + WarehouseInventory found2 = warehouseInventoryRepository.findById(wh2.id).get() + + then: + assertWarehouseInventory(found1, wh1) + assertWarehouseInventory(found2, wh2) + + when: + wh1.setQuantity(7) + wh2.setQuantity(11) + List updated = warehouseInventoryRepository.upsertAll([wh1, wh2]).toList() + + then: + updated.size() == 2 + updated.get(0) == wh1 + updated.get(1) == wh2 + + when: + found1 = warehouseInventoryRepository.findById(wh1.id).get() + found2 = warehouseInventoryRepository.findById(wh2.id).get() + + then: + assertWarehouseInventory(found1, wh1) + assertWarehouseInventory(found2, wh2) + } + + protected boolean supportsGeneratedUuidReturning() { + return true + } + + private static void assertProductReview(ProductReview productReview1, ProductReview productReview2) { + assert productReview1.id == productReview2.id + assert productReview1.title == productReview2.title + assert productReview1.content == productReview2.content + } + + private static void assertCustomerProfile(CustomerProfile customerProfile1, CustomerProfile customerProfile2) { + assert customerProfile1.email == customerProfile2.email + assert customerProfile1.displayName == customerProfile2.displayName + } + + private static void assertCustomerProfileUuid(CustomerProfileUuid customerProfile1, CustomerProfileUuid customerProfile2) { + assert customerProfile1.email == customerProfile2.email + assert customerProfile1.displayName == customerProfile2.displayName + } + + private static void assertWarehouseInventory(WarehouseInventory warehouseInventory1, WarehouseInventory warehouseInventory2) { + assert warehouseInventory1.sku == warehouseInventory2.sku + assert warehouseInventory1.warehouse == warehouseInventory2.warehouse + assert warehouseInventory1.quantity == warehouseInventory2.quantity + } +} diff --git a/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/CustomerProfile.java b/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/CustomerProfile.java new file mode 100644 index 00000000000..a16cca77cd0 --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/CustomerProfile.java @@ -0,0 +1,77 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.tck.jdbc.entities.upsert; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Index; +import io.micronaut.data.annotation.MappedEntity; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; +import org.jspecify.annotations.Nullable; + +@MappedEntity +@Index(columns = "email", unique = true) +public class CustomerProfile { + + @Id + @GeneratedValue(value = GeneratedValue.Type.IDENTITY) + @Nullable + private Long id; + + @NotBlank + private String email; + + @NotBlank + private String displayName; + + public CustomerProfile() { + } + + public CustomerProfile(String email, String displayName) { + this(null, email, displayName); + } + + public CustomerProfile(@Nullable Long id, String email, String displayName) { + this.id = id; + this.email = email; + this.displayName = displayName; + } + + @Nullable + public Long getId() { + return id; + } + + public void setId(@Nullable Long id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } +} diff --git a/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/CustomerProfileUuid.java b/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/CustomerProfileUuid.java new file mode 100644 index 00000000000..1a3dc5c7925 --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/CustomerProfileUuid.java @@ -0,0 +1,77 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.tck.jdbc.entities.upsert; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Index; +import io.micronaut.data.annotation.MappedEntity; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; +import org.jspecify.annotations.Nullable; + +@MappedEntity +@Index(columns = "email", unique = true) +public class CustomerProfileUuid { + + @Id + @GeneratedValue(value = GeneratedValue.Type.UUID) + @Nullable + private String id; + + @NotBlank + private String email; + + @NotBlank + private String displayName; + + public CustomerProfileUuid() { + } + + public CustomerProfileUuid(String email, String displayName) { + this(null, email, displayName); + } + + public CustomerProfileUuid(@Nullable String id, String email, String displayName) { + this.id = id; + this.email = email; + this.displayName = displayName; + } + + @Nullable + public String getId() { + return id; + } + + public void setId(@Nullable String id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } +} diff --git a/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/ProductReview.java b/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/ProductReview.java new file mode 100644 index 00000000000..95acb6a3b20 --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/ProductReview.java @@ -0,0 +1,66 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.tck.jdbc.entities.upsert; + +import io.micronaut.data.annotation.MappedEntity; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; + +@MappedEntity +public class ProductReview { + + @Id + private Long id; + + @NotBlank + private String title; + + @NotBlank + private String content; + + public ProductReview() { + } + + public ProductReview(Long id, String title, String content) { + this.id = id; + this.title = title; + this.content = content; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } +} diff --git a/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/WarehouseInventory.java b/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/WarehouseInventory.java new file mode 100644 index 00000000000..3467bc77b6e --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/WarehouseInventory.java @@ -0,0 +1,90 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.tck.jdbc.entities.upsert; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Index; +import io.micronaut.data.annotation.MappedEntity; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.jspecify.annotations.Nullable; + +@MappedEntity +@Index(columns = {"sku", "warehouse"}, unique = true) +public class WarehouseInventory { + + @Id + @GeneratedValue + @Nullable + private Long id; + + @NotBlank + private String sku; + + @NotBlank + private String warehouse; + + @NotNull + private Integer quantity; + + public WarehouseInventory() { + } + + public WarehouseInventory(String sku, String warehouse, Integer quantity) { + this(null, sku, warehouse, quantity); + } + + public WarehouseInventory(@Nullable Long id, String sku, String warehouse, Integer quantity) { + this.id = id; + this.sku = sku; + this.warehouse = warehouse; + this.quantity = quantity; + } + + @Nullable + public Long getId() { + return id; + } + + public void setId(@Nullable Long id) { + this.id = id; + } + + public String getSku() { + return sku; + } + + public void setSku(String sku) { + this.sku = sku; + } + + public String getWarehouse() { + return warehouse; + } + + public void setWarehouse(String warehouse) { + this.warehouse = warehouse; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } +} diff --git a/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/CustomerProfileRepository.java b/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/CustomerProfileRepository.java new file mode 100644 index 00000000000..e32935483b0 --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/CustomerProfileRepository.java @@ -0,0 +1,64 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.tck.repositories.upsert; + +import io.micronaut.data.annotation.Upsert; +import io.micronaut.data.repository.CrudRepository; +import io.micronaut.data.tck.jdbc.entities.upsert.CustomerProfile; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public interface CustomerProfileRepository extends CrudRepository { + + @Upsert(conflictsOn = "email") + CustomerProfile upsert(CustomerProfile customerProfile); + + @Upsert(conflictsOn = "email") + Mono upsertMono(CustomerProfile profile); + + @Upsert(conflictsOn = "email") + CompletableFuture upsertFuture(CustomerProfile profile); + + @Upsert(conflictsOn = "email") + void upsertNoResult(CustomerProfile customerProfile); + + @Upsert(conflictsOn = "email") + Mono upsertMonoNoResult(CustomerProfile customerProfile); + + @Upsert(conflictsOn = "email") + CompletableFuture upsertFutureNoResult(CustomerProfile profile); + + @Upsert(conflictsOn = "email") + List upsertAll(Iterable customerProfiles); + + @Upsert(conflictsOn = "email") + Flux upsertAllFlux(Iterable profiles); + + @Upsert(conflictsOn = "email") + CompletableFuture> upsertAllFuture(Iterable profiles); + + @Upsert(conflictsOn = "email") + void upsertAllNoResult(Iterable customerProfiles); + + @Upsert(conflictsOn = "email") + Flux upsertAllFluxNoResult(Iterable profiles); + + @Upsert(conflictsOn = "email") + CompletableFuture upsertAllFutureNoResult(Iterable profiles); +} diff --git a/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/CustomerProfileUuidRepository.java b/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/CustomerProfileUuidRepository.java new file mode 100644 index 00000000000..5dfeb1e29fc --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/CustomerProfileUuidRepository.java @@ -0,0 +1,31 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.tck.repositories.upsert; + +import io.micronaut.data.annotation.Upsert; +import io.micronaut.data.repository.CrudRepository; +import io.micronaut.data.tck.jdbc.entities.upsert.CustomerProfileUuid; + +import java.util.List; + +public interface CustomerProfileUuidRepository extends CrudRepository { + + @Upsert(conflictsOn = "email") + CustomerProfileUuid upsert(CustomerProfileUuid customerProfile); + + @Upsert(conflictsOn = "email") + List upsertAll(Iterable customerProfiles); +} diff --git a/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/ProductReviewRepository.java b/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/ProductReviewRepository.java new file mode 100644 index 00000000000..be238610ce9 --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/ProductReviewRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.tck.repositories.upsert; + +import io.micronaut.data.annotation.Upsert; +import io.micronaut.data.repository.CrudRepository; +import io.micronaut.data.tck.jdbc.entities.upsert.ProductReview; + +import java.util.List; + +public interface ProductReviewRepository extends CrudRepository { + + ProductReview upsert(ProductReview entity); + + List upsertAll(Iterable entities); + + @Upsert + ProductReview put(ProductReview entity); + + @Upsert + List putAll(Iterable entities); +} diff --git a/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/WarehouseInventoryRepository.java b/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/WarehouseInventoryRepository.java new file mode 100644 index 00000000000..1721b9466bd --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/WarehouseInventoryRepository.java @@ -0,0 +1,31 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.tck.repositories.upsert; + +import io.micronaut.data.annotation.Upsert; +import io.micronaut.data.repository.CrudRepository; +import io.micronaut.data.tck.jdbc.entities.upsert.WarehouseInventory; + +import java.util.List; + +public interface WarehouseInventoryRepository extends CrudRepository { + + @Upsert(conflictsOn = {"sku", "warehouse"}) + WarehouseInventory upsert(WarehouseInventory warehouseInventory); + + @Upsert(conflictsOn = {"sku", "warehouse"}) + List upsertAll(Iterable warehouseInventories); +} diff --git a/doc-examples/jdbc-example-java/src/main/java/example/Flight.java b/doc-examples/jdbc-example-java/src/main/java/example/Flight.java new file mode 100644 index 00000000000..fe147a062d7 --- /dev/null +++ b/doc-examples/jdbc-example-java/src/main/java/example/Flight.java @@ -0,0 +1,43 @@ +package example; + +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; + +// tag::upsert-entity[] +@MappedEntity +public class Flight { + + @Id + private final String number; + + private String origin; + + private String destination; + + public Flight(String number, String origin, String destination) { + this.number = number; + this.origin = origin; + this.destination = destination; + } + + public String getNumber() { + return number; + } + + public String getOrigin() { + return origin; + } + + public void setOrigin(String origin) { + this.origin = origin; + } + + public String getDestination() { + return destination; + } + + public void setDestination(String destination) { + this.destination = destination; + } +} +// end::upsert-entity[] diff --git a/doc-examples/jdbc-example-java/src/main/java/example/FlightRepository.java b/doc-examples/jdbc-example-java/src/main/java/example/FlightRepository.java new file mode 100644 index 00000000000..60976269d97 --- /dev/null +++ b/doc-examples/jdbc-example-java/src/main/java/example/FlightRepository.java @@ -0,0 +1,32 @@ +package example; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.data.annotation.Upsert; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +import java.util.concurrent.CompletableFuture; + +@Requires(notEnv="oracle") +// tag::upsert-repository[] +@JdbcRepository(dialect = Dialect.H2) +public interface FlightRepository extends CrudRepository { + + void upsert(Flight flight); + + void upsertAll(Iterable flights); + + @Upsert + void put(Flight flight); + + @Upsert + void put(Iterable flights); + + @Upsert + CompletableFuture upsertFuture(Flight flight); + + @Upsert + CompletableFuture upsertFuture(Iterable flights); +} +// end::upsert-repository[] diff --git a/doc-examples/jdbc-example-java/src/main/java/example/Passenger.java b/doc-examples/jdbc-example-java/src/main/java/example/Passenger.java new file mode 100644 index 00000000000..4162de436e9 --- /dev/null +++ b/doc-examples/jdbc-example-java/src/main/java/example/Passenger.java @@ -0,0 +1,57 @@ +package example; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.Index; +import io.micronaut.data.annotation.MappedEntity; + +// tag::upsert-entity[] +@MappedEntity +@Index(columns = "email", unique = true) +public class Passenger { + + @Id + @GeneratedValue(GeneratedValue.Type.IDENTITY) + private Long id; + + private final String email; + + private String firstName; + + private String lastName; + + public Passenger(String email, String firstName, String lastName) { + this.email = email; + this.firstName = firstName; + this.lastName = lastName; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } +} +// end::upsert-entity[] diff --git a/doc-examples/jdbc-example-java/src/main/java/example/PassengerRepository.java b/doc-examples/jdbc-example-java/src/main/java/example/PassengerRepository.java new file mode 100644 index 00000000000..fdb0f3e2d89 --- /dev/null +++ b/doc-examples/jdbc-example-java/src/main/java/example/PassengerRepository.java @@ -0,0 +1,29 @@ +package example; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.data.annotation.Upsert; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +@Requires(notEnv="oracle") +// tag::upsert-repository[] +@JdbcRepository(dialect = Dialect.H2) +public interface PassengerRepository extends CrudRepository { + + @Upsert(conflictsOn = "email") + Passenger upsertByEmail(Passenger passenger); + + @Upsert(conflictsOn = "email") + List upsertByEmail(Iterable passengers); + + @Upsert(conflictsOn = "email") + CompletableFuture upsertByEmailFuture(Passenger passenger); + + @Upsert(conflictsOn = "email") + CompletableFuture> upsertByEmailFuture(Iterable passengers); +} +// end::upsert-repository[] diff --git a/doc-examples/jdbc-example-java/src/main/java/example/oracle/FlightRepository.java b/doc-examples/jdbc-example-java/src/main/java/example/oracle/FlightRepository.java new file mode 100644 index 00000000000..58781c3b496 --- /dev/null +++ b/doc-examples/jdbc-example-java/src/main/java/example/oracle/FlightRepository.java @@ -0,0 +1,10 @@ +package example.oracle; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; + +@JdbcRepository(dialect = Dialect.ORACLE) +@Requires(env="oracle") +public interface FlightRepository extends example.FlightRepository { +} diff --git a/doc-examples/jdbc-example-java/src/main/java/example/oracle/PassengerRepository.java b/doc-examples/jdbc-example-java/src/main/java/example/oracle/PassengerRepository.java new file mode 100644 index 00000000000..da1e6fc3fd0 --- /dev/null +++ b/doc-examples/jdbc-example-java/src/main/java/example/oracle/PassengerRepository.java @@ -0,0 +1,10 @@ +package example.oracle; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; + +@JdbcRepository(dialect = Dialect.ORACLE) +@Requires(env="oracle") +public interface PassengerRepository extends example.PassengerRepository { +} diff --git a/doc-examples/jdbc-example-java/src/test/java/example/UpsertSpec.java b/doc-examples/jdbc-example-java/src/test/java/example/UpsertSpec.java new file mode 100644 index 00000000000..edf3382fe83 --- /dev/null +++ b/doc-examples/jdbc-example-java/src/test/java/example/UpsertSpec.java @@ -0,0 +1,187 @@ +package example; + +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public abstract class UpsertSpec { + + @Inject + FlightRepository flightRepository; + + @Inject + PassengerRepository passengerRepository; + + @BeforeEach + void cleanUp() { + flightRepository.deleteAll(); + passengerRepository.deleteAll(); + } + + @Test + void testUpsert() { + Flight flight = new Flight("MN100", "Athens", "London"); + + flightRepository.upsert(flight); + + assertFlight("MN100", "Athens", "London"); + + flight.setDestination("Paris"); + + flightRepository.upsert(flight); + + assertFlight("MN100", "Athens", "Paris"); + assertEquals(1, flightRepository.count()); + } + + @Test + void testUpsertAll() { + Flight flight1 = new Flight("MN101", "Athens", "London"); + Flight flight2 = new Flight("MN102", "Athens", "Paris"); + + flightRepository.upsertAll(List.of(flight1, flight2)); + + assertFlight("MN101", "Athens", "London"); + assertFlight("MN102", "Athens", "Paris"); + + flight1.setDestination("Rome"); + flight2.setDestination("Madrid"); + + flightRepository.upsertAll(List.of(flight1, flight2)); + + assertFlight("MN101", "Athens", "Rome"); + assertFlight("MN102", "Athens", "Madrid"); + assertEquals(2, flightRepository.count()); + } + + @Test + void testPut() { + Flight flight = new Flight("MN103", "Athens", "London"); + + flightRepository.put(flight); + + assertFlight("MN103", "Athens", "London"); + + flight.setDestination("Paris"); + + flightRepository.put(flight); + + assertFlight("MN103", "Athens", "Paris"); + assertEquals(1, flightRepository.count()); + } + + @Test + void testPutAll() { + Flight flight1 = new Flight("MN104", "Belgrade", "London"); + Flight flight2 = new Flight("MN105", "Belgrade", "Paris"); + + flightRepository.put(List.of(flight1, flight2)); + + assertFlight("MN104", "Belgrade", "London"); + assertFlight("MN105", "Belgrade", "Paris"); + + flight1.setDestination("Rome"); + flight2.setDestination("Madrid"); + + flightRepository.put(List.of(flight1, flight2)); + + assertFlight("MN104", "Belgrade", "Rome"); + assertFlight("MN105", "Belgrade", "Madrid"); + assertEquals(2, flightRepository.count()); + } + + @Test + void testUpsertFuture() { + Flight flight = new Flight("MN106", "Athens", "Berlin"); + + flightRepository.upsertFuture(flight).join(); + + assertFlight("MN106", "Athens", "Berlin"); + + flight.setDestination("Amsterdam"); + + flightRepository.upsertFuture(flight).join(); + + assertFlight("MN106", "Athens", "Amsterdam"); + assertEquals(1, flightRepository.count()); + } + + @Test + void testUpsertAllFuture() { + Flight flight1 = new Flight("MN107", "Athens", "Belgrade"); + Flight flight2 = new Flight("MN108", "Athens", "Zurich"); + + flightRepository.upsertFuture(List.of(flight1, flight2)).join(); + + assertFlight("MN107", "Athens", "Belgrade"); + assertFlight("MN108", "Athens", "Zurich"); + + flight1.setDestination("Lisbon"); + flight2.setDestination("Copenhagen"); + + flightRepository.upsertFuture(List.of(flight1, flight2)).join(); + + assertFlight("MN107", "Athens", "Lisbon"); + assertFlight("MN108", "Athens", "Copenhagen"); + assertEquals(2, flightRepository.count()); + } + + @Test + void testUpsertByEmail() { + Passenger passenger = new Passenger("test@example.com", "testFN", "testLN"); + + passengerRepository.upsertByEmail(passenger); + + assertPassenger("test@example.com", "testFN", "testLN"); + assertNotNull(passenger.getId()); + + passenger.setFirstName("testFN2"); + + passengerRepository.upsertByEmail(passenger); + + assertPassenger("test@example.com", "testFN2", "testLN"); + assertEquals(1, passengerRepository.count()); + } + + @Test + void testUpsertAllByEmail() { + Passenger passenger1 = new Passenger("test1@example.com", "testFN1", "testLN1"); + Passenger passenger2 = new Passenger("test2@example.com", "testFN2", "testLN2"); + + passengerRepository.upsertByEmail(List.of(passenger1, passenger2)); + + assertPassenger("test1@example.com", "testFN1", "testLN1"); + assertPassenger("test2@example.com", "testFN2", "testLN2"); + assertNotNull(passenger1.getId()); + assertNotNull(passenger2.getId()); + + passenger1.setFirstName("testFN3"); + passenger2.setLastName("testLN4"); + + passengerRepository.upsertByEmail(List.of(passenger1, passenger2)); + + assertPassenger("test1@example.com", "testFN3", "testLN1"); + assertPassenger("test2@example.com", "testFN2", "testLN4"); + assertEquals(2, passengerRepository.count()); + } + + private void assertFlight(String number, String origin, String destination) { + Flight flight = flightRepository.findById(number).orElseThrow(); + assertEquals(origin, flight.getOrigin()); + assertEquals(destination, flight.getDestination()); + } + + private void assertPassenger(String email, String firstName, String lastName) { + Passenger passenger = passengerRepository.findAll().stream() + .filter(candidate -> candidate.getEmail().equals(email)) + .findFirst() + .orElseThrow(); + assertEquals(firstName, passenger.getFirstName()); + assertEquals(lastName, passenger.getLastName()); + } +} diff --git a/doc-examples/jdbc-example-java/src/test/java/example/h2/UpsertSpec.java b/doc-examples/jdbc-example-java/src/test/java/example/h2/UpsertSpec.java new file mode 100644 index 00000000000..f57e8e1f565 --- /dev/null +++ b/doc-examples/jdbc-example-java/src/test/java/example/h2/UpsertSpec.java @@ -0,0 +1,7 @@ +package example.h2; + +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; + +@MicronautTest(transactional = false) +class UpsertSpec extends example.UpsertSpec { +} diff --git a/doc-examples/jdbc-example-java/src/test/java/example/oracle/UpsertSpec.java b/doc-examples/jdbc-example-java/src/test/java/example/oracle/UpsertSpec.java new file mode 100644 index 00000000000..4e6be2c3e40 --- /dev/null +++ b/doc-examples/jdbc-example-java/src/test/java/example/oracle/UpsertSpec.java @@ -0,0 +1,9 @@ +package example.oracle; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; + +@MicronautTest(transactional = false) +@Requires(env="oracle") +class UpsertSpec extends example.UpsertSpec { +} diff --git a/doc-examples/r2dbc-example-java/src/main/java/example/Flight.java b/doc-examples/r2dbc-example-java/src/main/java/example/Flight.java new file mode 100644 index 00000000000..0812dddba47 --- /dev/null +++ b/doc-examples/r2dbc-example-java/src/main/java/example/Flight.java @@ -0,0 +1,41 @@ +package example; + +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; + +@MappedEntity +public class Flight { + + @Id + private final String number; + + private String origin; + + private String destination; + + public Flight(String number, String origin, String destination) { + this.number = number; + this.origin = origin; + this.destination = destination; + } + + public String getNumber() { + return number; + } + + public String getOrigin() { + return origin; + } + + public void setOrigin(String origin) { + this.origin = origin; + } + + public String getDestination() { + return destination; + } + + public void setDestination(String destination) { + this.destination = destination; + } +} diff --git a/doc-examples/r2dbc-example-java/src/main/java/example/FlightRepository.java b/doc-examples/r2dbc-example-java/src/main/java/example/FlightRepository.java new file mode 100644 index 00000000000..d6a601eb626 --- /dev/null +++ b/doc-examples/r2dbc-example-java/src/main/java/example/FlightRepository.java @@ -0,0 +1,20 @@ +package example; + +import io.micronaut.data.annotation.Upsert; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.repository.CrudRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +// tag::upsert-repository[] +@R2dbcRepository(dialect = Dialect.POSTGRES) +public interface FlightRepository extends CrudRepository { + + @Upsert + Mono upsertMono(Flight flight); + + @Upsert + Flux upsertFlux(Iterable flights); +} +// end::upsert-repository[] diff --git a/doc-examples/r2dbc-example-java/src/test/java/example/UpsertTest.java b/doc-examples/r2dbc-example-java/src/test/java/example/UpsertTest.java new file mode 100644 index 00000000000..acac54e189f --- /dev/null +++ b/doc-examples/r2dbc-example-java/src/test/java/example/UpsertTest.java @@ -0,0 +1,64 @@ +package example; + +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@MicronautTest(transactional = false) +public class UpsertTest { + + @Inject + FlightRepository flightRepository; + + @BeforeEach + void cleanUp() { + flightRepository.deleteAll(); + } + + @Test + void testUpsertMono() { + Flight flight = new Flight("MN100", "Athens", "London"); + + flightRepository.upsertMono(flight).block(); + + assertFlight("MN100", "Athens", "London"); + + flight.setDestination("Paris"); + + flightRepository.upsertMono(flight).block(); + + assertFlight("MN100", "Athens", "Paris"); + assertEquals(1, flightRepository.count()); + } + + @Test + void testUpsertFlux() { + Flight flight1 = new Flight("MN101", "Athens", "London"); + Flight flight2 = new Flight("MN102", "Athens", "Paris"); + + flightRepository.upsertFlux(List.of(flight1, flight2)).collectList().block(); + + assertFlight("MN101", "Athens", "London"); + assertFlight("MN102", "Athens", "Paris"); + + flight1.setDestination("Rome"); + flight2.setDestination("Madrid"); + + flightRepository.upsertFlux(List.of(flight1, flight2)).collectList().block(); + + assertFlight("MN101", "Athens", "Rome"); + assertFlight("MN102", "Athens", "Madrid"); + assertEquals(2, flightRepository.count()); + } + + private void assertFlight(String number, String origin, String destination) { + Flight flight = flightRepository.findById(number).orElseThrow(); + assertEquals(origin, flight.getOrigin()); + assertEquals(destination, flight.getDestination()); + } +} diff --git a/src/main/docs/guide/shared/dataUpdates/upserts.adoc b/src/main/docs/guide/shared/dataUpdates/upserts.adoc new file mode 100644 index 00000000000..faca7dda2c0 --- /dev/null +++ b/src/main/docs/guide/shared/dataUpdates/upserts.adoc @@ -0,0 +1,153 @@ +An upsert is a native database operation that inserts a row when no matching row exists, or updates the existing row when a match is found. +Micronaut Data supports explicitly declared upsert operations for SQL repositories. + +Upsert methods are opt-in. +They are not inherited from api:data.repository.CrudRepository[]. +Declare an upsert method only on repositories that need this behavior. + +The default conflict target is the entity identity. +The following JDBC example uses the caller-provided flight number as the identity: + +snippet::example.Flight[project-base="doc-examples/jdbc-example", source="main", tags="upsert-entity"] + +Its repository declares single and iterable upsert methods by name, uses ann:data.annotation.Upsert[] with another method name, and includes asynchronous variants: + +snippet::example.FlightRepository[project-base="doc-examples/jdbc-example", source="main", tags="upsert-repository"] + +Use `conflictsOn` to use another persistent property, or a set of persistent properties, as the conflict target. +The following JDBC example has a generated identity and a unique email address: + +snippet::example.Passenger[project-base="doc-examples/jdbc-example", source="main", tags="upsert-entity"] + +Its repository uses `email` as the conflict target and returns the persisted passenger for single-item operations: + +snippet::example.PassengerRepository[project-base="doc-examples/jdbc-example", source="main", tags="upsert-repository"] + +The same repository method shapes are supported for reactive repositories where the repository type supports those return types. +The following R2DBC example returns a `Mono` for one flight and a `Flux` for an iterable of flights: + +snippet::example.FlightRepository[project-base="doc-examples/r2dbc-example", source="main", tags="upsert-repository"] + +Upsert methods must accept exactly one entity parameter or one iterable entity parameter. +A method cannot mix a single entity parameter and an iterable entity parameter. +The return type may be `void`, a number type, the entity type, or an iterable/reactive/asynchronous container that produces the entity type for the corresponding operation. + +IMPORTANT: Upsert support is implemented for explicit SQL repositories. +It is not implemented for repositories that rely on implicit query execution, such as MongoDB or Azure Cosmos repositories. + +== Conflict Properties + +When `conflictsOn` is not specified, Micronaut Data uses the entity identity properties to decide whether the upsert should update an existing row. + +When `conflictsOn` is specified, those properties define the conflict target. +For example, the `PassengerRepository` methods above use the unique `email` property. + +Conflict properties must resolve to persistent, non-generated properties. +Micronaut Data does not infer the conflict target from every unique constraint on the table. +The database schema should still define a primary key or unique constraint that matches the conflict target used by the repository method. +That database constraint is what makes the operation reliable under concurrent writes. + +Non-conflict persistent properties are updated when a matching row is found. +Identifier properties and conflict properties are preserved as needed by the generated statement. +All bindable columns required by the generated statement are available to the insert branch. + +== Supported SQL Dialects + +Micronaut Data generates dialect-specific SQL for upsert operations. + +[cols="1,3,3"] +|=== +|Dialect |Generated SQL shape |Notes + +|`Dialect.H2` +|`MERGE INTO (...) KEY(...) VALUES (...)` +|The `KEY` columns are the identity columns or the configured conflict properties. + +|`Dialect.MYSQL` +|`INSERT INTO
(...) VALUES (...) ON DUPLICATE KEY UPDATE ...` +|Used for MySQL-compatible databases, including MariaDB. The database detects duplicate rows from primary key or unique constraints, so the schema should match the selected conflict properties. + +|`Dialect.POSTGRES` +|`INSERT INTO
(...) VALUES (...) ON CONFLICT (...) DO UPDATE SET ...` +|The `ON CONFLICT` columns are the identity columns or the configured conflict properties. If there are no mutable columns to update, Micronaut Data generates `DO NOTHING`. + +|`Dialect.SQLITE` +|`INSERT INTO
(...) VALUES (...) ON CONFLICT (...) DO UPDATE SET ...` +|The `ON CONFLICT` columns are the identity columns or the configured conflict properties. If there are no mutable columns to update, Micronaut Data generates `DO NOTHING`. + +|`Dialect.SQL_SERVER` +|`MERGE INTO
WITH (HOLDLOCK) AS target USING (VALUES (...)) AS source (...) ON ...` +|The generated `MERGE` uses `WITH (HOLDLOCK)` to make the match and write atomic for the target key. Generated identity returning uses `OUTPUT inserted.` when supported. + +|`Dialect.ORACLE` +|`MERGE INTO
target USING (SELECT ... FROM DUAL) source ON (...) ...` +|Oracle uses a single-row `SELECT ... FROM DUAL` source. Generated identity returning uses `RETURNING INTO ?` when supported. + +|`Dialect.ANSI` +|`MERGE INTO
target USING (VALUES (...)) source (...) ON (...) ...` +|This is the generic SQL `MERGE` form. Actual support depends on the database used with the ANSI dialect. +|=== + +== Single and Batch Upsert + +A single-entity upsert method, such as `FlightRepository.upsert(Flight)`, executes the generated upsert statement for one entity. + +An iterable upsert method, such as `FlightRepository.upsertAll(Iterable)`, applies the same upsert operation to each entity. + +`upsertAll` is a batch operation at the repository API level, but it should not be understood as a portable multi-row SQL statement. +Depending on the dialect, driver, and repository implementation, Micronaut Data may use JDBC batching, driver-specific batching, or execute one statement per entity. +Each entity still has its own bind values. +Returned entities are produced in the same logical order as the input values. + +If one item in a batch fails, transaction behavior depends on the transaction boundary configured by the application and on the database driver. +Use an explicit transaction when all items must commit or roll back together. + +== Generated Identity Values + +When the default identity conflict target is used, generated identity properties are rejected because Micronaut Data needs a non-generated value to match an existing row. +When `conflictsOn` is used, the identity can be generated by the database because another property, such as `email`, is used to find the row. + +== Optimistic Locking + +Optimistic locking is not supported by the native upsert implementation. +Entities with ann:data.annotation.Version[] are rejected for upsert methods. + +To support optimistic locking in a single native merge statement, the match condition would need to include both the identity and the expected version, for example: + +[source,sql] +---- +MERGE INTO book b +USING (SELECT ? id, ? version, ? title FROM DUAL) v +ON (b.id = v.id AND b.version = v.version) +WHEN MATCHED THEN + UPDATE SET b.title = v.title, b.version = v.version + 1 +WHEN NOT MATCHED THEN + INSERT (id, version, title) + VALUES (v.id, v.version, v.title) +---- + +However, this makes the `WHEN NOT MATCHED` branch ambiguous: + +* No row exists for the given id, so insert is valid. +* A row exists for the id, but its version is different, so the operation should fail with api:data.exceptions.OptimisticLockException[]. + +Because a native upsert cannot portably distinguish those two cases, Micronaut Data does not enforce optimistic locking in the native upsert path. +Use an update or save operation when version checking is required. + +== Pessimistic Locking + +Pessimistic locking is supported by finder methods such as `find*ForUpdate`. +It is separate from upsert. + +If `find*ForUpdate` is executed before an upsert operation in the same transaction: + +* If no row exists, there is no row to lock. Another transaction can still insert the same unique key before this transaction writes. This is exactly where database-native upsert is useful: the unique constraint together with `MERGE`, `ON CONFLICT`, or the dialect-specific equivalent handles the race atomically. +* If a row exists, `find*ForUpdate` locks that row until the transaction completes. A following upsert will update that existing row while the lock is held. However, since the row is already known to exist, a normal update or save is usually clearer than upsert, unless the code intentionally wants the same method to handle both existing and missing rows. + +For a simple "insert or update this entity" operation, using `find*ForUpdate` before upsert is generally unnecessary. +It should not fail, but it usually adds an extra query and lock without much benefit. +It only makes sense when the application needs to read the existing row under lock, make decisions from its current state, and then write. + +== JSON Duality Views + +Upsert is not currently supported for entities that use JSON entity representation, including Oracle JSON Duality view entities mapped with ann:data.annotation.JsonView[]. diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index c3a1d90d938..f8127b7d53e 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -28,6 +28,7 @@ shared: title: Accessing data inserts: Inserting updates: Updating + upserts: Upserting deletes: Deleting timestamps: Entity Timestamps entityEvents: Entity Events diff --git a/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteCustomerProfileRepository.java b/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteCustomerProfileRepository.java new file mode 100644 index 00000000000..d0b57d37222 --- /dev/null +++ b/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteCustomerProfileRepository.java @@ -0,0 +1,29 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.sqlite; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.jdbc.entities.upsert.CustomerProfile; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository; + +import java.util.Optional; + +@JdbcRepository(dialect = Dialect.SQLITE) +public interface SQLiteCustomerProfileRepository extends CustomerProfileRepository { + + Optional findByEmail(String email); +} diff --git a/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteCustomerProfileUuidRepository.java b/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteCustomerProfileUuidRepository.java new file mode 100644 index 00000000000..c8dcb4a5b64 --- /dev/null +++ b/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteCustomerProfileUuidRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.sqlite; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.CustomerProfileUuidRepository; + +@JdbcRepository(dialect = Dialect.SQLITE) +public interface SQLiteCustomerProfileUuidRepository extends CustomerProfileUuidRepository { +} diff --git a/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteProductReviewRepository.java b/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteProductReviewRepository.java new file mode 100644 index 00000000000..40cbf0141c5 --- /dev/null +++ b/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteProductReviewRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.sqlite; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; + +@JdbcRepository(dialect = Dialect.SQLITE) +public interface SQLiteProductReviewRepository extends ProductReviewRepository { +} diff --git a/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteUpsertTest.java b/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteUpsertTest.java new file mode 100644 index 00000000000..ef249060f83 --- /dev/null +++ b/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteUpsertTest.java @@ -0,0 +1,210 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.sqlite; + +import io.micronaut.data.tck.jdbc.entities.upsert.CustomerProfile; +import io.micronaut.data.tck.jdbc.entities.upsert.ProductReview; +import io.micronaut.data.tck.jdbc.entities.upsert.WarehouseInventory; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@MicronautTest +@SQLiteDBProperties(packages = "io.micronaut.data.jdbc.sqlite,io.micronaut.data.tck.jdbc.entities.upsert") +class SQLiteUpsertTest { + + @Inject + SQLiteProductReviewRepository productReviewRepository; + + @Inject + SQLiteCustomerProfileRepository customerProfileRepository; + + @Inject + SQLiteWarehouseInventoryRepository warehouseInventoryRepository; + + @AfterEach + void cleanup() { + warehouseInventoryRepository.deleteAll(); + customerProfileRepository.deleteAll(); + productReviewRepository.deleteAll(); + } + + @Test + void upsertInsertsAndUpdatesProductReviewByAssignedId() { + ProductReview review = new ProductReview(1L, "title new", "content new"); + + ProductReview inserted = productReviewRepository.upsert(review); + + assertProductReview(review, inserted); + assertProductReview(review, productReviewRepository.findById(1L).orElseThrow()); + + review.setTitle("title modified"); + review.setContent("content modified"); + ProductReview updated = productReviewRepository.upsert(review); + + assertProductReview(review, updated); + assertProductReview(review, productReviewRepository.findById(1L).orElseThrow()); + } + + @Test + void upsertAllInsertsAndUpdatesProductReviewsByAssignedId() { + ProductReview review1 = new ProductReview(1L, "title 1", "content 1"); + ProductReview review2 = new ProductReview(2L, "title 2", "content 2"); + + List inserted = productReviewRepository.upsertAll(List.of(review1, review2)); + + assertEquals(2, inserted.size()); + assertProductReview(review1, inserted.get(0)); + assertProductReview(review2, inserted.get(1)); + assertProductReview(review1, productReviewRepository.findById(1L).orElseThrow()); + assertProductReview(review2, productReviewRepository.findById(2L).orElseThrow()); + + review1.setTitle("title 1 modified"); + review1.setContent("content 1 modified"); + review2.setTitle("title 2 modified"); + review2.setContent("content 2 modified"); + List updated = productReviewRepository.upsertAll(List.of(review1, review2)); + + assertEquals(2, updated.size()); + assertProductReview(review1, updated.get(0)); + assertProductReview(review2, updated.get(1)); + assertProductReview(review1, productReviewRepository.findById(1L).orElseThrow()); + assertProductReview(review2, productReviewRepository.findById(2L).orElseThrow()); + } + + @Test + void upsertByEmailConflictInsertsAndUpdatesCustomerProfile() { + CustomerProfile profile = new CustomerProfile("test@example.com", "test"); + + CustomerProfile inserted = customerProfileRepository.upsert(profile); + + assertNotNull(inserted.getId()); + assertCustomerProfile(profile, inserted); + assertCustomerProfile(profile, customerProfileRepository.findById(inserted.getId()).orElseThrow()); + + profile.setDisplayName("test modified"); + CustomerProfile updated = customerProfileRepository.upsert(profile); + + assertCustomerProfile(profile, updated); + assertCustomerProfile(profile, customerProfileRepository.findById(inserted.getId()).orElseThrow()); + } + + @Test + void upsertAllByEmailConflictInsertsAndUpdatesCustomerProfiles() { + CustomerProfile profile1 = new CustomerProfile("test1@example.com", "test 1"); + CustomerProfile profile2 = new CustomerProfile("test2@example.com", "test 2"); + + List inserted = customerProfileRepository.upsertAll(List.of(profile1, profile2)); + + assertEquals(2, inserted.size()); + assertCustomerProfileContent(profile1, inserted.get(0)); + assertCustomerProfileContent(profile2, inserted.get(1)); + CustomerProfile found1 = customerProfileRepository.findByEmail(profile1.getEmail()).orElseThrow(); + CustomerProfile found2 = customerProfileRepository.findByEmail(profile2.getEmail()).orElseThrow(); + assertNotNull(found1.getId()); + assertNotNull(found2.getId()); + assertCustomerProfileContent(profile1, found1); + assertCustomerProfileContent(profile2, found2); + + profile1.setDisplayName("test 1 modified"); + profile2.setDisplayName("test 2 modified"); + List updated = customerProfileRepository.upsertAll(List.of(profile1, profile2)); + + assertEquals(2, updated.size()); + assertCustomerProfileContent(profile1, updated.get(0)); + assertCustomerProfileContent(profile2, updated.get(1)); + assertCustomerProfileContent(profile1, customerProfileRepository.findByEmail(profile1.getEmail()).orElseThrow()); + assertCustomerProfileContent(profile2, customerProfileRepository.findByEmail(profile2.getEmail()).orElseThrow()); + } + + @Test + void upsertBySkuAndWarehouseConflictInsertsAndUpdatesWarehouseInventory() { + WarehouseInventory inventory = new WarehouseInventory("SKU-100", "Berlin", 12); + + WarehouseInventory inserted = warehouseInventoryRepository.upsert(inventory); + + assertNotNull(inserted.getId()); + assertWarehouseInventory(inventory, inserted); + assertWarehouseInventory(inventory, warehouseInventoryRepository.findById(inserted.getId()).orElseThrow()); + + inventory.setQuantity(18); + WarehouseInventory updated = warehouseInventoryRepository.upsert(inventory); + + assertWarehouseInventory(inventory, updated); + assertWarehouseInventory(inventory, warehouseInventoryRepository.findById(inserted.getId()).orElseThrow()); + } + + @Test + void upsertAllBySkuAndWarehouseConflictInsertsAndUpdatesWarehouseInventory() { + WarehouseInventory inventory1 = new WarehouseInventory("SKU-200", "Berlin", 5); + WarehouseInventory inventory2 = new WarehouseInventory("SKU-200", "Paris", 8); + + List inserted = warehouseInventoryRepository.upsertAll(List.of(inventory1, inventory2)); + + assertEquals(2, inserted.size()); + assertWarehouseInventoryContent(inventory1, inserted.get(0)); + assertWarehouseInventoryContent(inventory2, inserted.get(1)); + WarehouseInventory found1 = warehouseInventoryRepository.findBySkuAndWarehouse(inventory1.getSku(), inventory1.getWarehouse()).orElseThrow(); + WarehouseInventory found2 = warehouseInventoryRepository.findBySkuAndWarehouse(inventory2.getSku(), inventory2.getWarehouse()).orElseThrow(); + assertNotNull(found1.getId()); + assertNotNull(found2.getId()); + assertWarehouseInventoryContent(inventory1, found1); + assertWarehouseInventoryContent(inventory2, found2); + + inventory1.setQuantity(7); + inventory2.setQuantity(11); + List updated = warehouseInventoryRepository.upsertAll(List.of(inventory1, inventory2)); + + assertEquals(2, updated.size()); + assertWarehouseInventoryContent(inventory1, updated.get(0)); + assertWarehouseInventoryContent(inventory2, updated.get(1)); + assertWarehouseInventoryContent(inventory1, warehouseInventoryRepository.findBySkuAndWarehouse(inventory1.getSku(), inventory1.getWarehouse()).orElseThrow()); + assertWarehouseInventoryContent(inventory2, warehouseInventoryRepository.findBySkuAndWarehouse(inventory2.getSku(), inventory2.getWarehouse()).orElseThrow()); + } + + private static void assertProductReview(ProductReview expected, ProductReview actual) { + assertEquals(expected.getId(), actual.getId()); + assertEquals(expected.getTitle(), actual.getTitle()); + assertEquals(expected.getContent(), actual.getContent()); + } + + private static void assertCustomerProfile(CustomerProfile expected, CustomerProfile actual) { + assertEquals(expected.getId(), actual.getId()); + assertCustomerProfileContent(expected, actual); + } + + private static void assertCustomerProfileContent(CustomerProfile expected, CustomerProfile actual) { + assertEquals(expected.getEmail(), actual.getEmail()); + assertEquals(expected.getDisplayName(), actual.getDisplayName()); + } + + private static void assertWarehouseInventory(WarehouseInventory expected, WarehouseInventory actual) { + assertEquals(expected.getId(), actual.getId()); + assertWarehouseInventoryContent(expected, actual); + } + + private static void assertWarehouseInventoryContent(WarehouseInventory expected, WarehouseInventory actual) { + assertEquals(expected.getSku(), actual.getSku()); + assertEquals(expected.getWarehouse(), actual.getWarehouse()); + assertEquals(expected.getQuantity(), actual.getQuantity()); + } +} diff --git a/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteWarehouseInventoryRepository.java b/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteWarehouseInventoryRepository.java new file mode 100644 index 00000000000..5b283b3e8e1 --- /dev/null +++ b/test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteWarehouseInventoryRepository.java @@ -0,0 +1,29 @@ +/* + * Copyright 2017-2026 original 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 io.micronaut.data.jdbc.sqlite; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.jdbc.entities.upsert.WarehouseInventory; +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository; + +import java.util.Optional; + +@JdbcRepository(dialect = Dialect.SQLITE) +public interface SQLiteWarehouseInventoryRepository extends WarehouseInventoryRepository { + + Optional findBySkuAndWarehouse(String sku, String warehouse); +}