From 4be0cfc0cf6724fbaddbc3f454aad1b7f2d1e7d7 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 11 Jun 2026 10:23:47 +0200 Subject: [PATCH 01/57] Upsert implementation - wip --- .../data/jdbc/h2/H2UpsertSpec.groovy | 27 ++ .../jdbc/h2/H2UpsertEntityRepository.java | 24 ++ .../io/micronaut/data/annotation/Upsert.java | 47 ++++ .../data/intercept/annotation/DataMethod.java | 6 + .../intercept/annotation/DataMethodQuery.java | 6 + .../model/query/builder/QueryBuilder.java | 27 ++ .../query/builder/sql/SqlQueryBuilder.java | 241 ++++++++++++++++++ .../data/model/runtime/StoredQuery.java | 6 + .../RepositoryTypeElementVisitor.java | 6 +- .../visitors/finders/FindersUtils.java | 2 +- .../visitors/finders/UpsertMethodMatcher.java | 167 ++++++++++++ ...a.processor.visitors.finders.MethodMatcher | 1 + .../data/processor/sql/BuildInsertSpec.groovy | 205 +++++++++++++++ .../intercept/AbstractQueryInterceptor.java | 1 + .../data/tck/tests/AbstractUpsertSpec.groovy | 92 +++++++ .../data/tck/entities/UpsertEntity.java | 26 ++ .../repositories/UpsertEntityRepository.java | 28 ++ 17 files changed, 910 insertions(+), 2 deletions(-) create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2UpsertSpec.groovy create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/H2UpsertEntityRepository.java create mode 100644 data-model/src/main/java/io/micronaut/data/annotation/Upsert.java create mode 100644 data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/UpsertMethodMatcher.java create mode 100644 data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractUpsertSpec.groovy create mode 100644 data-tck/src/main/java/io/micronaut/data/tck/entities/UpsertEntity.java create mode 100644 data-tck/src/main/java/io/micronaut/data/tck/repositories/UpsertEntityRepository.java 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..a7786044e00 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2UpsertSpec.groovy @@ -0,0 +1,27 @@ +/* + * 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.tck.repositories.UpsertEntityRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class H2UpsertSpec extends AbstractUpsertSpec implements H2TestPropertyProvider { + + @Override + UpsertEntityRepository getUpsertEntityRepository() { + return context.getBean(H2UpsertEntityRepository) + } +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/H2UpsertEntityRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/H2UpsertEntityRepository.java new file mode 100644 index 00000000000..5ce1d9963de --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/H2UpsertEntityRepository.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; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.UpsertEntityRepository; + +@JdbcRepository(dialect = Dialect.H2) +public interface H2UpsertEntityRepository extends UpsertEntityRepository { +} 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..3330a59a508 --- /dev/null +++ b/data-model/src/main/java/io/micronaut/data/annotation/Upsert.java @@ -0,0 +1,47 @@ +/* + * 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 annotated method must either be declared {@code void}, or have a return type that is the same as the type of + * its parameter. + *

+ * + * @since 5.1.0 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Upsert { +} 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..e73cbc9f9d0 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,21 @@ interface InsertQueryDefinition { } + /** + * The upsert query definition. + * + * @since 5.1.0 + */ + interface UpsertQueryDefinition { + + /** + * @return The persistent entity + */ + + PersistentEntity persistentEntity(); + + } + /** * 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 a6615404bab..d2b19a3f478 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 @@ -1430,6 +1430,247 @@ public DataType getDataType() { Collections.emptyMap()); } + @Override + public QueryResult buildUpsert(AnnotationMetadata repositoryMetadata, UpsertQueryDefinition definition) { + PersistentEntity entity = definition.persistentEntity(); + if (isJsonEntity(repositoryMetadata, entity)) { + throw new IllegalStateException("Upsert is not supported for JSON entity representation: " + entity.getName()); + } + if (!entity.hasIdentity() && !entity.hasCompositeIdentity()) { + throw new IllegalStateException("Upsert requires 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); + String tableName = getTableName(entity); + String query = switch (dialect) { + case H2 -> buildH2Upsert(tableName, data); + case MYSQL -> buildMySqlUpsert(tableName, data); + case POSTGRES -> buildPostgresUpsert(tableName, data); + case SQL_SERVER -> buildSqlServerUpsert(tableName, data); + case ORACLE -> buildOracleUpsert(tableName, data); + case ANSI -> buildAnsiUpsert(tableName, data); + }; + return QueryResult.of(query, Collections.emptyList(), data.parameterBindings(), Collections.emptyMap()); + } + + private UpsertData buildUpsertData(PersistentEntity entity) { + boolean escape = shouldEscape(entity); + NamingStrategy namingStrategy = getNamingStrategy(entity); + List columns = new ArrayList<>(); + List values = new ArrayList<>(); + List parameterBindings = new ArrayList<>(); + + 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); + }); + } + + for (PersistentProperty identity : entity.getIdentityProperties()) { + PersistentEntityUtils.traversePersistentProperties(Collections.emptyList(), identity, (associations, property) -> { + if (SqlQueryBuilderUtils.isGeneratedProperty(property, associations)) { + throw new IllegalStateException("Upsert requires a non-generated identity property: " + property.getName()); + } + addUpsertColumn(columns, values, parameterBindings, namingStrategy, associations, property, escape, true); + }); + } + + if (columns.isEmpty()) { + throw new IllegalStateException("Upsert requires at least one bindable column for entity: " + entity.getName()); + } + if (columns.stream().noneMatch(UpsertColumn::identity)) { + throw new IllegalStateException("Upsert requires at least one bindable identity column for entity: " + entity.getName()); + } + return new UpsertData(columns, values, parameterBindings); + } + + private void addUpsertColumn(List columns, + List values, + List parameterBindings, + NamingStrategy namingStrategy, + List associations, + PersistentProperty property, + boolean escape, + boolean identity) { + addWriteExpression(values, property); + String key = String.valueOf(values.size()); + String[] path = asStringPath(associations, property); + parameterBindings.add(createParameterBinding(key, property, path)); + + String columnName = getMappedName(namingStrategy, associations, property); + if (escape) { + columnName = quote(columnName); + } + columns.add(new UpsertColumn(columnName, values.get(values.size() - 1), "c" + columns.size(), identity)); + } + + private 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; + } + }; + } + + private String buildH2Upsert(String tableName, UpsertData data) { + return "MERGE INTO " + tableName + " (" + data.columnNames() + ") KEY(" + data.identityColumnNames() + ") VALUES (" + data.valueExpressions() + CLOSE_BRACKET; + } + + private String buildMySqlUpsert(String tableName, UpsertData data) { + List updateColumns = data.updateColumnsOrIdentity(); + return buildInsertStatement(tableName, data) + + " ON DUPLICATE KEY UPDATE " + + updateColumns.stream() + .map(column -> column.column() + "=VALUES(" + column.column() + CLOSE_BRACKET) + .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.identityColumnNames() + 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.valueExpressions() + ")) AS source (" + data.sourceColumns() + ") " + + "ON " + upsertIdentityPredicate(data) + + upsertMatchedClause(data) + + upsertInsertClause(data) + + ";"; + } + + private String buildOracleUpsert(String tableName, UpsertData data) { + String sourceSelect = data.columns().stream() + .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 (" + upsertIdentityPredicate(data) + CLOSE_BRACKET + + upsertMatchedClause(data) + + upsertInsertClause(data); + } + + private String buildAnsiUpsert(String tableName, UpsertData data) { + return "MERGE INTO " + tableName + " target " + + "USING (VALUES (" + data.valueExpressions() + ")) source (" + data.sourceColumns() + ") " + + "ON (" + upsertIdentityPredicate(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 upsertIdentityPredicate(UpsertData data) { + return data.identityColumns().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 -> "source." + column.source()) + .collect(Collectors.joining(String.valueOf(COMMA))) + + CLOSE_BRACKET; + } + + private record UpsertData(List columns, + List values, + List parameterBindings) { + + private String columnNames() { + return columns.stream() + .map(UpsertColumn::column) + .collect(Collectors.joining(String.valueOf(COMMA))); + } + + private String valueExpressions() { + return String.join(String.valueOf(COMMA), values); + } + + private String sourceColumns() { + return columns.stream() + .map(UpsertColumn::source) + .collect(Collectors.joining(String.valueOf(COMMA))); + } + + private List identityColumns() { + return columns.stream() + .filter(UpsertColumn::identity) + .toList(); + } + + private String identityColumnNames() { + return identityColumns().stream() + .map(UpsertColumn::column) + .collect(Collectors.joining(String.valueOf(COMMA))); + } + + private List updateColumns() { + return columns.stream() + .filter(column -> !column.identity()) + .toList(); + } + + private List updateColumnsOrIdentity() { + List updateColumns = updateColumns(); + return updateColumns.isEmpty() ? List.of(identityColumns().get(0)) : updateColumns; + } + } + + private record UpsertColumn(String column, + String value, + String source, + boolean identity) { + } + private String[] asStringPath(List associations, PersistentProperty property) { if (associations.isEmpty()) { return new String[]{property.getName()}; 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 8f828e23d07..88c0b61eee6 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..12267932075 --- /dev/null +++ b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/UpsertMethodMatcher.java @@ -0,0 +1,167 @@ +/* + * 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.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.PersistentProperty; +import io.micronaut.data.model.query.builder.QueryResult; +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.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()) { + return null; + } + MethodElement methodElement = matchContext.getMethodElement(); + boolean producesAnEntity = TypeUtils.doesMethodProducesAnEntityIterableOfAnEntity(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().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.hasIdentity() && !rootEntity.hasCompositeIdentity()) { + return "entity does not define an identity"; + } + if (rootEntity.hasVersion()) { + return "versioned entities are not supported"; + } + if (rootEntity.getIdentityProperties().stream().anyMatch(PersistentProperty::isGenerated)) { + return "generated identity 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()); + } + + 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() + ); + QueryResult queryResult = mc.getQueryBuilder().buildUpsert(annotationMetadataHierarchy, mc::getRootEntity); + + methodMatchInfo + .encodeEntityParameters(true) + .queryResult(queryResult); + if (entitiesParameter != null) { + methodMatchInfo.addParameterRole(entitiesParameter, TypeRole.ENTITIES); + } + if (entityParameter != null) { + methodMatchInfo.addParameterRole(entityParameter, TypeRole.ENTITY); + } + return methodMatchInfo; + }; + } + +} 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..e9003260b6a 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,6 +16,8 @@ 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.annotation.DataMethod import io.micronaut.data.model.DataType import io.micronaut.data.model.entities.Person @@ -422,6 +424,209 @@ 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; + +@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); +} + +@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() + + then: + getOperationType(upsertMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(upsertMethod) == UpdateEntityInterceptor.name + getQuery(upsertMethod) == query + getParameterPropertyPaths(upsertMethod) == ["name", "pages", "id"] as String[] + getOperationType(putMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(putMethod) == UpdateEntityInterceptor.name + getQuery(putMethod) == query + getParameterPropertyPaths(putMethod) == ["name", "pages", "id"] as String[] + getOperationType(putAllMethod) == DataMethod.OperationType.UPSERT + getDataInterceptor(putAllMethod) == UpdateAllEntitiesInterceptor.name + getQuery(putAllMethod) == query + getParameterPropertyPaths(putAllMethod) == ["name", "pages", "id"] as String[] + + where: + dialect | query + 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)' + Dialect.H2 | 'MERGE INTO `upsert_test` (`name`,`pages`,`id`) KEY(`id`) VALUES (?,?,?)' + Dialect.MYSQL | 'INSERT INTO `upsert_test` (`name`,`pages`,`id`) VALUES (?,?,?) ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`pages`=VALUES(`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)' + Dialect.POSTGRES | 'INSERT INTO "upsert_test" ("name","pages","id") VALUES (?,?,?) ON CONFLICT ("id") DO UPDATE SET "name"=EXCLUDED."name","pages"=EXCLUDED."pages"' + 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);' + } + + 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" + } + void "POSTGRES test build save returning "() { given: def repository = buildRepository('test.BookRepository', """ 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 81a089fc27b..fd6697afa14 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 @@ -591,6 +591,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-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..ae5e2a69e49 --- /dev/null +++ b/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractUpsertSpec.groovy @@ -0,0 +1,92 @@ +/* + * 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.entities.UpsertEntity +import io.micronaut.data.tck.repositories.UpsertEntityRepository +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.lang.Specification + +abstract class AbstractUpsertSpec extends Specification { + + abstract UpsertEntityRepository getUpsertEntityRepository() + + abstract Map getProperties() + + @AutoCleanup + @Shared + ApplicationContext context = ApplicationContext.run(properties) + + ApplicationContext getApplicationContext() { + return context + } + + void setup() { + upsertEntityRepository.deleteAll() + } + + void cleanup() { + upsertEntityRepository.deleteAll() + } + + void "upsert inserts and updates assigned ID entity"() { + when: + UpsertEntity inserted = upsertEntityRepository.upsert(new UpsertEntity(1L, "First", "Initial value")) + + then: + inserted == new UpsertEntity(1L, "First", "Initial value") + upsertEntityRepository.findById(1L).get() == inserted + + when: + UpsertEntity updated = upsertEntityRepository.upsert(new UpsertEntity(1L, "Second", "Updated value")) + + then: + updated == new UpsertEntity(1L, "Second", "Updated value") + upsertEntityRepository.findById(1L).get() == updated + } + + void "upsertAll inserts and updates assigned ID entities"() { + when: + List inserted = upsertEntityRepository.upsertAll([ + new UpsertEntity(2L, "Batch first", "Initial first"), + new UpsertEntity(3L, "Batch second", "Initial second") + ]).toList() + + then: + inserted as Set == [ + new UpsertEntity(2L, "Batch first", "Initial first"), + new UpsertEntity(3L, "Batch second", "Initial second") + ] as Set + upsertEntityRepository.findById(2L).get() == new UpsertEntity(2L, "Batch first", "Initial first") + upsertEntityRepository.findById(3L).get() == new UpsertEntity(3L, "Batch second", "Initial second") + + when: + List updated = upsertEntityRepository.upsertAll([ + new UpsertEntity(2L, "Batch first", "Updated first"), + new UpsertEntity(3L, "Batch second", "Updated second") + ]).toList() + + then: + updated as Set == [ + new UpsertEntity(2L, "Batch first", "Updated first"), + new UpsertEntity(3L, "Batch second", "Updated second") + ] as Set + upsertEntityRepository.findById(2L).get() == new UpsertEntity(2L, "Batch first", "Updated first") + upsertEntityRepository.findById(3L).get() == new UpsertEntity(3L, "Batch second", "Updated second") + } +} diff --git a/data-tck/src/main/java/io/micronaut/data/tck/entities/UpsertEntity.java b/data-tck/src/main/java/io/micronaut/data/tck/entities/UpsertEntity.java new file mode 100644 index 00000000000..1eedf566e8c --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/entities/UpsertEntity.java @@ -0,0 +1,26 @@ +/* + * 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.entities; + +import io.micronaut.data.annotation.MappedEntity; +import jakarta.persistence.Id; + +@MappedEntity("upsert_entity") +public record UpsertEntity( + @Id Long id, + String name, + String description) { +} diff --git a/data-tck/src/main/java/io/micronaut/data/tck/repositories/UpsertEntityRepository.java b/data-tck/src/main/java/io/micronaut/data/tck/repositories/UpsertEntityRepository.java new file mode 100644 index 00000000000..de089947940 --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/repositories/UpsertEntityRepository.java @@ -0,0 +1,28 @@ +/* + * 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; + +import io.micronaut.data.repository.CrudRepository; +import io.micronaut.data.tck.entities.UpsertEntity; + +import java.util.List; + +public interface UpsertEntityRepository extends CrudRepository { + + UpsertEntity upsert(UpsertEntity entity); + + List upsertAll(Iterable entities); +} From ddb055e7c3df748e1cb266a7b5863b8a37e4dcc6 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 11 Jun 2026 10:48:04 +0200 Subject: [PATCH 02/57] Upsert implementation - wip --- .../data/tck/tests/AbstractUpsertSpec.groovy | 46 +++++++++++++++++++ .../repositories/UpsertEntityRepository.java | 7 +++ 2 files changed, 53 insertions(+) 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 index ae5e2a69e49..04eeba2291d 100644 --- 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 @@ -89,4 +89,50 @@ abstract class AbstractUpsertSpec extends Specification { upsertEntityRepository.findById(2L).get() == new UpsertEntity(2L, "Batch first", "Updated first") upsertEntityRepository.findById(3L).get() == new UpsertEntity(3L, "Batch second", "Updated second") } + + void "upsert annotation inserts and updates assigned ID entity"() { + when: + UpsertEntity inserted = upsertEntityRepository.put(new UpsertEntity(4L, "Annotated first", "Initial value")) + + then: + inserted == new UpsertEntity(4L, "Annotated first", "Initial value") + upsertEntityRepository.findById(4L).get() == inserted + + when: + UpsertEntity updated = upsertEntityRepository.put(new UpsertEntity(4L, "Annotated second", "Updated value")) + + then: + updated == new UpsertEntity(4L, "Annotated second", "Updated value") + upsertEntityRepository.findById(4L).get() == updated + } + + void "upsert annotation inserts and updates assigned ID entities"() { + when: + List inserted = upsertEntityRepository.putAll([ + new UpsertEntity(5L, "Annotated batch first", "Initial first"), + new UpsertEntity(6L, "Annotated batch second", "Initial second") + ]).toList() + + then: + inserted as Set == [ + new UpsertEntity(5L, "Annotated batch first", "Initial first"), + new UpsertEntity(6L, "Annotated batch second", "Initial second") + ] as Set + upsertEntityRepository.findById(5L).get() == new UpsertEntity(5L, "Annotated batch first", "Initial first") + upsertEntityRepository.findById(6L).get() == new UpsertEntity(6L, "Annotated batch second", "Initial second") + + when: + List updated = upsertEntityRepository.putAll([ + new UpsertEntity(5L, "Annotated batch first", "Updated first"), + new UpsertEntity(6L, "Annotated batch second", "Updated second") + ]).toList() + + then: + updated as Set == [ + new UpsertEntity(5L, "Annotated batch first", "Updated first"), + new UpsertEntity(6L, "Annotated batch second", "Updated second") + ] as Set + upsertEntityRepository.findById(5L).get() == new UpsertEntity(5L, "Annotated batch first", "Updated first") + upsertEntityRepository.findById(6L).get() == new UpsertEntity(6L, "Annotated batch second", "Updated second") + } } diff --git a/data-tck/src/main/java/io/micronaut/data/tck/repositories/UpsertEntityRepository.java b/data-tck/src/main/java/io/micronaut/data/tck/repositories/UpsertEntityRepository.java index de089947940..9c07f32c22c 100644 --- a/data-tck/src/main/java/io/micronaut/data/tck/repositories/UpsertEntityRepository.java +++ b/data-tck/src/main/java/io/micronaut/data/tck/repositories/UpsertEntityRepository.java @@ -15,6 +15,7 @@ */ package io.micronaut.data.tck.repositories; +import io.micronaut.data.annotation.Upsert; import io.micronaut.data.repository.CrudRepository; import io.micronaut.data.tck.entities.UpsertEntity; @@ -25,4 +26,10 @@ public interface UpsertEntityRepository extends CrudRepository upsertAll(Iterable entities); + + @Upsert + UpsertEntity put(UpsertEntity entity); + + @Upsert + List putAll(Iterable entities); } From 7360d309352fac8d06cca828334eed67029a44dc Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 11 Jun 2026 11:29:00 +0200 Subject: [PATCH 03/57] Upsert implementation - wip --- .../data/jdbc/mariadb/MariaUpsertSpec.groovy | 28 +++++ .../data/jdbc/mysql/MySqlUpsertSpec.groovy | 27 +++++ .../jdbc/oraclexe/OracleXEUpsertSpec.groovy | 27 +++++ .../mysql/MySqlUpsertEntityRepository.java | 24 ++++ .../OracleXEUpsertEntityRepository.java | 24 ++++ .../query/builder/sql/SqlQueryBuilder.java | 104 +++++++++++++++++- .../data/processor/sql/BuildInsertSpec.groovy | 20 ++-- 7 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaUpsertSpec.groovy create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlUpsertSpec.groovy create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/OracleXEUpsertSpec.groovy create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/MySqlUpsertEntityRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/OracleXEUpsertEntityRepository.java 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..29e308b49ef --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaUpsertSpec.groovy @@ -0,0 +1,28 @@ +/* + * 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.MySqlUpsertEntityRepository +import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class MariaUpsertSpec extends AbstractUpsertSpec implements MariaTestPropertyProvider { + + @Override + UpsertEntityRepository getUpsertEntityRepository() { + return context.getBean(MySqlUpsertEntityRepository) + } +} 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..84b583abe59 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlUpsertSpec.groovy @@ -0,0 +1,27 @@ +/* + * 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.tck.repositories.UpsertEntityRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class MySqlUpsertSpec extends AbstractUpsertSpec implements MySQLTestPropertyProvider { + + @Override + UpsertEntityRepository getUpsertEntityRepository() { + return context.getBean(MySqlUpsertEntityRepository) + } +} 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..a8b4b45ddc6 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/OracleXEUpsertSpec.groovy @@ -0,0 +1,27 @@ +/* + * 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.tck.repositories.UpsertEntityRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleTestPropertyProvider { + + @Override + UpsertEntityRepository getUpsertEntityRepository() { + return context.getBean(OracleXEUpsertEntityRepository) + } +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/MySqlUpsertEntityRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/MySqlUpsertEntityRepository.java new file mode 100644 index 00000000000..b0bd06163df --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/MySqlUpsertEntityRepository.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; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.UpsertEntityRepository; + +@JdbcRepository(dialect = Dialect.MYSQL) +public interface MySqlUpsertEntityRepository extends UpsertEntityRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/OracleXEUpsertEntityRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/OracleXEUpsertEntityRepository.java new file mode 100644 index 00000000000..ffdc6520a59 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/OracleXEUpsertEntityRepository.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; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.UpsertEntityRepository; + +@JdbcRepository(dialect = Dialect.ORACLE) +public interface OracleXEUpsertEntityRepository extends UpsertEntityRepository { +} 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 d2b19a3f478..9c2dce00b16 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 @@ -1452,7 +1452,7 @@ public QueryResult buildUpsert(AnnotationMetadata repositoryMetadata, UpsertQuer case ORACLE -> buildOracleUpsert(tableName, data); case ANSI -> buildAnsiUpsert(tableName, data); }; - return QueryResult.of(query, Collections.emptyList(), data.parameterBindings(), Collections.emptyMap()); + return QueryResult.of(query, Collections.emptyList(), buildUpsertParameterBindings(data), Collections.emptyMap()); } private UpsertData buildUpsertData(PersistentEntity entity) { @@ -1500,13 +1500,14 @@ private void addUpsertColumn(List columns, addWriteExpression(values, property); String key = String.valueOf(values.size()); String[] path = asStringPath(associations, property); - parameterBindings.add(createParameterBinding(key, property, path)); + QueryParameterBinding parameterBinding = createParameterBinding(key, property, path); + parameterBindings.add(parameterBinding); String columnName = getMappedName(namingStrategy, associations, property); if (escape) { columnName = quote(columnName); } - columns.add(new UpsertColumn(columnName, values.get(values.size() - 1), "c" + columns.size(), identity)); + columns.add(new UpsertColumn(columnName, values.get(values.size() - 1), "c" + columns.size(), parameterBinding, identity)); } private QueryParameterBinding createParameterBinding(String key, PersistentProperty property, String[] path) { @@ -1547,10 +1548,104 @@ private String buildMySqlUpsert(String tableName, UpsertData data) { return buildInsertStatement(tableName, data) + " ON DUPLICATE KEY UPDATE " + updateColumns.stream() - .map(column -> column.column() + "=VALUES(" + column.column() + CLOSE_BRACKET) + .map(column -> column.column() + "=" + column.value()) .collect(Collectors.joining(String.valueOf(COMMA))); } + private List buildUpsertParameterBindings(UpsertData data) { + if (dialect != Dialect.MYSQL) { + return data.parameterBindings(); + } + List parameterBindings = new ArrayList<>(data.parameterBindings()); + for (UpsertColumn updateColumn : data.updateColumnsOrIdentity()) { + parameterBindings.add(copyParameterBinding(String.valueOf(parameterBindings.size() + 1), updateColumn.parameterBinding())); + } + return parameterBindings; + } + + private QueryParameterBinding copyParameterBinding(String key, QueryParameterBinding parameterBinding) { + return new QueryParameterBinding() { + @Override + public String getName() { + return key; + } + + @Override + public String getKey() { + return key; + } + + @Override + public DataType getDataType() { + return parameterBinding.getDataType(); + } + + @Override + public JsonDataType getJsonDataType() { + return parameterBinding.getJsonDataType(); + } + + @Override + @Nullable + public String getConverterClassName() { + return parameterBinding.getConverterClassName(); + } + + @Override + public int getParameterIndex() { + return parameterBinding.getParameterIndex(); + } + + @Override + public String @Nullable [] getParameterBindingPath() { + return parameterBinding.getParameterBindingPath(); + } + + @Override + public String @Nullable [] getPropertyPath() { + return parameterBinding.getPropertyPath(); + } + + @Override + public boolean isAutoPopulated() { + return parameterBinding.isAutoPopulated(); + } + + @Override + public boolean isRequiresPreviousPopulatedValue() { + return parameterBinding.isRequiresPreviousPopulatedValue(); + } + + @Override + public boolean isExpandable() { + return parameterBinding.isExpandable(); + } + + @Override + @Nullable + public Object getValue() { + return parameterBinding.getValue(); + } + + @Override + public boolean isExpression() { + return parameterBinding.isExpression(); + } + + @Override + @Nullable + public String getRole() { + return parameterBinding.getRole(); + } + + @Override + @Nullable + public String getTableAlias() { + return parameterBinding.getTableAlias(); + } + }; + } + private String buildPostgresUpsert(String tableName, UpsertData data) { List updateColumns = data.updateColumns(); String conflict = buildInsertStatement(tableName, data) + " ON CONFLICT (" + data.identityColumnNames() + CLOSE_BRACKET; @@ -1668,6 +1763,7 @@ private List updateColumnsOrIdentity() { private record UpsertColumn(String column, String value, String source, + QueryParameterBinding parameterBinding, boolean identity) { } 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 e9003260b6a..5ade928ccb5 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 @@ -487,24 +487,24 @@ class Test { getOperationType(upsertMethod) == DataMethod.OperationType.UPSERT getDataInterceptor(upsertMethod) == UpdateEntityInterceptor.name getQuery(upsertMethod) == query - getParameterPropertyPaths(upsertMethod) == ["name", "pages", "id"] as String[] + getParameterPropertyPaths(upsertMethod) == parameterPropertyPaths as String[] getOperationType(putMethod) == DataMethod.OperationType.UPSERT getDataInterceptor(putMethod) == UpdateEntityInterceptor.name getQuery(putMethod) == query - getParameterPropertyPaths(putMethod) == ["name", "pages", "id"] as String[] + getParameterPropertyPaths(putMethod) == parameterPropertyPaths as String[] getOperationType(putAllMethod) == DataMethod.OperationType.UPSERT getDataInterceptor(putAllMethod) == UpdateAllEntitiesInterceptor.name getQuery(putAllMethod) == query - getParameterPropertyPaths(putAllMethod) == ["name", "pages", "id"] as String[] + getParameterPropertyPaths(putAllMethod) == parameterPropertyPaths as String[] where: - dialect | query - 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)' - Dialect.H2 | 'MERGE INTO `upsert_test` (`name`,`pages`,`id`) KEY(`id`) VALUES (?,?,?)' - Dialect.MYSQL | 'INSERT INTO `upsert_test` (`name`,`pages`,`id`) VALUES (?,?,?) ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`pages`=VALUES(`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)' - Dialect.POSTGRES | 'INSERT INTO "upsert_test" ("name","pages","id") VALUES (?,?,?) ON CONFLICT ("id") DO UPDATE SET "name"=EXCLUDED."name","pages"=EXCLUDED."pages"' - 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);' + 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.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"] } void "test annotated upsert on repository without base interface"() { From b044e358e988fea257936935736073c2d7ed0999 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 11 Jun 2026 12:12:57 +0200 Subject: [PATCH 04/57] Upsert implementation - wip --- .../query/builder/sql/SqlQueryBuilder.java | 93 +------------------ 1 file changed, 5 insertions(+), 88 deletions(-) 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 9c2dce00b16..145cf3a753b 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 @@ -1500,14 +1500,13 @@ private void addUpsertColumn(List columns, addWriteExpression(values, property); String key = String.valueOf(values.size()); String[] path = asStringPath(associations, property); - QueryParameterBinding parameterBinding = createParameterBinding(key, property, path); - parameterBindings.add(parameterBinding); + parameterBindings.add(createParameterBinding(key, property, path)); String columnName = getMappedName(namingStrategy, associations, property); if (escape) { columnName = quote(columnName); } - columns.add(new UpsertColumn(columnName, values.get(values.size() - 1), "c" + columns.size(), parameterBinding, identity)); + columns.add(new UpsertColumn(columnName, values.get(values.size() - 1), "c" + columns.size(), property, List.of(path), identity)); } private QueryParameterBinding createParameterBinding(String key, PersistentProperty property, String[] path) { @@ -1558,94 +1557,11 @@ private List buildUpsertParameterBindings(UpsertData data } List parameterBindings = new ArrayList<>(data.parameterBindings()); for (UpsertColumn updateColumn : data.updateColumnsOrIdentity()) { - parameterBindings.add(copyParameterBinding(String.valueOf(parameterBindings.size() + 1), updateColumn.parameterBinding())); + parameterBindings.add(createParameterBinding(String.valueOf(parameterBindings.size() + 1), updateColumn.property(), updateColumn.path().toArray(new String[0]))); } return parameterBindings; } - private QueryParameterBinding copyParameterBinding(String key, QueryParameterBinding parameterBinding) { - return new QueryParameterBinding() { - @Override - public String getName() { - return key; - } - - @Override - public String getKey() { - return key; - } - - @Override - public DataType getDataType() { - return parameterBinding.getDataType(); - } - - @Override - public JsonDataType getJsonDataType() { - return parameterBinding.getJsonDataType(); - } - - @Override - @Nullable - public String getConverterClassName() { - return parameterBinding.getConverterClassName(); - } - - @Override - public int getParameterIndex() { - return parameterBinding.getParameterIndex(); - } - - @Override - public String @Nullable [] getParameterBindingPath() { - return parameterBinding.getParameterBindingPath(); - } - - @Override - public String @Nullable [] getPropertyPath() { - return parameterBinding.getPropertyPath(); - } - - @Override - public boolean isAutoPopulated() { - return parameterBinding.isAutoPopulated(); - } - - @Override - public boolean isRequiresPreviousPopulatedValue() { - return parameterBinding.isRequiresPreviousPopulatedValue(); - } - - @Override - public boolean isExpandable() { - return parameterBinding.isExpandable(); - } - - @Override - @Nullable - public Object getValue() { - return parameterBinding.getValue(); - } - - @Override - public boolean isExpression() { - return parameterBinding.isExpression(); - } - - @Override - @Nullable - public String getRole() { - return parameterBinding.getRole(); - } - - @Override - @Nullable - public String getTableAlias() { - return parameterBinding.getTableAlias(); - } - }; - } - private String buildPostgresUpsert(String tableName, UpsertData data) { List updateColumns = data.updateColumns(); String conflict = buildInsertStatement(tableName, data) + " ON CONFLICT (" + data.identityColumnNames() + CLOSE_BRACKET; @@ -1763,7 +1679,8 @@ private List updateColumnsOrIdentity() { private record UpsertColumn(String column, String value, String source, - QueryParameterBinding parameterBinding, + PersistentProperty property, + List path, boolean identity) { } From 7d53ae77e9d84d9bfb54e3bb7e75377dab138e7d Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 11 Jun 2026 12:33:19 +0200 Subject: [PATCH 05/57] Upsert implementation - wip --- .../data/r2dbc/h2/H2UpsertSpec.groovy | 27 ++++++++++++++++++ .../r2dbc/mariadb/MariaDbUpsertSpec.groovy | 28 +++++++++++++++++++ .../data/r2dbc/mysql/MySqlUpsertSpec.groovy | 27 ++++++++++++++++++ .../r2dbc/oraclexe/OracleXEUpsertSpec.groovy | 27 ++++++++++++++++++ .../r2dbc/h2/H2UpsertEntityRepository.java | 24 ++++++++++++++++ .../mysql/MySqlUpsertEntityRepository.java | 24 ++++++++++++++++ .../OracleXEUpsertEntityRepository.java | 24 ++++++++++++++++ 7 files changed, 181 insertions(+) create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2UpsertSpec.groovy create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaDbUpsertSpec.groovy create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlUpsertSpec.groovy create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/OracleXEUpsertSpec.groovy create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/H2UpsertEntityRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlUpsertEntityRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/OracleXEUpsertEntityRepository.java 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..a024e48cdd3 --- /dev/null +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2UpsertSpec.groovy @@ -0,0 +1,27 @@ +/* + * 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.tck.repositories.UpsertEntityRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class H2UpsertSpec extends AbstractUpsertSpec implements H2TestPropertyProvider { + + @Override + UpsertEntityRepository getUpsertEntityRepository() { + return context.getBean(H2UpsertEntityRepository) + } +} 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..8fa0e5dfc46 --- /dev/null +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaDbUpsertSpec.groovy @@ -0,0 +1,28 @@ +/* + * 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.MySqlUpsertEntityRepository +import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class MariaDbUpsertSpec extends AbstractUpsertSpec implements MariaDbTestPropertyProvider { + + @Override + UpsertEntityRepository getUpsertEntityRepository() { + return context.getBean(MySqlUpsertEntityRepository) + } +} 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..8b886f66116 --- /dev/null +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlUpsertSpec.groovy @@ -0,0 +1,27 @@ +/* + * 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.tck.repositories.UpsertEntityRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class MySqlUpsertSpec extends AbstractUpsertSpec implements MySqlTestPropertyProvider { + + @Override + UpsertEntityRepository getUpsertEntityRepository() { + return context.getBean(MySqlUpsertEntityRepository) + } +} 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..581b5b9484d --- /dev/null +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/OracleXEUpsertSpec.groovy @@ -0,0 +1,27 @@ +/* + * 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.tck.repositories.UpsertEntityRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleXETestPropertyProvider { + + @Override + UpsertEntityRepository getUpsertEntityRepository() { + return context.getBean(OracleXEUpsertEntityRepository) + } +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/H2UpsertEntityRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/H2UpsertEntityRepository.java new file mode 100644 index 00000000000..83a5fb80d6e --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/H2UpsertEntityRepository.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; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.UpsertEntityRepository; + +@R2dbcRepository(dialect = Dialect.H2) +public interface H2UpsertEntityRepository extends UpsertEntityRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlUpsertEntityRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlUpsertEntityRepository.java new file mode 100644 index 00000000000..fa9dff04d9c --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlUpsertEntityRepository.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; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.UpsertEntityRepository; + +@R2dbcRepository(dialect = Dialect.MYSQL) +public interface MySqlUpsertEntityRepository extends UpsertEntityRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/OracleXEUpsertEntityRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/OracleXEUpsertEntityRepository.java new file mode 100644 index 00000000000..a8fa93daa56 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/OracleXEUpsertEntityRepository.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; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.UpsertEntityRepository; + +@R2dbcRepository(dialect = Dialect.ORACLE) +public interface OracleXEUpsertEntityRepository extends UpsertEntityRepository { +} From c3af05e7ae732291c69e8a2e694645a675a1a504 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 12 Jun 2026 09:15:52 +0200 Subject: [PATCH 06/57] Upsert implementation - added tests for sqlserver and postgres --- .../jdbc/postgres/PostgresUpsertSpec.groovy | 27 +++++++++++++++++++ .../jdbc/sqlserver/SqlServerUpsertSpec.groovy | 27 +++++++++++++++++++ .../PostgresUpsertEntityRepository.java | 24 +++++++++++++++++ .../sqlserver/MSUpsertEntityRepository.java | 24 +++++++++++++++++ .../r2dbc/postgres/PostgresUpsertSpec.groovy | 27 +++++++++++++++++++ .../sqlserver/SqlServerUpsertSpec.groovy | 27 +++++++++++++++++++ .../PostgresUpsertEntityRepository.java | 24 +++++++++++++++++ .../sqlserver/MSUpsertEntityRepository.java | 24 +++++++++++++++++ 8 files changed, 204 insertions(+) create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/postgres/PostgresUpsertSpec.groovy create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/sqlserver/SqlServerUpsertSpec.groovy create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/PostgresUpsertEntityRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/MSUpsertEntityRepository.java create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresUpsertSpec.groovy create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/sqlserver/SqlServerUpsertSpec.groovy create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/PostgresUpsertEntityRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/MSUpsertEntityRepository.java 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..bdaea9019b4 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/postgres/PostgresUpsertSpec.groovy @@ -0,0 +1,27 @@ +/* + * 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.tck.repositories.UpsertEntityRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPropertyProvider { + + @Override + UpsertEntityRepository getUpsertEntityRepository() { + return context.getBean(PostgresUpsertEntityRepository) + } +} 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..1199191d408 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/sqlserver/SqlServerUpsertSpec.groovy @@ -0,0 +1,27 @@ +/* + * 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.tck.repositories.UpsertEntityRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class SqlServerUpsertSpec extends AbstractUpsertSpec implements MSSQLTestPropertyProvider { + + @Override + UpsertEntityRepository getUpsertEntityRepository() { + return context.getBean(MSUpsertEntityRepository) + } +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/PostgresUpsertEntityRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/PostgresUpsertEntityRepository.java new file mode 100644 index 00000000000..8aeb0b40d28 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/PostgresUpsertEntityRepository.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; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.UpsertEntityRepository; + +@JdbcRepository(dialect = Dialect.POSTGRES) +public interface PostgresUpsertEntityRepository extends UpsertEntityRepository { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/MSUpsertEntityRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/MSUpsertEntityRepository.java new file mode 100644 index 00000000000..5b17103bcdc --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/MSUpsertEntityRepository.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; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.repositories.UpsertEntityRepository; + +@JdbcRepository(dialect = Dialect.SQL_SERVER) +public interface MSUpsertEntityRepository extends UpsertEntityRepository { +} 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..44957967288 --- /dev/null +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresUpsertSpec.groovy @@ -0,0 +1,27 @@ +/* + * 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.tck.repositories.UpsertEntityRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPropertyProvider { + + @Override + UpsertEntityRepository getUpsertEntityRepository() { + return context.getBean(PostgresUpsertEntityRepository) + } +} 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..964d8997c6b --- /dev/null +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/sqlserver/SqlServerUpsertSpec.groovy @@ -0,0 +1,27 @@ +/* + * 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.tck.repositories.UpsertEntityRepository +import io.micronaut.data.tck.tests.AbstractUpsertSpec + +class SqlServerUpsertSpec extends AbstractUpsertSpec implements SqlServerTestPropertyProvider { + + @Override + UpsertEntityRepository getUpsertEntityRepository() { + return context.getBean(MSUpsertEntityRepository) + } +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/PostgresUpsertEntityRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/PostgresUpsertEntityRepository.java new file mode 100644 index 00000000000..d716a227d82 --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/PostgresUpsertEntityRepository.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; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.UpsertEntityRepository; + +@R2dbcRepository(dialect = Dialect.POSTGRES) +public interface PostgresUpsertEntityRepository extends UpsertEntityRepository { +} diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/MSUpsertEntityRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/MSUpsertEntityRepository.java new file mode 100644 index 00000000000..159cfaa087d --- /dev/null +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/MSUpsertEntityRepository.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; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.r2dbc.annotation.R2dbcRepository; +import io.micronaut.data.tck.repositories.UpsertEntityRepository; + +@R2dbcRepository(dialect = Dialect.SQL_SERVER) +public interface MSUpsertEntityRepository extends UpsertEntityRepository { +} From a743cb3ec27375a4a0dc89f72ef24079cf128296 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 12 Jun 2026 10:54:52 +0200 Subject: [PATCH 07/57] Upsert implementation - minor refactoring --- .../query/builder/sql/SqlQueryBuilder.java | 115 +++++------------- 1 file changed, 31 insertions(+), 84 deletions(-) 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 145cf3a753b..75d7efa63f9 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 @@ -66,7 +66,6 @@ import io.micronaut.data.model.query.builder.QueryResult; import io.micronaut.data.model.runtime.convert.SqlIndexDefinitionProvider; import io.micronaut.data.model.schema.sql.SqlColumnMapping; -import io.micronaut.data.model.schema.sql.SqlDbType; import io.micronaut.data.model.schema.sql.SqlIndexMapping; import io.micronaut.data.model.schema.sql.SqlSequenceMapping; import io.micronaut.data.model.schema.sql.SqlTableMapping; @@ -1235,32 +1234,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); @@ -1349,34 +1323,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); @@ -1509,35 +1456,6 @@ private void addUpsertColumn(List columns, columns.add(new UpsertColumn(columnName, values.get(values.size() - 1), "c" + columns.size(), property, List.of(path), identity)); } - private 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; - } - }; - } - private String buildH2Upsert(String tableName, UpsertData data) { return "MERGE INTO " + tableName + " (" + data.columnNames() + ") KEY(" + data.identityColumnNames() + ") VALUES (" + data.valueExpressions() + CLOSE_BRACKET; } @@ -1684,6 +1602,35 @@ private record UpsertColumn(String column, boolean identity) { } + private 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; + } + }; + } + private String[] asStringPath(List associations, PersistentProperty property) { if (associations.isEmpty()) { return new String[]{property.getName()}; From 2b5038e15f91498abdcf1844b44c48440a08df56 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 12 Jun 2026 15:00:26 +0200 Subject: [PATCH 08/57] Upsert implementation - added conflictProperties --- .../io/micronaut/data/annotation/Upsert.java | 11 ++ .../model/query/builder/QueryBuilder.java | 7 ++ .../query/builder/sql/SqlQueryBuilder.java | 100 +++++++++++++----- .../visitors/finders/UpsertMethodMatcher.java | 55 +++++++++- .../data/processor/sql/BuildInsertSpec.groovy | 70 ++++++++++++ 5 files changed, 214 insertions(+), 29 deletions(-) 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 index 3330a59a508..2419863100f 100644 --- a/data-model/src/main/java/io/micronaut/data/annotation/Upsert.java +++ b/data-model/src/main/java/io/micronaut/data/annotation/Upsert.java @@ -37,6 +37,10 @@ *

The annotated method must either be declared {@code void}, or have a return type that is the same as the type of * its parameter. *

+ *

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

* * @since 5.1.0 */ @@ -44,4 +48,11 @@ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Upsert { + + /** + * The persistent entity properties to use as the conflict target. + * + * @return The conflict properties + */ + String[] conflictProperties() default {}; } 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 e73cbc9f9d0..8b6fd3cd037 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 @@ -218,6 +218,13 @@ interface UpsertQueryDefinition { PersistentEntity persistentEntity(); + /** + * @return The persistent entity properties to use as the conflict target + */ + default List conflictProperties() { + return List.of(); + } + } /** 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 75d7efa63f9..13db2bd196f 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 @@ -1383,13 +1383,13 @@ public QueryResult buildUpsert(AnnotationMetadata repositoryMetadata, UpsertQuer if (isJsonEntity(repositoryMetadata, entity)) { throw new IllegalStateException("Upsert is not supported for JSON entity representation: " + entity.getName()); } - if (!entity.hasIdentity() && !entity.hasCompositeIdentity()) { + if (definition.conflictProperties().isEmpty() && !entity.hasIdentity() && !entity.hasCompositeIdentity()) { throw new IllegalStateException("Upsert requires 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); + UpsertData data = buildUpsertData(entity, definition.conflictProperties()); String tableName = getTableName(entity); String query = switch (dialect) { case H2 -> buildH2Upsert(tableName, data); @@ -1402,19 +1402,20 @@ public QueryResult buildUpsert(AnnotationMetadata repositoryMetadata, UpsertQuer return QueryResult.of(query, Collections.emptyList(), buildUpsertParameterBindings(data), Collections.emptyMap()); } - private UpsertData buildUpsertData(PersistentEntity entity) { + private UpsertData buildUpsertData(PersistentEntity entity, List conflictProperties) { boolean escape = shouldEscape(entity); NamingStrategy namingStrategy = getNamingStrategy(entity); List columns = new ArrayList<>(); List values = new ArrayList<>(); List parameterBindings = new ArrayList<>(); + List conflictPropertyPaths = resolveUpsertConflictPropertyPaths(entity, conflictProperties); 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); + addUpsertColumn(columns, values, parameterBindings, namingStrategy, associations, property, escape, false, conflictPropertyPaths); }); } @@ -1423,15 +1424,15 @@ private UpsertData buildUpsertData(PersistentEntity entity) { if (SqlQueryBuilderUtils.isGeneratedProperty(property, associations)) { throw new IllegalStateException("Upsert requires a non-generated identity property: " + property.getName()); } - addUpsertColumn(columns, values, parameterBindings, namingStrategy, associations, property, escape, true); + 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::identity)) { - throw new IllegalStateException("Upsert requires at least one bindable identity 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, values, parameterBindings); } @@ -1443,7 +1444,8 @@ private void addUpsertColumn(List columns, List associations, PersistentProperty property, boolean escape, - boolean identity) { + boolean identity, + List conflictPropertyPaths) { addWriteExpression(values, property); String key = String.valueOf(values.size()); String[] path = asStringPath(associations, property); @@ -1453,15 +1455,60 @@ private void addUpsertColumn(List columns, if (escape) { columnName = quote(columnName); } - columns.add(new UpsertColumn(columnName, values.get(values.size() - 1), "c" + columns.size(), property, List.of(path), identity)); + columns.add(new UpsertColumn(columnName, values.get(values.size() - 1), "c" + columns.size(), property, List.of(path), identity, conflictPropertyPaths.contains(toPathString(path)))); + } + + 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(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.identityColumnNames() + ") VALUES (" + data.valueExpressions() + CLOSE_BRACKET; + return "MERGE INTO " + tableName + " (" + data.columnNames() + ") KEY(" + data.conflictColumnNames() + ") VALUES (" + data.valueExpressions() + CLOSE_BRACKET; } private String buildMySqlUpsert(String tableName, UpsertData data) { - List updateColumns = data.updateColumnsOrIdentity(); + List updateColumns = data.updateColumnsOrConflict(); return buildInsertStatement(tableName, data) + " ON DUPLICATE KEY UPDATE " + updateColumns.stream() @@ -1474,7 +1521,7 @@ private List buildUpsertParameterBindings(UpsertData data return data.parameterBindings(); } List parameterBindings = new ArrayList<>(data.parameterBindings()); - for (UpsertColumn updateColumn : data.updateColumnsOrIdentity()) { + for (UpsertColumn updateColumn : data.updateColumnsOrConflict()) { parameterBindings.add(createParameterBinding(String.valueOf(parameterBindings.size() + 1), updateColumn.property(), updateColumn.path().toArray(new String[0]))); } return parameterBindings; @@ -1482,7 +1529,7 @@ private List buildUpsertParameterBindings(UpsertData data private String buildPostgresUpsert(String tableName, UpsertData data) { List updateColumns = data.updateColumns(); - String conflict = buildInsertStatement(tableName, data) + " ON CONFLICT (" + data.identityColumnNames() + CLOSE_BRACKET; + String conflict = buildInsertStatement(tableName, data) + " ON CONFLICT (" + data.conflictColumnNames() + CLOSE_BRACKET; if (updateColumns.isEmpty()) { return conflict + " DO NOTHING"; } @@ -1496,7 +1543,7 @@ private String buildPostgresUpsert(String tableName, UpsertData data) { private String buildSqlServerUpsert(String tableName, UpsertData data) { return "MERGE INTO " + tableName + " WITH (HOLDLOCK) AS target " + "USING (VALUES (" + data.valueExpressions() + ")) AS source (" + data.sourceColumns() + ") " - + "ON " + upsertIdentityPredicate(data) + + "ON " + upsertConflictPredicate(data) + upsertMatchedClause(data) + upsertInsertClause(data) + ";"; @@ -1508,7 +1555,7 @@ private String buildOracleUpsert(String tableName, UpsertData data) { .collect(Collectors.joining(String.valueOf(COMMA))); return "MERGE INTO " + tableName + " target " + "USING (SELECT " + sourceSelect + " FROM DUAL) source " - + "ON (" + upsertIdentityPredicate(data) + CLOSE_BRACKET + + "ON (" + upsertConflictPredicate(data) + CLOSE_BRACKET + upsertMatchedClause(data) + upsertInsertClause(data); } @@ -1516,7 +1563,7 @@ private String buildOracleUpsert(String tableName, UpsertData data) { private String buildAnsiUpsert(String tableName, UpsertData data) { return "MERGE INTO " + tableName + " target " + "USING (VALUES (" + data.valueExpressions() + ")) source (" + data.sourceColumns() + ") " - + "ON (" + upsertIdentityPredicate(data) + CLOSE_BRACKET + + "ON (" + upsertConflictPredicate(data) + CLOSE_BRACKET + upsertMatchedClause(data) + upsertInsertClause(data); } @@ -1525,8 +1572,8 @@ private String buildInsertStatement(String tableName, UpsertData data) { return INSERT_INTO + tableName + " (" + data.columnNames() + ") VALUES (" + data.valueExpressions() + CLOSE_BRACKET; } - private String upsertIdentityPredicate(UpsertData data) { - return data.identityColumns().stream() + private String upsertConflictPredicate(UpsertData data) { + return data.conflictColumns().stream() .map(column -> "target." + column.column() + "=source." + column.source()) .collect(Collectors.joining(" AND ")); } @@ -1570,27 +1617,27 @@ private String sourceColumns() { .collect(Collectors.joining(String.valueOf(COMMA))); } - private List identityColumns() { + private List conflictColumns() { return columns.stream() - .filter(UpsertColumn::identity) + .filter(UpsertColumn::conflict) .toList(); } - private String identityColumnNames() { - return identityColumns().stream() + 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()) + .filter(column -> !column.identity() && !column.conflict()) .toList(); } - private List updateColumnsOrIdentity() { + private List updateColumnsOrConflict() { List updateColumns = updateColumns(); - return updateColumns.isEmpty() ? List.of(identityColumns().get(0)) : updateColumns; + return updateColumns.isEmpty() ? List.of(conflictColumns().get(0)) : updateColumns; } } @@ -1599,7 +1646,8 @@ private record UpsertColumn(String column, String source, PersistentProperty property, List path, - boolean identity) { + boolean identity, + boolean conflict) { } private QueryParameterBinding createParameterBinding(String key, PersistentProperty property, String[] path) { 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 index 12267932075..85ea21178df 100644 --- 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 @@ -16,11 +16,15 @@ 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.SqlQueryBuilder; import io.micronaut.data.processor.model.SourcePersistentEntity; @@ -33,6 +37,7 @@ import io.micronaut.inject.processing.ProcessingException; import org.jspecify.annotations.Nullable; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -112,8 +117,9 @@ private String explicitUpsertUnsupportedReason(MethodMatchContext matchContext) if (DataAnnotationUtils.hasJsonEntityRepresentationAnnotation(rootEntity.getAnnotationMetadata())) { return "JSON entity representation is not supported"; } - if (!rootEntity.hasIdentity() && !rootEntity.hasCompositeIdentity()) { - return "entity does not define an identity"; + List conflictProperties = conflictProperties(matchContext); + if (conflictProperties.isEmpty() && !rootEntity.hasIdentity() && !rootEntity.hasCompositeIdentity()) { + return "entity does not define an identity and no conflict properties were specified"; } if (rootEntity.hasVersion()) { return "versioned entities are not supported"; @@ -121,6 +127,34 @@ private String explicitUpsertUnsupportedReason(MethodMatchContext matchContext) 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; } @@ -149,7 +183,18 @@ private MethodMatch upsertEntity() { mc.getRepositoryClass().getAnnotationMetadata(), mc.getAnnotationMetadata() ); - QueryResult queryResult = mc.getQueryBuilder().buildUpsert(annotationMetadataHierarchy, mc::getRootEntity); + List conflictProperties = conflictProperties(mc); + QueryResult queryResult = mc.getQueryBuilder().buildUpsert(annotationMetadataHierarchy, new QueryBuilder.UpsertQueryDefinition() { + @Override + public SourcePersistentEntity persistentEntity() { + return mc.getRootEntity(); + } + + @Override + public List conflictProperties() { + return conflictProperties; + } + }); methodMatchInfo .encodeEntityParameters(true) @@ -164,4 +209,8 @@ private MethodMatch upsertEntity() { }; } + private List conflictProperties(MethodMatchContext matchContext) { + return Arrays.asList(matchContext.getAnnotationMetadata().stringValues(Upsert.class, "conflictProperties")); + } + } 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 5ade928ccb5..1549772368f 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 @@ -507,6 +507,74 @@ class Test { 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; + +@JdbcRepository(dialect=Dialect.${dialect.name()}) +@io.micronaut.context.annotation.Executable +interface MyInterface extends GenericRepository { + @Upsert(conflictProperties = "name") + Test put(Test test); +} + +@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.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"] + } + void "test annotated upsert on repository without base interface"() { given: BeanDefinition beanDefinition = buildRepository('test.MyInterface', """ @@ -625,6 +693,8 @@ class Test { "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(conflictProperties = \"\")" | "" | "@Id" | "" | "conflict property cannot be blank" + "unknown conflict property" | "@Upsert(conflictProperties = \"missing\")" | "" | "@Id" | "" | "conflict property does not exist: missing" } void "POSTGRES test build save returning "() { From b815148b888d961a1f1771611db97a2d62ded779 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 12 Jun 2026 15:13:01 +0200 Subject: [PATCH 09/57] Upsert implementation - added conflictProperties --- .../data/processor/sql/BuildInsertSpec.groovy | 193 ++++++++++++------ 1 file changed, 135 insertions(+), 58 deletions(-) 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 1549772368f..c7f9da62f99 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 @@ -427,7 +427,7 @@ interface MyInterface extends GenericRepository { @Unroll void "test build upsert for dialect - #dialect"() { given: - BeanDefinition beanDefinition = buildRepository('test.MyInterface', """ + 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; @@ -479,38 +479,38 @@ class Test { """) when: - def upsertMethod = beanDefinition.findPossibleMethods("upsert").findFirst().get() - def putMethod = beanDefinition.findPossibleMethods("put").findFirst().get() - def putAllMethod = beanDefinition.findPossibleMethods("putAll").findFirst().get() + def upsertMethod = beanDefinition.findPossibleMethods("upsert").findFirst().get() + def putMethod = beanDefinition.findPossibleMethods("put").findFirst().get() + def putAllMethod = beanDefinition.findPossibleMethods("putAll").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(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[] 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.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"] + 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.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', """ + 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; @@ -557,27 +557,104 @@ class Test { """) when: - def putMethod = beanDefinition.findPossibleMethods("put").findFirst().get() + 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.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 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(conflictProperties = {"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[] + 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.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"] + 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.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', """ + 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; @@ -618,24 +695,24 @@ class Test { """) when: - def putMethod = beanDefinition.findPossibleMethods("put").findFirst().get() - def putAllMethod = beanDefinition.findPossibleMethods("putAll").findFirst().get() + 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[] + 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', """ + 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; @@ -683,18 +760,18 @@ class Test { """) then: - def ex = thrown(RuntimeException) - ex.message.contains("Cannot implement explicit upsert query: ${message}") + 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(conflictProperties = \"\")" | "" | "@Id" | "" | "conflict property cannot be blank" - "unknown conflict property" | "@Upsert(conflictProperties = \"missing\")" | "" | "@Id" | "" | "conflict property does not exist: missing" + 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(conflictProperties = \"\")" | "" | "@Id" | "" | "conflict property cannot be blank" + "unknown conflict property" | "@Upsert(conflictProperties = \"missing\")" | "" | "@Id" | "" | "conflict property does not exist: missing" } void "POSTGRES test build save returning "() { From f450bde52991e8e9bf2895607a31319041fafaf1 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 12 Jun 2026 16:13:44 +0200 Subject: [PATCH 10/57] Upsert implementation - wip --- .../data/jdbc/h2/H2UpsertSpec.groovy | 7 +- .../data/jdbc/mariadb/MariaUpsertSpec.groovy | 8 +- .../data/jdbc/mysql/MySqlUpsertSpec.groovy | 7 +- .../jdbc/oraclexe/OracleXEUpsertSpec.groovy | 7 +- .../jdbc/postgres/PostgresUpsertSpec.groovy | 7 +- .../jdbc/sqlserver/SqlServerUpsertSpec.groovy | 7 +- .../H2ProductReviewRepository.java} | 6 +- .../MySqlProductReviewRepository.java} | 6 +- .../OracleXEProductReviewRepository.java} | 6 +- .../PostgresProductReviewRepository.java} | 6 +- .../MSProductReviewRepository.java} | 6 +- .../data/r2dbc/h2/H2UpsertSpec.groovy | 7 +- .../r2dbc/mariadb/MariaDbUpsertSpec.groovy | 8 +- .../data/r2dbc/mysql/MySqlUpsertSpec.groovy | 7 +- .../r2dbc/oraclexe/OracleXEUpsertSpec.groovy | 7 +- .../r2dbc/postgres/PostgresUpsertSpec.groovy | 7 +- .../sqlserver/SqlServerUpsertSpec.groovy | 7 +- .../H2ProductReviewRepository.java} | 6 +- .../MySqlProductReviewRepository.java} | 6 +- .../OracleXEProductReviewRepository.java} | 6 +- .../PostgresProductReviewRepository.java} | 6 +- .../MSProductReviewRepository.java} | 6 +- .../data/tck/tests/AbstractUpsertSpec.groovy | 90 +++++++++---------- .../jdbc/entities/upsert/CustomerProfile.java | 42 +++++++++ .../entities/upsert/ProductReview.java} | 18 ++-- .../entities/upsert/WarehouseInventory.java | 46 ++++++++++ .../ProductReviewRepository.java} | 14 +-- 27 files changed, 230 insertions(+), 126 deletions(-) rename data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/{H2UpsertEntityRepository.java => upsert/H2ProductReviewRepository.java} (79%) rename data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/{MySqlUpsertEntityRepository.java => upsert/MySqlProductReviewRepository.java} (79%) rename data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/{OracleXEUpsertEntityRepository.java => upsert/OracleXEProductReviewRepository.java} (78%) rename data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/{PostgresUpsertEntityRepository.java => upsert/PostgresProductReviewRepository.java} (78%) rename data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/{MSUpsertEntityRepository.java => upsert/MSProductReviewRepository.java} (79%) rename data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/{H2UpsertEntityRepository.java => upsert/H2ProductReviewRepository.java} (79%) rename data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/{MySqlUpsertEntityRepository.java => upsert/MySqlProductReviewRepository.java} (79%) rename data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/{OracleXEUpsertEntityRepository.java => upsert/OracleXEProductReviewRepository.java} (78%) rename data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/{PostgresUpsertEntityRepository.java => upsert/PostgresProductReviewRepository.java} (78%) rename data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/{MSUpsertEntityRepository.java => upsert/MSProductReviewRepository.java} (79%) create mode 100644 data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/CustomerProfile.java rename data-tck/src/main/java/io/micronaut/data/tck/{entities/UpsertEntity.java => jdbc/entities/upsert/ProductReview.java} (74%) create mode 100644 data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/WarehouseInventory.java rename data-tck/src/main/java/io/micronaut/data/tck/repositories/{UpsertEntityRepository.java => upsert/ProductReviewRepository.java} (63%) 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 index a7786044e00..e72835ea9dc 100644 --- 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 @@ -15,13 +15,14 @@ */ package io.micronaut.data.jdbc.h2 -import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.jdbc.h2.upsert.H2ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class H2UpsertSpec extends AbstractUpsertSpec implements H2TestPropertyProvider { @Override - UpsertEntityRepository getUpsertEntityRepository() { - return context.getBean(H2UpsertEntityRepository) + ProductReviewRepository getProductReviewRepository() { + return context.getBean(H2ProductReviewRepository) } } 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 index 29e308b49ef..22472eee234 100644 --- 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 @@ -15,14 +15,14 @@ */ package io.micronaut.data.jdbc.mariadb -import io.micronaut.data.jdbc.mysql.MySqlUpsertEntityRepository -import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.jdbc.mysql.upsert.MySqlProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class MariaUpsertSpec extends AbstractUpsertSpec implements MariaTestPropertyProvider { @Override - UpsertEntityRepository getUpsertEntityRepository() { - return context.getBean(MySqlUpsertEntityRepository) + ProductReviewRepository getProductReviewRepository() { + return context.getBean(MySqlProductReviewRepository) } } 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 index 84b583abe59..b38f6462dc2 100644 --- 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 @@ -15,13 +15,14 @@ */ package io.micronaut.data.jdbc.mysql -import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.jdbc.mysql.upsert.MySqlProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class MySqlUpsertSpec extends AbstractUpsertSpec implements MySQLTestPropertyProvider { @Override - UpsertEntityRepository getUpsertEntityRepository() { - return context.getBean(MySqlUpsertEntityRepository) + ProductReviewRepository getProductReviewRepository() { + return context.getBean(MySqlProductReviewRepository) } } 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 index a8b4b45ddc6..863acad1ec9 100644 --- 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 @@ -15,13 +15,14 @@ */ package io.micronaut.data.jdbc.oraclexe -import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.jdbc.oraclexe.upsert.OracleXEProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleTestPropertyProvider { @Override - UpsertEntityRepository getUpsertEntityRepository() { - return context.getBean(OracleXEUpsertEntityRepository) + ProductReviewRepository getProductReviewRepository() { + return context.getBean(OracleXEProductReviewRepository) } } 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 index bdaea9019b4..2969abf5cd6 100644 --- 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 @@ -15,13 +15,14 @@ */ package io.micronaut.data.jdbc.postgres -import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.jdbc.postgres.upsert.PostgresProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPropertyProvider { @Override - UpsertEntityRepository getUpsertEntityRepository() { - return context.getBean(PostgresUpsertEntityRepository) + ProductReviewRepository getProductReviewRepository() { + return context.getBean(PostgresProductReviewRepository) } } 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 index 1199191d408..0a1d2fd9519 100644 --- 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 @@ -15,13 +15,14 @@ */ package io.micronaut.data.jdbc.sqlserver -import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.jdbc.sqlserver.upsert.MSProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class SqlServerUpsertSpec extends AbstractUpsertSpec implements MSSQLTestPropertyProvider { @Override - UpsertEntityRepository getUpsertEntityRepository() { - return context.getBean(MSUpsertEntityRepository) + ProductReviewRepository getProductReviewRepository() { + return context.getBean(MSProductReviewRepository) } } diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/H2UpsertEntityRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2ProductReviewRepository.java similarity index 79% rename from data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/H2UpsertEntityRepository.java rename to data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2ProductReviewRepository.java index 5ce1d9963de..1de1ad98f2f 100644 --- a/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/H2UpsertEntityRepository.java +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2ProductReviewRepository.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.data.jdbc.h2; +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.UpsertEntityRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; @JdbcRepository(dialect = Dialect.H2) -public interface H2UpsertEntityRepository extends UpsertEntityRepository { +public interface H2ProductReviewRepository extends ProductReviewRepository { } diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/MySqlUpsertEntityRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlProductReviewRepository.java similarity index 79% rename from data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/MySqlUpsertEntityRepository.java rename to data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlProductReviewRepository.java index b0bd06163df..6ff7a98323b 100644 --- a/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/MySqlUpsertEntityRepository.java +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlProductReviewRepository.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.data.jdbc.mysql; +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.UpsertEntityRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; @JdbcRepository(dialect = Dialect.MYSQL) -public interface MySqlUpsertEntityRepository extends UpsertEntityRepository { +public interface MySqlProductReviewRepository extends ProductReviewRepository { } diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/OracleXEUpsertEntityRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXEProductReviewRepository.java similarity index 78% rename from data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/OracleXEUpsertEntityRepository.java rename to data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXEProductReviewRepository.java index ffdc6520a59..83d93be5599 100644 --- a/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/OracleXEUpsertEntityRepository.java +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXEProductReviewRepository.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.data.jdbc.oraclexe; +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.UpsertEntityRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; @JdbcRepository(dialect = Dialect.ORACLE) -public interface OracleXEUpsertEntityRepository extends UpsertEntityRepository { +public interface OracleXEProductReviewRepository extends ProductReviewRepository { } diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/PostgresUpsertEntityRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresProductReviewRepository.java similarity index 78% rename from data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/PostgresUpsertEntityRepository.java rename to data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresProductReviewRepository.java index 8aeb0b40d28..fbc44f10c3d 100644 --- a/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/PostgresUpsertEntityRepository.java +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresProductReviewRepository.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.data.jdbc.postgres; +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.UpsertEntityRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; @JdbcRepository(dialect = Dialect.POSTGRES) -public interface PostgresUpsertEntityRepository extends UpsertEntityRepository { +public interface PostgresProductReviewRepository extends ProductReviewRepository { } diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/MSUpsertEntityRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSProductReviewRepository.java similarity index 79% rename from data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/MSUpsertEntityRepository.java rename to data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSProductReviewRepository.java index 5b17103bcdc..22d9c629814 100644 --- a/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/MSUpsertEntityRepository.java +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSProductReviewRepository.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.data.jdbc.sqlserver; +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.UpsertEntityRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; @JdbcRepository(dialect = Dialect.SQL_SERVER) -public interface MSUpsertEntityRepository extends UpsertEntityRepository { +public interface MSProductReviewRepository extends ProductReviewRepository { } 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 index a024e48cdd3..b42b7813a0a 100644 --- 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 @@ -15,13 +15,14 @@ */ package io.micronaut.data.r2dbc.h2 -import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.r2dbc.h2.upsert.H2ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class H2UpsertSpec extends AbstractUpsertSpec implements H2TestPropertyProvider { @Override - UpsertEntityRepository getUpsertEntityRepository() { - return context.getBean(H2UpsertEntityRepository) + ProductReviewRepository getProductReviewRepository() { + return context.getBean(H2ProductReviewRepository) } } 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 index 8fa0e5dfc46..8f94f9c79ae 100644 --- 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 @@ -15,14 +15,14 @@ */ package io.micronaut.data.r2dbc.mariadb -import io.micronaut.data.r2dbc.mysql.MySqlUpsertEntityRepository -import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.r2dbc.mysql.upsert.MySqlProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class MariaDbUpsertSpec extends AbstractUpsertSpec implements MariaDbTestPropertyProvider { @Override - UpsertEntityRepository getUpsertEntityRepository() { - return context.getBean(MySqlUpsertEntityRepository) + ProductReviewRepository getProductReviewRepository() { + return context.getBean(MySqlProductReviewRepository) } } 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 index 8b886f66116..e3f9ecea216 100644 --- 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 @@ -15,13 +15,14 @@ */ package io.micronaut.data.r2dbc.mysql -import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.r2dbc.mysql.upsert.MySqlProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class MySqlUpsertSpec extends AbstractUpsertSpec implements MySqlTestPropertyProvider { @Override - UpsertEntityRepository getUpsertEntityRepository() { - return context.getBean(MySqlUpsertEntityRepository) + ProductReviewRepository getProductReviewRepository() { + return context.getBean(MySqlProductReviewRepository) } } 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 index 581b5b9484d..2d937dddcc8 100644 --- 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 @@ -15,13 +15,14 @@ */ package io.micronaut.data.r2dbc.oraclexe -import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.r2dbc.oraclexe.upsert.OracleXEProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleXETestPropertyProvider { @Override - UpsertEntityRepository getUpsertEntityRepository() { - return context.getBean(OracleXEUpsertEntityRepository) + ProductReviewRepository getProductReviewRepository() { + return context.getBean(OracleXEProductReviewRepository) } } 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 index 44957967288..11534427142 100644 --- 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 @@ -15,13 +15,14 @@ */ package io.micronaut.data.r2dbc.postgres -import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.r2dbc.postgres.upsert.PostgresProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPropertyProvider { @Override - UpsertEntityRepository getUpsertEntityRepository() { - return context.getBean(PostgresUpsertEntityRepository) + ProductReviewRepository getProductReviewRepository() { + return context.getBean(PostgresProductReviewRepository) } } 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 index 964d8997c6b..f49b9e91b92 100644 --- 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 @@ -15,13 +15,14 @@ */ package io.micronaut.data.r2dbc.sqlserver -import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.r2dbc.sqlserver.upsert.MSProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class SqlServerUpsertSpec extends AbstractUpsertSpec implements SqlServerTestPropertyProvider { @Override - UpsertEntityRepository getUpsertEntityRepository() { - return context.getBean(MSUpsertEntityRepository) + ProductReviewRepository getProductReviewRepository() { + return context.getBean(MSProductReviewRepository) } } diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/H2UpsertEntityRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2ProductReviewRepository.java similarity index 79% rename from data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/H2UpsertEntityRepository.java rename to data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2ProductReviewRepository.java index 83a5fb80d6e..25f2b496e2f 100644 --- a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/H2UpsertEntityRepository.java +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2ProductReviewRepository.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.data.r2dbc.h2; +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.UpsertEntityRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; @R2dbcRepository(dialect = Dialect.H2) -public interface H2UpsertEntityRepository extends UpsertEntityRepository { +public interface H2ProductReviewRepository extends ProductReviewRepository { } diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlUpsertEntityRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlProductReviewRepository.java similarity index 79% rename from data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlUpsertEntityRepository.java rename to data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlProductReviewRepository.java index fa9dff04d9c..26c2ad1bfe0 100644 --- a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlUpsertEntityRepository.java +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlProductReviewRepository.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.data.r2dbc.mysql; +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.UpsertEntityRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; @R2dbcRepository(dialect = Dialect.MYSQL) -public interface MySqlUpsertEntityRepository extends UpsertEntityRepository { +public interface MySqlProductReviewRepository extends ProductReviewRepository { } diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/OracleXEUpsertEntityRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXEProductReviewRepository.java similarity index 78% rename from data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/OracleXEUpsertEntityRepository.java rename to data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXEProductReviewRepository.java index a8fa93daa56..169e887c3f7 100644 --- a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/OracleXEUpsertEntityRepository.java +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXEProductReviewRepository.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.data.r2dbc.oraclexe; +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.UpsertEntityRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; @R2dbcRepository(dialect = Dialect.ORACLE) -public interface OracleXEUpsertEntityRepository extends UpsertEntityRepository { +public interface OracleXEProductReviewRepository extends ProductReviewRepository { } diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/PostgresUpsertEntityRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresProductReviewRepository.java similarity index 78% rename from data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/PostgresUpsertEntityRepository.java rename to data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresProductReviewRepository.java index d716a227d82..af1caf65419 100644 --- a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/PostgresUpsertEntityRepository.java +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresProductReviewRepository.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.data.r2dbc.postgres; +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.UpsertEntityRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; @R2dbcRepository(dialect = Dialect.POSTGRES) -public interface PostgresUpsertEntityRepository extends UpsertEntityRepository { +public interface PostgresProductReviewRepository extends ProductReviewRepository { } diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/MSUpsertEntityRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSProductReviewRepository.java similarity index 79% rename from data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/MSUpsertEntityRepository.java rename to data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSProductReviewRepository.java index 159cfaa087d..0a283c787e2 100644 --- a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/MSUpsertEntityRepository.java +++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSProductReviewRepository.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.data.r2dbc.sqlserver; +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.UpsertEntityRepository; +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository; @R2dbcRepository(dialect = Dialect.SQL_SERVER) -public interface MSUpsertEntityRepository extends UpsertEntityRepository { +public interface MSProductReviewRepository extends ProductReviewRepository { } 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 index 04eeba2291d..647d9276a9b 100644 --- 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 @@ -16,15 +16,15 @@ package io.micronaut.data.tck.tests import io.micronaut.context.ApplicationContext -import io.micronaut.data.tck.entities.UpsertEntity -import io.micronaut.data.tck.repositories.UpsertEntityRepository +import io.micronaut.data.tck.jdbc.entities.upsert.ProductReview +import io.micronaut.data.tck.repositories.upsert.ProductReviewRepository import spock.lang.AutoCleanup import spock.lang.Shared import spock.lang.Specification abstract class AbstractUpsertSpec extends Specification { - abstract UpsertEntityRepository getUpsertEntityRepository() + abstract ProductReviewRepository getProductReviewRepository() abstract Map getProperties() @@ -37,102 +37,102 @@ abstract class AbstractUpsertSpec extends Specification { } void setup() { - upsertEntityRepository.deleteAll() + productReviewRepository.deleteAll() } void cleanup() { - upsertEntityRepository.deleteAll() + productReviewRepository.deleteAll() } void "upsert inserts and updates assigned ID entity"() { when: - UpsertEntity inserted = upsertEntityRepository.upsert(new UpsertEntity(1L, "First", "Initial value")) + ProductReview inserted = productReviewRepository.upsert(new ProductReview(1L, "First", "Initial value")) then: - inserted == new UpsertEntity(1L, "First", "Initial value") - upsertEntityRepository.findById(1L).get() == inserted + inserted == new ProductReview(1L, "First", "Initial value") + productReviewRepository.findById(1L).get() == inserted when: - UpsertEntity updated = upsertEntityRepository.upsert(new UpsertEntity(1L, "Second", "Updated value")) + ProductReview updated = productReviewRepository.upsert(new ProductReview(1L, "Second", "Updated value")) then: - updated == new UpsertEntity(1L, "Second", "Updated value") - upsertEntityRepository.findById(1L).get() == updated + updated == new ProductReview(1L, "Second", "Updated value") + productReviewRepository.findById(1L).get() == updated } void "upsertAll inserts and updates assigned ID entities"() { when: - List inserted = upsertEntityRepository.upsertAll([ - new UpsertEntity(2L, "Batch first", "Initial first"), - new UpsertEntity(3L, "Batch second", "Initial second") + List inserted = productReviewRepository.upsertAll([ + new ProductReview(2L, "Batch first", "Initial first"), + new ProductReview(3L, "Batch second", "Initial second") ]).toList() then: inserted as Set == [ - new UpsertEntity(2L, "Batch first", "Initial first"), - new UpsertEntity(3L, "Batch second", "Initial second") + new ProductReview(2L, "Batch first", "Initial first"), + new ProductReview(3L, "Batch second", "Initial second") ] as Set - upsertEntityRepository.findById(2L).get() == new UpsertEntity(2L, "Batch first", "Initial first") - upsertEntityRepository.findById(3L).get() == new UpsertEntity(3L, "Batch second", "Initial second") + productReviewRepository.findById(2L).get() == new ProductReview(2L, "Batch first", "Initial first") + productReviewRepository.findById(3L).get() == new ProductReview(3L, "Batch second", "Initial second") when: - List updated = upsertEntityRepository.upsertAll([ - new UpsertEntity(2L, "Batch first", "Updated first"), - new UpsertEntity(3L, "Batch second", "Updated second") + List updated = productReviewRepository.upsertAll([ + new ProductReview(2L, "Batch first", "Updated first"), + new ProductReview(3L, "Batch second", "Updated second") ]).toList() then: updated as Set == [ - new UpsertEntity(2L, "Batch first", "Updated first"), - new UpsertEntity(3L, "Batch second", "Updated second") + new ProductReview(2L, "Batch first", "Updated first"), + new ProductReview(3L, "Batch second", "Updated second") ] as Set - upsertEntityRepository.findById(2L).get() == new UpsertEntity(2L, "Batch first", "Updated first") - upsertEntityRepository.findById(3L).get() == new UpsertEntity(3L, "Batch second", "Updated second") + productReviewRepository.findById(2L).get() == new ProductReview(2L, "Batch first", "Updated first") + productReviewRepository.findById(3L).get() == new ProductReview(3L, "Batch second", "Updated second") } void "upsert annotation inserts and updates assigned ID entity"() { when: - UpsertEntity inserted = upsertEntityRepository.put(new UpsertEntity(4L, "Annotated first", "Initial value")) + ProductReview inserted = productReviewRepository.put(new ProductReview(4L, "Annotated first", "Initial value")) then: - inserted == new UpsertEntity(4L, "Annotated first", "Initial value") - upsertEntityRepository.findById(4L).get() == inserted + inserted == new ProductReview(4L, "Annotated first", "Initial value") + productReviewRepository.findById(4L).get() == inserted when: - UpsertEntity updated = upsertEntityRepository.put(new UpsertEntity(4L, "Annotated second", "Updated value")) + ProductReview updated = productReviewRepository.put(new ProductReview(4L, "Annotated second", "Updated value")) then: - updated == new UpsertEntity(4L, "Annotated second", "Updated value") - upsertEntityRepository.findById(4L).get() == updated + updated == new ProductReview(4L, "Annotated second", "Updated value") + productReviewRepository.findById(4L).get() == updated } void "upsert annotation inserts and updates assigned ID entities"() { when: - List inserted = upsertEntityRepository.putAll([ - new UpsertEntity(5L, "Annotated batch first", "Initial first"), - new UpsertEntity(6L, "Annotated batch second", "Initial second") + List inserted = productReviewRepository.putAll([ + new ProductReview(5L, "Annotated batch first", "Initial first"), + new ProductReview(6L, "Annotated batch second", "Initial second") ]).toList() then: inserted as Set == [ - new UpsertEntity(5L, "Annotated batch first", "Initial first"), - new UpsertEntity(6L, "Annotated batch second", "Initial second") + new ProductReview(5L, "Annotated batch first", "Initial first"), + new ProductReview(6L, "Annotated batch second", "Initial second") ] as Set - upsertEntityRepository.findById(5L).get() == new UpsertEntity(5L, "Annotated batch first", "Initial first") - upsertEntityRepository.findById(6L).get() == new UpsertEntity(6L, "Annotated batch second", "Initial second") + productReviewRepository.findById(5L).get() == new ProductReview(5L, "Annotated batch first", "Initial first") + productReviewRepository.findById(6L).get() == new ProductReview(6L, "Annotated batch second", "Initial second") when: - List updated = upsertEntityRepository.putAll([ - new UpsertEntity(5L, "Annotated batch first", "Updated first"), - new UpsertEntity(6L, "Annotated batch second", "Updated second") + List updated = productReviewRepository.putAll([ + new ProductReview(5L, "Annotated batch first", "Updated first"), + new ProductReview(6L, "Annotated batch second", "Updated second") ]).toList() then: updated as Set == [ - new UpsertEntity(5L, "Annotated batch first", "Updated first"), - new UpsertEntity(6L, "Annotated batch second", "Updated second") + new ProductReview(5L, "Annotated batch first", "Updated first"), + new ProductReview(6L, "Annotated batch second", "Updated second") ] as Set - upsertEntityRepository.findById(5L).get() == new UpsertEntity(5L, "Annotated batch first", "Updated first") - upsertEntityRepository.findById(6L).get() == new UpsertEntity(6L, "Annotated batch second", "Updated second") + productReviewRepository.findById(5L).get() == new ProductReview(5L, "Annotated batch first", "Updated first") + productReviewRepository.findById(6L).get() == new ProductReview(6L, "Annotated batch second", "Updated second") } } 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..9fa06c8470d --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/CustomerProfile.java @@ -0,0 +1,42 @@ +/* + * 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 record CustomerProfile( + @Id + @GeneratedValue + @Nullable + Long id, + + @NotBlank + String email, + + @NotBlank + String displayName) { + + public CustomerProfile(String email, String displayName) { + this(null, email, displayName); + } +} diff --git a/data-tck/src/main/java/io/micronaut/data/tck/entities/UpsertEntity.java b/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/ProductReview.java similarity index 74% rename from data-tck/src/main/java/io/micronaut/data/tck/entities/UpsertEntity.java rename to data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/ProductReview.java index 1eedf566e8c..20625fc4d2b 100644 --- a/data-tck/src/main/java/io/micronaut/data/tck/entities/UpsertEntity.java +++ b/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/ProductReview.java @@ -13,14 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.data.tck.entities; +package io.micronaut.data.tck.jdbc.entities.upsert; import io.micronaut.data.annotation.MappedEntity; import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; -@MappedEntity("upsert_entity") -public record UpsertEntity( - @Id Long id, - String name, - String description) { +@MappedEntity +public record ProductReview( + @Id + Long id, + + @NotBlank + String title, + + @NotBlank + String 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..731626dff9b --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/WarehouseInventory.java @@ -0,0 +1,46 @@ +/* + * 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 record WarehouseInventory( + @Id + @GeneratedValue + @Nullable + Long id, + + @NotBlank + String sku, + + @NotBlank + String warehouse, + + @NotNull + Integer quantity) { + + public WarehouseInventory(String sku, String warehouse, Integer quantity) { + this(null, sku, warehouse, quantity); + } +} diff --git a/data-tck/src/main/java/io/micronaut/data/tck/repositories/UpsertEntityRepository.java b/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/ProductReviewRepository.java similarity index 63% rename from data-tck/src/main/java/io/micronaut/data/tck/repositories/UpsertEntityRepository.java rename to data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/ProductReviewRepository.java index 9c07f32c22c..be238610ce9 100644 --- a/data-tck/src/main/java/io/micronaut/data/tck/repositories/UpsertEntityRepository.java +++ b/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/ProductReviewRepository.java @@ -13,23 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.data.tck.repositories; +package io.micronaut.data.tck.repositories.upsert; import io.micronaut.data.annotation.Upsert; import io.micronaut.data.repository.CrudRepository; -import io.micronaut.data.tck.entities.UpsertEntity; +import io.micronaut.data.tck.jdbc.entities.upsert.ProductReview; import java.util.List; -public interface UpsertEntityRepository extends CrudRepository { +public interface ProductReviewRepository extends CrudRepository { - UpsertEntity upsert(UpsertEntity entity); + ProductReview upsert(ProductReview entity); - List upsertAll(Iterable entities); + List upsertAll(Iterable entities); @Upsert - UpsertEntity put(UpsertEntity entity); + ProductReview put(ProductReview entity); @Upsert - List putAll(Iterable entities); + List putAll(Iterable entities); } From 175c6afcde1d90b475decf1efe0292a8f253e798 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Mon, 15 Jun 2026 10:55:54 +0200 Subject: [PATCH 11/57] Upsert implementation - wip --- .../data/tck/tests/AbstractUpsertSpec.groovy | 332 +++++++++++++++--- 1 file changed, 278 insertions(+), 54 deletions(-) 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 index 647d9276a9b..54530ac67a7 100644 --- 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 @@ -16,8 +16,12 @@ 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.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.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository import spock.lang.AutoCleanup import spock.lang.Shared import spock.lang.Specification @@ -26,6 +30,10 @@ abstract class AbstractUpsertSpec extends Specification { abstract ProductReviewRepository getProductReviewRepository() + abstract CustomerProfileRepository getCustomerProfileRepository() + + abstract WarehouseInventoryRepository getWarehouseInventoryRepository() + abstract Map getProperties() @AutoCleanup @@ -37,102 +45,318 @@ abstract class AbstractUpsertSpec extends Specification { } void setup() { + warehouseInventoryRepository.deleteAll() + customerProfileRepository.deleteAll() productReviewRepository.deleteAll() } void cleanup() { + warehouseInventoryRepository.deleteAll() + customerProfileRepository.deleteAll() productReviewRepository.deleteAll() } - void "upsert inserts and updates assigned ID entity"() { + void "upsert inserts and updates product review by assigned ID"() { + given: + ProductReview pr1 = new ProductReview(1L, "title new", "content new") + ProductReview pr2 = new ProductReview(1L, "title modified", "content modified") + + when: + ProductReview inserted = productReviewRepository.upsert(pr1) + + then: + assertProductReview(pr1, inserted) + when: - ProductReview inserted = productReviewRepository.upsert(new ProductReview(1L, "First", "Initial value")) + ProductReview found = productReviewRepository.findById(1L).get() then: - inserted == new ProductReview(1L, "First", "Initial value") - productReviewRepository.findById(1L).get() == inserted + assertProductReview(pr1, found) when: - ProductReview updated = productReviewRepository.upsert(new ProductReview(1L, "Second", "Updated value")) + ProductReview updated = productReviewRepository.upsert(pr2) then: - updated == new ProductReview(1L, "Second", "Updated value") - productReviewRepository.findById(1L).get() == updated + assertProductReview(pr2, updated) + + when: + found = productReviewRepository.findById(1L).get() + + then: + assertProductReview(pr2, found) } - void "upsertAll inserts and updates assigned ID entities"() { + void "upsertAll inserts and updates product reviews by assigned ID"() { + given: + ProductReview pr1 = new ProductReview(2L, "title 1", "content 1") + ProductReview pr2 = new ProductReview(3L, "title 2", "content 2") + ProductReview pr3 = new ProductReview(2L, "title 1 modified", "content 1 modified") + ProductReview pr4 = new ProductReview(3L, "title 2 modified", "content 2 modified") + when: - List inserted = productReviewRepository.upsertAll([ - new ProductReview(2L, "Batch first", "Initial first"), - new ProductReview(3L, "Batch second", "Initial second") - ]).toList() + List insertedList = productReviewRepository.upsertAll([pr1, pr2]).toList() then: - inserted as Set == [ - new ProductReview(2L, "Batch first", "Initial first"), - new ProductReview(3L, "Batch second", "Initial second") - ] as Set - productReviewRepository.findById(2L).get() == new ProductReview(2L, "Batch first", "Initial first") - productReviewRepository.findById(3L).get() == new ProductReview(3L, "Batch second", "Initial second") + assertProductReview(pr1, insertedList.get(0)) + assertProductReview(pr2, insertedList.get(1)) when: - List updated = productReviewRepository.upsertAll([ - new ProductReview(2L, "Batch first", "Updated first"), - new ProductReview(3L, "Batch second", "Updated second") - ]).toList() + ProductReview found1 = productReviewRepository.findById(2L).get() + ProductReview found2 = productReviewRepository.findById(3L).get() then: - updated as Set == [ - new ProductReview(2L, "Batch first", "Updated first"), - new ProductReview(3L, "Batch second", "Updated second") - ] as Set - productReviewRepository.findById(2L).get() == new ProductReview(2L, "Batch first", "Updated first") - productReviewRepository.findById(3L).get() == new ProductReview(3L, "Batch second", "Updated second") + assertProductReview(pr1, found1) + assertProductReview(pr2, found2) + + when: + List updatedList = productReviewRepository.upsertAll([pr3, pr4]).toList() + + then: + assertProductReview(pr3, updatedList.get(0)) + assertProductReview(pr4, updatedList.get(1)) + + when: + ProductReview found3 = productReviewRepository.findById(2L).get() + ProductReview found4 = productReviewRepository.findById(3L).get() + + then: + assertProductReview(pr3, found3) + assertProductReview(pr4, found4) } - void "upsert annotation inserts and updates assigned ID entity"() { + void "upsert annotation inserts and updates product review by assigned ID"() { + given: + ProductReview pr1 = new ProductReview(4L, "title new", "content new") + ProductReview pr2 = new ProductReview(4L, "title modified", "content modified") + when: - ProductReview inserted = productReviewRepository.put(new ProductReview(4L, "Annotated first", "Initial value")) + ProductReview inserted = productReviewRepository.put(pr1) then: - inserted == new ProductReview(4L, "Annotated first", "Initial value") - productReviewRepository.findById(4L).get() == inserted + assertProductReview(pr1, inserted) when: - ProductReview updated = productReviewRepository.put(new ProductReview(4L, "Annotated second", "Updated value")) + ProductReview found = productReviewRepository.findById(4L).get() then: - updated == new ProductReview(4L, "Annotated second", "Updated value") - productReviewRepository.findById(4L).get() == updated + assertProductReview(pr1, found) + + when: + ProductReview updated = productReviewRepository.put(pr2) + + then: + assertProductReview(pr2, updated) + + when: + found = productReviewRepository.findById(4L).get() + + then: + assertProductReview(pr2, found) } - void "upsert annotation inserts and updates assigned ID entities"() { + void "upsert annotation inserts and updates product reviews by assigned ID"() { + given: + ProductReview pr1 = new ProductReview(2L, "title 1", "content 1") + ProductReview pr2 = new ProductReview(3L, "title 2", "content 2") + ProductReview pr3 = new ProductReview(2L, "title 1 modified", "content 1 modified") + ProductReview pr4 = new ProductReview(3L, "title 2 modified", "content 2 modified") + when: - List inserted = productReviewRepository.putAll([ - new ProductReview(5L, "Annotated batch first", "Initial first"), - new ProductReview(6L, "Annotated batch second", "Initial second") + List insertedList = productReviewRepository.putAll([pr1, pr2]).toList() + + then: + assertProductReview(pr1, insertedList.get(0)) + assertProductReview(pr2, insertedList.get(1)) + + when: + ProductReview found1 = productReviewRepository.findById(2L).get() + ProductReview found2 = productReviewRepository.findById(3L).get() + + then: + assertProductReview(pr1, found1) + assertProductReview(pr2, found2) + + when: + List updatedList = productReviewRepository.putAll([pr3, pr4]).toList() + + then: + assertProductReview(pr3, updatedList.get(0)) + assertProductReview(pr4, updatedList.get(1)) + + when: + ProductReview found3 = productReviewRepository.findById(2L).get() + ProductReview found4 = productReviewRepository.findById(3L).get() + + then: + assertProductReview(pr3, found3) + assertProductReview(pr4, found4) + } + + void "upsert annotation inserts and updates customer profile by email conflict property"() { + given: + CustomerProfile cp1 = new CustomerProfile("test@example.com", "test") + CustomerProfile cp2 = new CustomerProfile("test@example.com", "test modified") + + when: + CustomerProfile inserted = customerProfileRepository.upsert(cp1) + + then: + assertCustomerProfile(inserted, cp1) + + when: + List found = customerProfileRepository.findAll().toList() + + then: + found.size() == 1 + found[0].id() != null + assertCustomerProfile(found[0], cp1) + + when: + Long profileId = found[0].id() + CustomerProfile updated = customerProfileRepository.upsert(cp2) + + then: + updated.id() == profileId + assertCustomerProfile(updated, cp2) + + when: + found = customerProfileRepository.findAll().toList() + + then: + found.size() == 1 + found[0].id() == profileId + assertCustomerProfile(found[0], cp2) + } + + void "upsertAll annotation inserts and updates customer profiles by email conflict property"() { + given: + CustomerProfile cp1 = new CustomerProfile("test1@example.com", "test 1") + CustomerProfile cp2 = new CustomerProfile("test2@example.com", "test 2") + CustomerProfile cp3 = new CustomerProfile("test1@example.com", "test 1 modified") + CustomerProfile cp4 = new CustomerProfile("test2@example.com", "test 2 modified") + + when: + List inserted = customerProfileRepository.upsertAll([cp1, cp2]).toList() + + then: + inserted.size() == 2 + assertCustomerProfile(inserted.get(0), cp1) + assertCustomerProfile(inserted.get(1), cp2) + + when: + List found = customerProfileRepository.findAll().toList() + + then: + found.size() == 2 + found.get(0).id() != null + found.get(1).id() != null + assertCustomerProfile(found.get(0), cp1) + assertCustomerProfile(found.get(1), cp2) + + when: + Long id1 = found.get(0).id() + Long id2 = found.get(1).id() + List updated = customerProfileRepository.upsertAll([cp3, cp4]).toList() + + then: + updated.size() == 2 + updated.get(0).id() == id1 + updated.get(1).id() == id2 + assertCustomerProfile(updated.get(0), cp3) + assertCustomerProfile(updated.get(1), cp4) + + when: + found = customerProfileRepository.findAll().toList() + + then: + found.size() == 2 + found.get(0).id() == id1 + found.get(1).id() == id2 + assertCustomerProfile(found.get(0), cp3) + assertCustomerProfile(found.get(1), cp4) + } + + void "upsert annotation inserts and updates warehouse inventory by sku and warehouse conflict properties"() { + given: + WarehouseInventory wh1 = new WarehouseInventory("SKU-100", "Berlin", 12) + + when: + WarehouseInventory inserted = warehouseInventoryRepository.upsert(wh1) + + + + List inventories = warehouseInventoryRepository.findAll().toList() + + then: + inserted.sku() == "SKU-100" + inserted.warehouse() == "Berlin" + inserted.quantity() == 12 + inventories.size() == 1 + inventories[0].id() != null + inventories[0].sku() == "SKU-100" + inventories[0].warehouse() == "Berlin" + inventories[0].quantity() == 12 + + when: + Long inventoryId = inventories[0].id() + WarehouseInventory updated = warehouseInventoryRepository.upsert(new WarehouseInventory("SKU-100", "Berlin", 18)) + inventories = warehouseInventoryRepository.findAll().toList() + + then: + updated.sku() == "SKU-100" + updated.warehouse() == "Berlin" + updated.quantity() == 18 + inventories.size() == 1 + inventories[0].id() == inventoryId + inventories[0].sku() == "SKU-100" + inventories[0].warehouse() == "Berlin" + inventories[0].quantity() == 18 + } + + void "upsertAll annotation inserts and updates warehouse inventory by sku and warehouse conflict properties"() { + when: + List inserted = warehouseInventoryRepository.upsertAll([ + new WarehouseInventory("SKU-200", "Berlin", 5), + new WarehouseInventory("SKU-200", "Paris", 8) ]).toList() + List inventories = warehouseInventoryRepository.findAll().toList() then: - inserted as Set == [ - new ProductReview(5L, "Annotated batch first", "Initial first"), - new ProductReview(6L, "Annotated batch second", "Initial second") - ] as Set - productReviewRepository.findById(5L).get() == new ProductReview(5L, "Annotated batch first", "Initial first") - productReviewRepository.findById(6L).get() == new ProductReview(6L, "Annotated batch second", "Initial second") + inserted.collect { it.sku() } as Set == ["SKU-200"] as Set + inserted.collect { it.warehouse() } as Set == ["Berlin", "Paris"] as Set + inserted.collect { it.quantity() } as Set == [5, 8] as Set + inventories.size() == 2 + inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Berlin" }.quantity() == 5 + inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Paris" }.quantity() == 8 when: - List updated = productReviewRepository.putAll([ - new ProductReview(5L, "Annotated batch first", "Updated first"), - new ProductReview(6L, "Annotated batch second", "Updated second") + Long berlinId = inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Berlin" }.id() + Long parisId = inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Paris" }.id() + List updated = warehouseInventoryRepository.upsertAll([ + new WarehouseInventory("SKU-200", "Berlin", 7), + new WarehouseInventory("SKU-200", "Paris", 11) ]).toList() + inventories = warehouseInventoryRepository.findAll().toList() then: - updated as Set == [ - new ProductReview(5L, "Annotated batch first", "Updated first"), - new ProductReview(6L, "Annotated batch second", "Updated second") - ] as Set - productReviewRepository.findById(5L).get() == new ProductReview(5L, "Annotated batch first", "Updated first") - productReviewRepository.findById(6L).get() == new ProductReview(6L, "Annotated batch second", "Updated second") + updated.collect { it.sku() } as Set == ["SKU-200"] as Set + updated.collect { it.warehouse() } as Set == ["Berlin", "Paris"] as Set + updated.collect { it.quantity() } as Set == [7, 11] as Set + inventories.size() == 2 + inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Berlin" }.id() == berlinId + inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Berlin" }.quantity() == 7 + inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Paris" }.id() == parisId + inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Paris" }.quantity() == 11 + } + + 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() } } From b7c406cd31989075973edd69125a69f74fdb9ab5 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Mon, 15 Jun 2026 11:00:58 +0200 Subject: [PATCH 12/57] Upsert implementation - wip --- .../data/jdbc/h2/H2UpsertSpec.groovy | 14 +++++++++ .../data/jdbc/mariadb/MariaUpsertSpec.groovy | 14 +++++++++ .../data/jdbc/mysql/MySqlUpsertSpec.groovy | 14 +++++++++ .../jdbc/oraclexe/OracleXEUpsertSpec.groovy | 14 +++++++++ .../upsert/H2CustomerProfileRepository.java | 24 ++++++++++++++ .../H2WarehouseInventoryRepository.java | 24 ++++++++++++++ .../MySqlCustomerProfileRepository.java | 24 ++++++++++++++ .../MySqlWarehouseInventoryRepository.java | 24 ++++++++++++++ .../OracleXECustomerProfileRepository.java | 24 ++++++++++++++ .../OracleXEWarehouseInventoryRepository.java | 24 ++++++++++++++ .../upsert/MSCustomerProfileRepository.java | 24 ++++++++++++++ .../MSWarehouseInventoryRepository.java | 24 ++++++++++++++ .../data/r2dbc/h2/H2UpsertSpec.groovy | 14 +++++++++ .../r2dbc/mariadb/MariaDbUpsertSpec.groovy | 14 +++++++++ .../data/r2dbc/mysql/MySqlUpsertSpec.groovy | 14 +++++++++ .../r2dbc/oraclexe/OracleXEUpsertSpec.groovy | 14 +++++++++ .../upsert/H2CustomerProfileRepository.java | 24 ++++++++++++++ .../H2WarehouseInventoryRepository.java | 24 ++++++++++++++ .../MySqlCustomerProfileRepository.java | 24 ++++++++++++++ .../MySqlWarehouseInventoryRepository.java | 24 ++++++++++++++ .../OracleXECustomerProfileRepository.java | 24 ++++++++++++++ .../OracleXEWarehouseInventoryRepository.java | 24 ++++++++++++++ .../upsert/MSCustomerProfileRepository.java | 24 ++++++++++++++ .../MSWarehouseInventoryRepository.java | 24 ++++++++++++++ .../jdbc/entities/upsert/CustomerProfile.java | 2 -- .../upsert/CustomerProfileRepository.java | 31 +++++++++++++++++++ 26 files changed, 527 insertions(+), 2 deletions(-) create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2CustomerProfileRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2WarehouseInventoryRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlCustomerProfileRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlWarehouseInventoryRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXECustomerProfileRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXEWarehouseInventoryRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSCustomerProfileRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSWarehouseInventoryRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2CustomerProfileRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2WarehouseInventoryRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlCustomerProfileRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlWarehouseInventoryRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXECustomerProfileRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXEWarehouseInventoryRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSCustomerProfileRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSWarehouseInventoryRepository.java create mode 100644 data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/CustomerProfileRepository.java 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 index e72835ea9dc..2ebf361930a 100644 --- 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 @@ -15,8 +15,12 @@ */ package io.micronaut.data.jdbc.h2 +import io.micronaut.data.jdbc.h2.upsert.H2CustomerProfileRepository 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.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class H2UpsertSpec extends AbstractUpsertSpec implements H2TestPropertyProvider { @@ -25,4 +29,14 @@ class H2UpsertSpec extends AbstractUpsertSpec implements H2TestPropertyProvider ProductReviewRepository getProductReviewRepository() { return context.getBean(H2ProductReviewRepository) } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(H2CustomerProfileRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(H2WarehouseInventoryRepository) + } } 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 index 22472eee234..21ee071f73c 100644 --- 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 @@ -15,8 +15,12 @@ */ package io.micronaut.data.jdbc.mariadb +import io.micronaut.data.jdbc.mysql.upsert.MySqlCustomerProfileRepository 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.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class MariaUpsertSpec extends AbstractUpsertSpec implements MariaTestPropertyProvider { @@ -25,4 +29,14 @@ class MariaUpsertSpec extends AbstractUpsertSpec implements MariaTestPropertyPro ProductReviewRepository getProductReviewRepository() { return context.getBean(MySqlProductReviewRepository) } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(MySqlCustomerProfileRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(MySqlWarehouseInventoryRepository) + } } 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 index b38f6462dc2..51a61560192 100644 --- 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 @@ -15,8 +15,12 @@ */ package io.micronaut.data.jdbc.mysql +import io.micronaut.data.jdbc.mysql.upsert.MySqlCustomerProfileRepository 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.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class MySqlUpsertSpec extends AbstractUpsertSpec implements MySQLTestPropertyProvider { @@ -25,4 +29,14 @@ class MySqlUpsertSpec extends AbstractUpsertSpec implements MySQLTestPropertyPro ProductReviewRepository getProductReviewRepository() { return context.getBean(MySqlProductReviewRepository) } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(MySqlCustomerProfileRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(MySqlWarehouseInventoryRepository) + } } 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 index 863acad1ec9..159726a5be0 100644 --- 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 @@ -15,8 +15,12 @@ */ package io.micronaut.data.jdbc.oraclexe +import io.micronaut.data.jdbc.oraclexe.upsert.OracleXECustomerProfileRepository 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.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleTestPropertyProvider { @@ -25,4 +29,14 @@ class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleTestPropert ProductReviewRepository getProductReviewRepository() { return context.getBean(OracleXEProductReviewRepository) } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(OracleXECustomerProfileRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(OracleXEWarehouseInventoryRepository) + } } 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/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/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/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/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/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/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-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2UpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2UpsertSpec.groovy index b42b7813a0a..55f86ba4f91 100644 --- 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 @@ -15,8 +15,12 @@ */ package io.micronaut.data.r2dbc.h2 +import io.micronaut.data.r2dbc.h2.upsert.H2CustomerProfileRepository 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.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class H2UpsertSpec extends AbstractUpsertSpec implements H2TestPropertyProvider { @@ -25,4 +29,14 @@ class H2UpsertSpec extends AbstractUpsertSpec implements H2TestPropertyProvider ProductReviewRepository getProductReviewRepository() { return context.getBean(H2ProductReviewRepository) } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(H2CustomerProfileRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(H2WarehouseInventoryRepository) + } } 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 index 8f94f9c79ae..94a1b73e63c 100644 --- 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 @@ -15,8 +15,12 @@ */ package io.micronaut.data.r2dbc.mariadb +import io.micronaut.data.r2dbc.mysql.upsert.MySqlCustomerProfileRepository 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.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class MariaDbUpsertSpec extends AbstractUpsertSpec implements MariaDbTestPropertyProvider { @@ -25,4 +29,14 @@ class MariaDbUpsertSpec extends AbstractUpsertSpec implements MariaDbTestPropert ProductReviewRepository getProductReviewRepository() { return context.getBean(MySqlProductReviewRepository) } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(MySqlCustomerProfileRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(MySqlWarehouseInventoryRepository) + } } 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 index e3f9ecea216..a11077fc995 100644 --- 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 @@ -15,8 +15,12 @@ */ package io.micronaut.data.r2dbc.mysql +import io.micronaut.data.r2dbc.mysql.upsert.MySqlCustomerProfileRepository 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.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class MySqlUpsertSpec extends AbstractUpsertSpec implements MySqlTestPropertyProvider { @@ -25,4 +29,14 @@ class MySqlUpsertSpec extends AbstractUpsertSpec implements MySqlTestPropertyPro ProductReviewRepository getProductReviewRepository() { return context.getBean(MySqlProductReviewRepository) } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(MySqlCustomerProfileRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(MySqlWarehouseInventoryRepository) + } } 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 index 2d937dddcc8..e14d917eb44 100644 --- 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 @@ -15,8 +15,12 @@ */ package io.micronaut.data.r2dbc.oraclexe +import io.micronaut.data.r2dbc.oraclexe.upsert.OracleXECustomerProfileRepository 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.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleXETestPropertyProvider { @@ -25,4 +29,14 @@ class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleXETestPrope ProductReviewRepository getProductReviewRepository() { return context.getBean(OracleXEProductReviewRepository) } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(OracleXECustomerProfileRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(OracleXEWarehouseInventoryRepository) + } } 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/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/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/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/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/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/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-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 index 9fa06c8470d..5e8f2b0cb7e 100644 --- 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 @@ -20,14 +20,12 @@ 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 record CustomerProfile( @Id @GeneratedValue - @Nullable Long id, @NotBlank 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..9369fa3af4f --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/CustomerProfileRepository.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.CustomerProfile; + +import java.util.List; + +public interface CustomerProfileRepository extends CrudRepository { + + @Upsert(conflictProperties = "email") + CustomerProfile upsert(CustomerProfile customerProfile); + + @Upsert(conflictProperties = "email") + List upsertAll(Iterable customerProfiles); +} From e0a31641904a3583747d38e6f1649ba41e147fbd Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Mon, 15 Jun 2026 11:02:48 +0200 Subject: [PATCH 13/57] Upsert implementation - wip --- .../jdbc/postgres/PostgresUpsertSpec.groovy | 14 +++++++++ .../jdbc/sqlserver/SqlServerUpsertSpec.groovy | 14 +++++++++ .../PostgresCustomerProfileRepository.java | 24 ++++++++++++++ .../PostgresWarehouseInventoryRepository.java | 24 ++++++++++++++ .../r2dbc/postgres/PostgresUpsertSpec.groovy | 14 +++++++++ .../sqlserver/SqlServerUpsertSpec.groovy | 14 +++++++++ .../PostgresCustomerProfileRepository.java | 24 ++++++++++++++ .../PostgresWarehouseInventoryRepository.java | 24 ++++++++++++++ .../entities/upsert/WarehouseInventory.java | 2 -- .../upsert/WarehouseInventoryRepository.java | 31 +++++++++++++++++++ 10 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresCustomerProfileRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresWarehouseInventoryRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresCustomerProfileRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresWarehouseInventoryRepository.java create mode 100644 data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/WarehouseInventoryRepository.java 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 index 2969abf5cd6..b20c29515e4 100644 --- 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 @@ -15,8 +15,12 @@ */ package io.micronaut.data.jdbc.postgres +import io.micronaut.data.jdbc.postgres.upsert.PostgresCustomerProfileRepository 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.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPropertyProvider { @@ -25,4 +29,14 @@ class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPrope ProductReviewRepository getProductReviewRepository() { return context.getBean(PostgresProductReviewRepository) } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(PostgresCustomerProfileRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(PostgresWarehouseInventoryRepository) + } } 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 index 0a1d2fd9519..db58eb83dd3 100644 --- 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 @@ -15,8 +15,12 @@ */ package io.micronaut.data.jdbc.sqlserver +import io.micronaut.data.jdbc.sqlserver.upsert.MSCustomerProfileRepository 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.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class SqlServerUpsertSpec extends AbstractUpsertSpec implements MSSQLTestPropertyProvider { @@ -25,4 +29,14 @@ class SqlServerUpsertSpec extends AbstractUpsertSpec implements MSSQLTestPropert ProductReviewRepository getProductReviewRepository() { return context.getBean(MSProductReviewRepository) } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(MSCustomerProfileRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(MSWarehouseInventoryRepository) + } } 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/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-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresUpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresUpsertSpec.groovy index 11534427142..060018287a5 100644 --- 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 @@ -15,8 +15,12 @@ */ package io.micronaut.data.r2dbc.postgres +import io.micronaut.data.r2dbc.postgres.upsert.PostgresCustomerProfileRepository 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.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPropertyProvider { @@ -25,4 +29,14 @@ class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPrope ProductReviewRepository getProductReviewRepository() { return context.getBean(PostgresProductReviewRepository) } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(PostgresCustomerProfileRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(PostgresWarehouseInventoryRepository) + } } 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 index f49b9e91b92..5081d0d4e36 100644 --- 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 @@ -15,8 +15,12 @@ */ package io.micronaut.data.r2dbc.sqlserver +import io.micronaut.data.r2dbc.sqlserver.upsert.MSCustomerProfileRepository 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.ProductReviewRepository +import io.micronaut.data.tck.repositories.upsert.WarehouseInventoryRepository import io.micronaut.data.tck.tests.AbstractUpsertSpec class SqlServerUpsertSpec extends AbstractUpsertSpec implements SqlServerTestPropertyProvider { @@ -25,4 +29,14 @@ class SqlServerUpsertSpec extends AbstractUpsertSpec implements SqlServerTestPro ProductReviewRepository getProductReviewRepository() { return context.getBean(MSProductReviewRepository) } + + @Override + CustomerProfileRepository getCustomerProfileRepository() { + return context.getBean(MSCustomerProfileRepository) + } + + @Override + WarehouseInventoryRepository getWarehouseInventoryRepository() { + return context.getBean(MSWarehouseInventoryRepository) + } } 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/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-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 index 731626dff9b..17ea46f4636 100644 --- 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 @@ -21,14 +21,12 @@ 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 record WarehouseInventory( @Id @GeneratedValue - @Nullable Long id, @NotBlank 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..586cf3fc6e4 --- /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(conflictProperties = {"sku", "warehouse"}) + WarehouseInventory upsert(WarehouseInventory warehouseInventory); + + @Upsert(conflictProperties = {"sku", "warehouse"}) + List upsertAll(Iterable warehouseInventories); +} From fcf6598fbb2693f9eb9089b53c39dd27014261e0 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Mon, 15 Jun 2026 12:59:32 +0200 Subject: [PATCH 14/57] Upsert implementation - wip --- .../data/tck/tests/AbstractUpsertSpec.groovy | 130 +++++++++--------- 1 file changed, 68 insertions(+), 62 deletions(-) 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 index 54530ac67a7..f7777aec9ed 100644 --- 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 @@ -56,37 +56,38 @@ abstract class AbstractUpsertSpec extends Specification { productReviewRepository.deleteAll() } - void "upsert inserts and updates product review by assigned ID"() { + void "upsert method inserts and updates product review by assigned ID"() { given: - ProductReview pr1 = new ProductReview(1L, "title new", "content new") - ProductReview pr2 = new ProductReview(1L, "title modified", "content modified") + ProductReview pr = new ProductReview(1L, "title new", "content new") when: - ProductReview inserted = productReviewRepository.upsert(pr1) + ProductReview inserted = productReviewRepository.upsert(pr) then: - assertProductReview(pr1, inserted) + inserted == pr when: - ProductReview found = productReviewRepository.findById(1L).get() + ProductReview found = productReviewRepository.findById(pr.id).get() then: - assertProductReview(pr1, found) + assertProductReview(pr, found) when: - ProductReview updated = productReviewRepository.upsert(pr2) + pr.setTitle("title modified") + pr.setContent("content modified") + ProductReview updated = productReviewRepository.upsert(pr) then: - assertProductReview(pr2, updated) + updated == pr when: - found = productReviewRepository.findById(1L).get() + found = productReviewRepository.findById(pr.id).get() then: - assertProductReview(pr2, found) + assertProductReview(pr, found) } - void "upsertAll inserts and updates product reviews by assigned ID"() { + void "upsertAll method inserts and updates product reviews by assigned ID"() { given: ProductReview pr1 = new ProductReview(2L, "title 1", "content 1") ProductReview pr2 = new ProductReview(3L, "title 2", "content 2") @@ -201,6 +202,7 @@ abstract class AbstractUpsertSpec extends Specification { CustomerProfile inserted = customerProfileRepository.upsert(cp1) then: + inserted.id != null assertCustomerProfile(inserted, cp1) when: @@ -208,15 +210,15 @@ abstract class AbstractUpsertSpec extends Specification { then: found.size() == 1 - found[0].id() != null + found[0].id != null assertCustomerProfile(found[0], cp1) when: - Long profileId = found[0].id() + Long profileId = inserted.id CustomerProfile updated = customerProfileRepository.upsert(cp2) then: - updated.id() == profileId + updated.id == profileId assertCustomerProfile(updated, cp2) when: @@ -224,7 +226,7 @@ abstract class AbstractUpsertSpec extends Specification { then: found.size() == 1 - found[0].id() == profileId + found[0].id == profileId assertCustomerProfile(found[0], cp2) } @@ -240,6 +242,8 @@ abstract class AbstractUpsertSpec extends Specification { then: inserted.size() == 2 + inserted.get(0).id != null + inserted.get(1).id != null assertCustomerProfile(inserted.get(0), cp1) assertCustomerProfile(inserted.get(1), cp2) @@ -248,20 +252,20 @@ abstract class AbstractUpsertSpec extends Specification { then: found.size() == 2 - found.get(0).id() != null - found.get(1).id() != null + found.get(0).id != null + found.get(1).id != null assertCustomerProfile(found.get(0), cp1) assertCustomerProfile(found.get(1), cp2) when: - Long id1 = found.get(0).id() - Long id2 = found.get(1).id() + Long id1 = found.get(0).id + Long id2 = found.get(1).id List updated = customerProfileRepository.upsertAll([cp3, cp4]).toList() then: updated.size() == 2 - updated.get(0).id() == id1 - updated.get(1).id() == id2 + updated.get(0).id == id1 + updated.get(1).id == id2 assertCustomerProfile(updated.get(0), cp3) assertCustomerProfile(updated.get(1), cp4) @@ -270,8 +274,8 @@ abstract class AbstractUpsertSpec extends Specification { then: found.size() == 2 - found.get(0).id() == id1 - found.get(1).id() == id2 + found.get(0).id == id1 + found.get(1).id == id2 assertCustomerProfile(found.get(0), cp3) assertCustomerProfile(found.get(1), cp4) } @@ -279,38 +283,37 @@ abstract class AbstractUpsertSpec extends Specification { void "upsert annotation inserts and updates warehouse inventory by sku and warehouse conflict properties"() { given: WarehouseInventory wh1 = new WarehouseInventory("SKU-100", "Berlin", 12) - + when: WarehouseInventory inserted = warehouseInventoryRepository.upsert(wh1) - - - List inventories = warehouseInventoryRepository.findAll().toList() then: - inserted.sku() == "SKU-100" - inserted.warehouse() == "Berlin" - inserted.quantity() == 12 + inserted.id != null + inserted.sku == "SKU-100" + inserted.warehouse == "Berlin" + inserted.quantity == 12 inventories.size() == 1 - inventories[0].id() != null - inventories[0].sku() == "SKU-100" - inventories[0].warehouse() == "Berlin" - inventories[0].quantity() == 12 + inventories[0].id != null + inventories[0].sku == "SKU-100" + inventories[0].warehouse == "Berlin" + inventories[0].quantity == 12 when: - Long inventoryId = inventories[0].id() + Long inventoryId = inventories[0].id WarehouseInventory updated = warehouseInventoryRepository.upsert(new WarehouseInventory("SKU-100", "Berlin", 18)) inventories = warehouseInventoryRepository.findAll().toList() then: - updated.sku() == "SKU-100" - updated.warehouse() == "Berlin" - updated.quantity() == 18 + updated.id == inventoryId + updated.sku == "SKU-100" + updated.warehouse == "Berlin" + updated.quantity == 18 inventories.size() == 1 - inventories[0].id() == inventoryId - inventories[0].sku() == "SKU-100" - inventories[0].warehouse() == "Berlin" - inventories[0].quantity() == 18 + inventories[0].id == inventoryId + inventories[0].sku == "SKU-100" + inventories[0].warehouse == "Berlin" + inventories[0].quantity == 18 } void "upsertAll annotation inserts and updates warehouse inventory by sku and warehouse conflict properties"() { @@ -322,16 +325,17 @@ abstract class AbstractUpsertSpec extends Specification { List inventories = warehouseInventoryRepository.findAll().toList() then: - inserted.collect { it.sku() } as Set == ["SKU-200"] as Set - inserted.collect { it.warehouse() } as Set == ["Berlin", "Paris"] as Set - inserted.collect { it.quantity() } as Set == [5, 8] as Set + inserted.every { it.id != null } + inserted.collect { it.sku } as Set == ["SKU-200"] as Set + inserted.collect { it.warehouse } as Set == ["Berlin", "Paris"] as Set + inserted.collect { it.quantity } as Set == [5, 8] as Set inventories.size() == 2 - inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Berlin" }.quantity() == 5 - inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Paris" }.quantity() == 8 + inventories.find { it.sku == "SKU-200" && it.warehouse == "Berlin" }.quantity == 5 + inventories.find { it.sku == "SKU-200" && it.warehouse == "Paris" }.quantity == 8 when: - Long berlinId = inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Berlin" }.id() - Long parisId = inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Paris" }.id() + Long berlinId = inventories.find { it.sku == "SKU-200" && it.warehouse == "Berlin" }.id + Long parisId = inventories.find { it.sku == "SKU-200" && it.warehouse == "Paris" }.id List updated = warehouseInventoryRepository.upsertAll([ new WarehouseInventory("SKU-200", "Berlin", 7), new WarehouseInventory("SKU-200", "Paris", 11) @@ -339,24 +343,26 @@ abstract class AbstractUpsertSpec extends Specification { inventories = warehouseInventoryRepository.findAll().toList() then: - updated.collect { it.sku() } as Set == ["SKU-200"] as Set - updated.collect { it.warehouse() } as Set == ["Berlin", "Paris"] as Set - updated.collect { it.quantity() } as Set == [7, 11] as Set + updated.find { it.sku == "SKU-200" && it.warehouse == "Berlin" }.id == berlinId + updated.find { it.sku == "SKU-200" && it.warehouse == "Paris" }.id == parisId + updated.collect { it.sku } as Set == ["SKU-200"] as Set + updated.collect { it.warehouse } as Set == ["Berlin", "Paris"] as Set + updated.collect { it.quantity } as Set == [7, 11] as Set inventories.size() == 2 - inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Berlin" }.id() == berlinId - inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Berlin" }.quantity() == 7 - inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Paris" }.id() == parisId - inventories.find { it.sku() == "SKU-200" && it.warehouse() == "Paris" }.quantity() == 11 + inventories.find { it.sku == "SKU-200" && it.warehouse == "Berlin" }.id == berlinId + inventories.find { it.sku == "SKU-200" && it.warehouse == "Berlin" }.quantity == 7 + inventories.find { it.sku == "SKU-200" && it.warehouse == "Paris" }.id == parisId + inventories.find { it.sku == "SKU-200" && it.warehouse == "Paris" }.quantity == 11 } private static void assertProductReview(ProductReview productReview1, ProductReview productReview2) { - assert productReview1.id() == productReview2.id() - assert productReview1.title() == productReview2.title() - assert productReview1.content() == productReview2.content() + 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() + assert customerProfile1.email == customerProfile2.email + assert customerProfile1.displayName == customerProfile2.displayName } } From 80644270c5b6aaef5cfdc6b86aff6e043b23b869 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 17 Jun 2026 17:14:35 +0200 Subject: [PATCH 15/57] Upsert implementation - modified test entities --- .../data/tck/tests/AbstractUpsertSpec.groovy | 192 ++++++++++-------- .../jdbc/entities/upsert/CustomerProfile.java | 47 ++++- .../jdbc/entities/upsert/ProductReview.java | 42 +++- .../entities/upsert/WarehouseInventory.java | 56 ++++- 4 files changed, 233 insertions(+), 104 deletions(-) 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 index f7777aec9ed..214481040f7 100644 --- 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 @@ -70,7 +70,7 @@ abstract class AbstractUpsertSpec extends Specification { ProductReview found = productReviewRepository.findById(pr.id).get() then: - assertProductReview(pr, found) + assertProductReview(found, pr) when: pr.setTitle("title modified") @@ -84,158 +84,159 @@ abstract class AbstractUpsertSpec extends Specification { found = productReviewRepository.findById(pr.id).get() then: - assertProductReview(pr, found) + assertProductReview(found, pr) } void "upsertAll method inserts and updates product reviews by assigned ID"() { given: - ProductReview pr1 = new ProductReview(2L, "title 1", "content 1") - ProductReview pr2 = new ProductReview(3L, "title 2", "content 2") - ProductReview pr3 = new ProductReview(2L, "title 1 modified", "content 1 modified") - ProductReview pr4 = new ProductReview(3L, "title 2 modified", "content 2 modified") + ProductReview pr1 = new ProductReview(1L, "title 1", "content 1") + ProductReview pr2 = new ProductReview(1L, "title 2", "content 2") when: List insertedList = productReviewRepository.upsertAll([pr1, pr2]).toList() then: - assertProductReview(pr1, insertedList.get(0)) - assertProductReview(pr2, insertedList.get(1)) + insertedList.size() == 2 + insertedList.get(0) == pr1 + insertedList.get(1) == pr2 when: - ProductReview found1 = productReviewRepository.findById(2L).get() - ProductReview found2 = productReviewRepository.findById(3L).get() + ProductReview found1 = productReviewRepository.findById(1L).get() + ProductReview found2 = productReviewRepository.findById(2L).get() then: - assertProductReview(pr1, found1) - assertProductReview(pr2, found2) + assertProductReview(found1, pr1) + assertProductReview(found2, pr2) when: - List updatedList = productReviewRepository.upsertAll([pr3, pr4]).toList() + pr1.setTitle("title 1 modified") + pr1.setContent("content 1 modified") + pr2.setTitle("title 2 modified") + pr2.setContent("content 2 modified") + List updatedList = productReviewRepository.upsertAll([pr1, pr2]).toList() then: - assertProductReview(pr3, updatedList.get(0)) - assertProductReview(pr4, updatedList.get(1)) + updatedList.size() == 2 + updatedList.get(0) == pr1 + updatedList.get(1) == pr2 when: - ProductReview found3 = productReviewRepository.findById(2L).get() - ProductReview found4 = productReviewRepository.findById(3L).get() + found1 = productReviewRepository.findById(1L).get() + found2 = productReviewRepository.findById(2L).get() then: - assertProductReview(pr3, found3) - assertProductReview(pr4, found4) + assertProductReview(found1, pr1) + assertProductReview(found2, pr2) } void "upsert annotation inserts and updates product review by assigned ID"() { given: - ProductReview pr1 = new ProductReview(4L, "title new", "content new") - ProductReview pr2 = new ProductReview(4L, "title modified", "content modified") + ProductReview pr = new ProductReview(1L, "title new", "content new") when: - ProductReview inserted = productReviewRepository.put(pr1) + ProductReview inserted = productReviewRepository.put(pr) then: - assertProductReview(pr1, inserted) + inserted == pr when: - ProductReview found = productReviewRepository.findById(4L).get() + ProductReview found = productReviewRepository.findById(pr.id).get() then: - assertProductReview(pr1, found) + assertProductReview(found, pr) when: - ProductReview updated = productReviewRepository.put(pr2) + pr.setTitle("title modified") + pr.setContent("content modified") + ProductReview updated = productReviewRepository.put(pr) then: - assertProductReview(pr2, updated) + updated == pr when: - found = productReviewRepository.findById(4L).get() + found = productReviewRepository.findById(pr.id).get() then: - assertProductReview(pr2, found) + assertProductReview(found, pr) } void "upsert annotation inserts and updates product reviews by assigned ID"() { given: - ProductReview pr1 = new ProductReview(2L, "title 1", "content 1") - ProductReview pr2 = new ProductReview(3L, "title 2", "content 2") - ProductReview pr3 = new ProductReview(2L, "title 1 modified", "content 1 modified") - ProductReview pr4 = new ProductReview(3L, "title 2 modified", "content 2 modified") + ProductReview pr1 = new ProductReview(1L, "title 1", "content 1") + ProductReview pr2 = new ProductReview(1L, "title 2", "content 2") when: List insertedList = productReviewRepository.putAll([pr1, pr2]).toList() then: - assertProductReview(pr1, insertedList.get(0)) - assertProductReview(pr2, insertedList.get(1)) + insertedList.size() == 2 + insertedList.get(0) == pr1 + insertedList.get(1) == pr2 when: - ProductReview found1 = productReviewRepository.findById(2L).get() - ProductReview found2 = productReviewRepository.findById(3L).get() + ProductReview found1 = productReviewRepository.findById(1L).get() + ProductReview found2 = productReviewRepository.findById(2L).get() then: - assertProductReview(pr1, found1) - assertProductReview(pr2, found2) + assertProductReview(found1, pr1) + assertProductReview(found2, pr2) when: - List updatedList = productReviewRepository.putAll([pr3, pr4]).toList() + pr1.setTitle("title 1 modified") + pr1.setContent("content 1 modified") + pr2.setTitle("title 2 modified") + pr2.setContent("content 2 modified") + List updatedList = productReviewRepository.putAll([pr1, pr2]).toList() then: - assertProductReview(pr3, updatedList.get(0)) - assertProductReview(pr4, updatedList.get(1)) + updatedList.size() == 2 + updatedList.get(0) == pr1 + updatedList.get(1) == pr2 when: - ProductReview found3 = productReviewRepository.findById(2L).get() - ProductReview found4 = productReviewRepository.findById(3L).get() + found1 = productReviewRepository.findById(1L).get() + found2 = productReviewRepository.findById(2L).get() then: - assertProductReview(pr3, found3) - assertProductReview(pr4, found4) + assertProductReview(found1, pr1) + assertProductReview(found2, pr2) } void "upsert annotation inserts and updates customer profile by email conflict property"() { given: - CustomerProfile cp1 = new CustomerProfile("test@example.com", "test") - CustomerProfile cp2 = new CustomerProfile("test@example.com", "test modified") + CustomerProfile cp = new CustomerProfile("test@example.com", "test") when: - CustomerProfile inserted = customerProfileRepository.upsert(cp1) + CustomerProfile inserted = customerProfileRepository.upsert(cp) then: inserted.id != null - assertCustomerProfile(inserted, cp1) + inserted == cp when: - List found = customerProfileRepository.findAll().toList() + CustomerProfile found = customerProfileRepository.findById(cp.id).get() then: - found.size() == 1 - found[0].id != null - assertCustomerProfile(found[0], cp1) + assertCustomerProfile(cp, found) when: - Long profileId = inserted.id - CustomerProfile updated = customerProfileRepository.upsert(cp2) + cp.setDisplayName("test modified") + CustomerProfile updated = customerProfileRepository.upsert(cp) then: - updated.id == profileId - assertCustomerProfile(updated, cp2) + updated == cp when: - found = customerProfileRepository.findAll().toList() + found = customerProfileRepository.findById(cp.id).get() then: - found.size() == 1 - found[0].id == profileId - assertCustomerProfile(found[0], cp2) + assertCustomerProfile(cp, found) } void "upsertAll annotation inserts and updates customer profiles by email conflict property"() { given: CustomerProfile cp1 = new CustomerProfile("test1@example.com", "test 1") CustomerProfile cp2 = new CustomerProfile("test2@example.com", "test 2") - CustomerProfile cp3 = new CustomerProfile("test1@example.com", "test 1 modified") - CustomerProfile cp4 = new CustomerProfile("test2@example.com", "test 2 modified") when: List inserted = customerProfileRepository.upsertAll([cp1, cp2]).toList() @@ -244,40 +245,34 @@ abstract class AbstractUpsertSpec extends Specification { inserted.size() == 2 inserted.get(0).id != null inserted.get(1).id != null - assertCustomerProfile(inserted.get(0), cp1) - assertCustomerProfile(inserted.get(1), cp2) + inserted.get(0) == cp1 + inserted.get(1) == cp2 when: - List found = customerProfileRepository.findAll().toList() + CustomerProfile found1 = customerProfileRepository.findById(cp1.id).get() + CustomerProfile found2 = customerProfileRepository.findById(cp2.id).get() then: - found.size() == 2 - found.get(0).id != null - found.get(1).id != null - assertCustomerProfile(found.get(0), cp1) - assertCustomerProfile(found.get(1), cp2) + assertCustomerProfile(found1, cp1) + assertCustomerProfile(found2, cp2) when: - Long id1 = found.get(0).id - Long id2 = found.get(1).id - List updated = customerProfileRepository.upsertAll([cp3, cp4]).toList() + cp1.setDisplayName("test 1 modified") + cp2.setDisplayName("test 2 modified") + List updated = customerProfileRepository.upsertAll([cp1, cp2]).toList() then: updated.size() == 2 - updated.get(0).id == id1 - updated.get(1).id == id2 - assertCustomerProfile(updated.get(0), cp3) - assertCustomerProfile(updated.get(1), cp4) + updated.get(0) == cp1 + updated.get(1) == cp2 when: - found = customerProfileRepository.findAll().toList() + found1 = customerProfileRepository.findById(cp1.id).get() + found2 = customerProfileRepository.findById(cp2.id).get() then: - found.size() == 2 - found.get(0).id == id1 - found.get(1).id == id2 - assertCustomerProfile(found.get(0), cp3) - assertCustomerProfile(found.get(1), cp4) + assertCustomerProfile(found1, cp1) + assertCustomerProfile(found2, cp2) } void "upsert annotation inserts and updates warehouse inventory by sku and warehouse conflict properties"() { @@ -289,7 +284,9 @@ abstract class AbstractUpsertSpec extends Specification { List inventories = warehouseInventoryRepository.findAll().toList() then: - inserted.id != null + if (inserted.id != null) { + assert inserted.id == inventories[0].id + } inserted.sku == "SKU-100" inserted.warehouse == "Berlin" inserted.quantity == 12 @@ -305,7 +302,9 @@ abstract class AbstractUpsertSpec extends Specification { inventories = warehouseInventoryRepository.findAll().toList() then: - updated.id == inventoryId + if (updated.id != null) { + assert updated.id == inventoryId + } updated.sku == "SKU-100" updated.warehouse == "Berlin" updated.quantity == 18 @@ -325,13 +324,15 @@ abstract class AbstractUpsertSpec extends Specification { List inventories = warehouseInventoryRepository.findAll().toList() then: - inserted.every { it.id != null } inserted.collect { it.sku } as Set == ["SKU-200"] as Set inserted.collect { it.warehouse } as Set == ["Berlin", "Paris"] as Set inserted.collect { it.quantity } as Set == [5, 8] as Set inventories.size() == 2 + inventories.find { it.sku == "SKU-200" && it.warehouse == "Berlin" }.id != null inventories.find { it.sku == "SKU-200" && it.warehouse == "Berlin" }.quantity == 5 + inventories.find { it.sku == "SKU-200" && it.warehouse == "Paris" }.id != null inventories.find { it.sku == "SKU-200" && it.warehouse == "Paris" }.quantity == 8 + assertReturnedWarehouseInventoryIdsIfPresent(inserted, inventories) when: Long berlinId = inventories.find { it.sku == "SKU-200" && it.warehouse == "Berlin" }.id @@ -343,8 +344,7 @@ abstract class AbstractUpsertSpec extends Specification { inventories = warehouseInventoryRepository.findAll().toList() then: - updated.find { it.sku == "SKU-200" && it.warehouse == "Berlin" }.id == berlinId - updated.find { it.sku == "SKU-200" && it.warehouse == "Paris" }.id == parisId + assertReturnedWarehouseInventoryIdsIfPresent(updated, inventories) updated.collect { it.sku } as Set == ["SKU-200"] as Set updated.collect { it.warehouse } as Set == ["Berlin", "Paris"] as Set updated.collect { it.quantity } as Set == [7, 11] as Set @@ -365,4 +365,16 @@ abstract class AbstractUpsertSpec extends Specification { assert customerProfile1.email == customerProfile2.email assert customerProfile1.displayName == customerProfile2.displayName } + + private static void assertReturnedWarehouseInventoryIdsIfPresent(List returned, List persisted) { + returned.each { WarehouseInventory warehouseInventory -> + if (warehouseInventory.id != null) { + WarehouseInventory persistedWarehouseInventory = persisted.find { + it.sku == warehouseInventory.sku && it.warehouse == warehouseInventory.warehouse + } + assert persistedWarehouseInventory != null + assert warehouseInventory.id == persistedWarehouseInventory.id + } + } + } } 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 index 5e8f2b0cb7e..a16cca77cd0 100644 --- 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 @@ -20,21 +20,58 @@ 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 record CustomerProfile( +public class CustomerProfile { + @Id - @GeneratedValue - Long id, + @GeneratedValue(value = GeneratedValue.Type.IDENTITY) + @Nullable + private Long id; @NotBlank - String email, + private String email; @NotBlank - String displayName) { + 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/ProductReview.java b/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/ProductReview.java index 20625fc4d2b..95acb6a3b20 100644 --- 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 @@ -20,13 +20,47 @@ import jakarta.validation.constraints.NotBlank; @MappedEntity -public record ProductReview( +public class ProductReview { + @Id - Long id, + private Long id; @NotBlank - String title, + private String title; @NotBlank - String content) { + 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 index 17ea46f4636..3467bc77b6e 100644 --- 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 @@ -21,24 +21,70 @@ 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 record WarehouseInventory( +public class WarehouseInventory { + @Id @GeneratedValue - Long id, + @Nullable + private Long id; @NotBlank - String sku, + private String sku; @NotBlank - String warehouse, + private String warehouse; @NotNull - Integer quantity) { + 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; + } } From c08222216817ed1d6a7cf428bb6aff663f888ac7 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 18 Jun 2026 16:34:15 +0200 Subject: [PATCH 16/57] Upsert implementation - return generated ids --- .../DefaultJdbcRepositoryOperations.java | 157 +++++++--- .../JdbcRepositoryOperationsConditions.java | 109 +++++++ .../OracleJdbcRepositoryOperations.java | 273 ++++++++++++++++++ .../jdbc/oraclexe/OracleXEUpsertSpec.groovy | 94 ++++++ .../upsert/CustomerProfileSequence.java | 77 +++++ ...leXECustomerProfileSequenceRepository.java | 33 +++ .../model/query/builder/QueryBuilder.java | 7 + .../query/builder/sql/SqlQueryBuilder.java | 131 ++++++++- .../visitors/finders/UpsertMethodMatcher.java | 35 ++- .../data/processor/sql/BuildInsertSpec.groovy | 236 +++++++++++++++ .../data/tck/tests/AbstractUpsertSpec.groovy | 224 ++++++++------ .../upsert/CustomerProfileRepository.java | 6 + 12 files changed, 1230 insertions(+), 152 deletions(-) create mode 100644 data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/JdbcRepositoryOperationsConditions.java create mode 100644 data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/OracleJdbcRepositoryOperations.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/CustomerProfileSequence.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXECustomerProfileSequenceRepository.java 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 93ae930e8db..8eb6caa0dc1 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()); @@ -733,13 +735,13 @@ public List deleteAllReturning(DeleteReturningBatchOperation ope 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 (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(); @@ -752,7 +754,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()); @@ -769,13 +771,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()); @@ -787,7 +789,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(); @@ -853,13 +855,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(); } @@ -1062,7 +1064,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); @@ -1128,7 +1130,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())); @@ -1136,6 +1138,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. @@ -1193,14 +1263,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; @@ -1271,18 +1341,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, @@ -1407,7 +1473,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); } } @@ -1417,16 +1483,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, @@ -1444,7 +1506,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)) { @@ -1502,7 +1571,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); @@ -1526,7 +1597,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..8e512179f29 --- /dev/null +++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/JdbcRepositoryOperationsConditions.java @@ -0,0 +1,109 @@ +/* + * 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.BeanResolutionContext; +import io.micronaut.context.Qualifier; +import io.micronaut.context.condition.Condition; +import io.micronaut.context.condition.ConditionContext; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.naming.Named; +import io.micronaut.inject.BeanDefinition; + +/** + * Condition that enables the default JDBC repository operations for non-Oracle datasources. + */ +@Internal +final class DefaultJdbcRepositoryOperationsCondition implements Condition { + + /** + * Checks whether the current datasource is not configured with the Oracle dialect. + * + * @param context The condition context + * @return {@code true} when default JDBC operations should be enabled + */ + @Override + public boolean matches(ConditionContext context) { + return !JdbcRepositoryOperationsConditions.isOracleDialect(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); + } +} + +/** + * 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 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) { + String dataSourceName = resolveDataSourceName(context); + String dialectProperty = DATASOURCES + '.' + dataSourceName + '.' + DIALECT; + String dialect = context.getProperty(dialectProperty, String.class).orElse(null); + return ORACLE_DIALECT.equalsIgnoreCase(dialect); + } + + /** + * Resolves the datasource name from the current qualifier, falling back to {@code default}. + * + * @param context The condition context + * @return The datasource name + */ + private static String resolveDataSourceName(ConditionContext context) { + BeanResolutionContext beanResolutionContext = context.getBeanResolutionContext(); + Qualifier currentQualifier = null; + if (beanResolutionContext != null) { + currentQualifier = beanResolutionContext.getCurrentQualifier(); + } + if (currentQualifier == null && context.getComponent() instanceof BeanDefinition definition) { + currentQualifier = definition.getDeclaredQualifier(); + } + if (currentQualifier instanceof Named named) { + return named.getName(); + } + return DEFAULT; + } +} 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..7cc4224c999 --- /dev/null +++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/OracleJdbcRepositoryOperations.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.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.annotation.Internal; +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.runtime.QueryOutParameterBinding; +import io.micronaut.data.model.runtime.AttributeConverterRegistry; +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.concurrent.ExecutorService; + +/** + * Oracle-specific JDBC repository operations. + */ +@EachBean(DataSource.class) +@Requires(classes = OraclePreparedStatement.class) +@Requires(condition = OracleJdbcRepositoryOperationsCondition.class) +@Internal +public 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 + */ + @Internal + @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 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); + } + } + + 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 (!isUpsertOperation(storedQuery) || CollectionUtils.isEmpty(storedQuery.getOutParameterBindings())) { + super.execute(); + return; + } + QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); + try (PreparedStatement ps = ctx.connection.prepareStatement(storedQuery.getQuery())) { + OraclePreparedStatement oraclePreparedStatement = ps.unwrap(OraclePreparedStatement.class); + JdbcParameterBinder parameterBinder = new JdbcParameterBinder(ctx.connection, ps, storedQuery); + storedQuery.bindParameters(parameterBinder, ctx.invocationContext, entity, previousValues); + registerReturnParameters(oraclePreparedStatement, storedQuery, parameterBinder.currentIndex() - 1); + rowsUpdated = oraclePreparedStatement.executeUpdate(); + try (ResultSet returnedIds = oraclePreparedStatement.getReturnResultSet()) { + if (returnedIds.next()) { + RuntimePersistentProperty identity = persistentEntity.getIdentity(); + Object id = getGeneratedIdentity(returnedIds, identity, storedQuery.getDialect()); + entity = updateEntityId(identity.getProperty(), entity, id); + } else { + throw new DataAccessException("Oracle upsert RETURNING clause produced no generated ID for entity: " + entity); + } + } + } 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 (!isUpsertOperation(storedQuery) || CollectionUtils.isEmpty(storedQuery.getOutParameterBindings())) { + super.execute(); + return; + } + QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); + long notVetoedCount = countNotVetoedEntities(); + if (notVetoedCount == 0) { + rowsUpdated = 0; + return; + } + try (PreparedStatement ps = ctx.connection.prepareStatement(storedQuery.getQuery())) { + OraclePreparedStatement oraclePreparedStatement = ps.unwrap(OraclePreparedStatement.class); + boolean returnParametersRegistered = false; + for (Data d : entities) { + if (d.vetoed) { + continue; + } + JdbcParameterBinder parameterBinder = new JdbcParameterBinder(ctx.connection, ps, storedQuery); + storedQuery.bindParameters(parameterBinder, ctx.invocationContext, d.entity, d.previousValues); + if (!returnParametersRegistered) { + registerReturnParameters(oraclePreparedStatement, storedQuery, parameterBinder.currentIndex() - 1); + returnParametersRegistered = true; + } + ps.addBatch(); + } + rowsUpdated = Arrays.stream(ps.executeBatch()).sum(); + updateEntityIdsFromReturnedIds(oraclePreparedStatement); + } catch (SQLException e) { + throw new DataAccessException("Error executing batch Oracle SQL RETURNING: " + e.getMessage(), e); + } + } + + private void updateEntityIdsFromReturnedIds(OraclePreparedStatement oraclePreparedStatement) throws SQLException { + RuntimePersistentProperty identity = persistentEntity.getIdentity(); + List ids = new ArrayList<>(); + try (ResultSet returnedIds = oraclePreparedStatement.getReturnResultSet()) { + while (returnedIds.next()) { + ids.add(getGeneratedIdentity(returnedIds, identity, storedQuery.getDialect())); + } + } + Iterator iterator = ids.iterator(); + int updated = 0; + for (Data d : entities) { + if (d.vetoed) { + continue; + } + if (!iterator.hasNext()) { + throw new DataAccessException("Oracle upsert RETURNING clause produced " + updated + " generated IDs for " + countNotVetoedEntities() + " 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 long countNotVetoedEntities() { + return entities.stream().filter(d -> !d.vetoed).count(); + } + } +} 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 index 159726a5be0..3190000f433 100644 --- 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 @@ -15,7 +15,9 @@ */ 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.OracleXEProductReviewRepository import io.micronaut.data.jdbc.oraclexe.upsert.OracleXEWarehouseInventoryRepository import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository @@ -39,4 +41,96 @@ class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleTestPropert 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]).toList() + + 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") + List updated = customerProfileSequenceRepository.upsertAll([cp1, cp2]).toList() + + then: + updated.size() == 2 + updated.get(0) == cp1 + updated.get(1) == cp2 + + when: + found1 = customerProfileSequenceRepository.findById(cp1.id).get() + found2 = customerProfileSequenceRepository.findById(cp2.id).get() + + then: + assertCustomerProfileSequence(found1, cp1) + assertCustomerProfileSequence(found2, cp2) + } + + 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/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/OracleXECustomerProfileSequenceRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXECustomerProfileSequenceRepository.java new file mode 100644 index 00000000000..f8d8f0caec6 --- /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(conflictProperties = "email") + CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); + + @Upsert(conflictProperties = "email") + List upsertAll(Iterable customerProfiles); +} 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 8b6fd3cd037..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 @@ -225,6 +225,13 @@ default List conflictProperties() { return List.of(); } + /** + * @return Should upsert return generated id + */ + default boolean returnGeneratedId() { + return false; + } + } /** 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 13db2bd196f..3aa2d574eff 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 @@ -1399,7 +1399,28 @@ public QueryResult buildUpsert(AnnotationMetadata repositoryMetadata, UpsertQuer case ORACLE -> buildOracleUpsert(tableName, data); case ANSI -> buildAnsiUpsert(tableName, data); }; - return QueryResult.of(query, Collections.emptyList(), buildUpsertParameterBindings(data), Collections.emptyMap()); + + List parameterBindings = buildUpsertParameterBindings(data); + if (definition.returnGeneratedId() && dialect == Dialect.ORACLE) { + List returningColumns = resolveGeneratedIdentityUpsertReturningColumns(entity); + if (!returningColumns.isEmpty()) { + if (returningColumns.size() > 1) { + throw new IllegalStateException("Oracle MERGE ... RETURNING supports a single generated identity for entity: " + entity.getName()); + } + UpsertReturningColumn returningColumn = returningColumns.get(0); + String outPlaceholder = formatParameter(parameterBindings.size() + 1).name(); + query = query + " RETURNING " + returningColumn.column() + " INTO " + outPlaceholder; + return QueryResult.of( + query, + Collections.emptyList(), + parameterBindings, + buildUpsertOutParameterBindings(returningColumns), + Collections.emptyMap() + ); + } + } + + return QueryResult.of(query, Collections.emptyList(), parameterBindings, Collections.emptyMap()); } private UpsertData buildUpsertData(PersistentEntity entity, List conflictProperties) { @@ -1409,6 +1430,8 @@ private UpsertData buildUpsertData(PersistentEntity entity, List conflic List values = new ArrayList<>(); List parameterBindings = new ArrayList<>(); List conflictPropertyPaths = resolveUpsertConflictPropertyPaths(entity, conflictProperties); + final String unescapedTableName = getUnescapedTableName(entity); + final String unescapedSchema = SqlQueryBuilderUtils.getSchemaName(entity); for (PersistentProperty prop : entity.getPersistentProperties()) { PersistentEntityUtils.traversePersistentProperties(Collections.emptyList(), prop, (associations, property) -> { @@ -1419,10 +1442,17 @@ private UpsertData buildUpsertData(PersistentEntity entity, List conflic }); } + boolean identityConflict = conflictProperties.isEmpty(); for (PersistentProperty identity : entity.getIdentityProperties()) { PersistentEntityUtils.traversePersistentProperties(Collections.emptyList(), identity, (associations, property) -> { if (SqlQueryBuilderUtils.isGeneratedProperty(property, associations)) { - throw new IllegalStateException("Upsert requires a non-generated identity property: " + property.getName()); + if (identityConflict) { + throw new IllegalStateException("Upsert requires a non-generated identity property: " + property.getName()); + } + if (SqlQueryBuilderUtils.isNotForeign(associations) && isSequenceGeneratedProperty(property)) { + addGeneratedUpsertColumn(columns, namingStrategy, associations, property, escape, true, conflictPropertyPaths, getSequenceStatement(unescapedSchema, unescapedTableName, property)); + } + return; } addUpsertColumn(columns, values, parameterBindings, namingStrategy, associations, property, escape, true, conflictPropertyPaths); }); @@ -1434,7 +1464,7 @@ private UpsertData buildUpsertData(PersistentEntity entity, List conflic 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, values, parameterBindings); + return new UpsertData(columns, parameterBindings); } private void addUpsertColumn(List columns, @@ -1455,7 +1485,40 @@ private void addUpsertColumn(List columns, if (escape) { columnName = quote(columnName); } - columns.add(new UpsertColumn(columnName, values.get(values.size() - 1), "c" + columns.size(), property, List.of(path), identity, conflictPropertyPaths.contains(toPathString(path)))); + columns.add(new UpsertColumn(columnName, values.get(values.size() - 1), "c" + sourceColumnCount(columns), true, property, List.of(path), identity, conflictPropertyPaths.contains(toPathString(path)))); + } + + private void addGeneratedUpsertColumn(List columns, + NamingStrategy namingStrategy, + List associations, + PersistentProperty property, + boolean escape, + boolean identity, + List conflictPropertyPaths, + String value) { + String[] path = asStringPath(associations, property); + String columnName = getMappedName(namingStrategy, associations, property); + if (escape) { + columnName = quote(columnName); + } + columns.add(new UpsertColumn(columnName, value, "", false, property, List.of(path), identity, conflictPropertyPaths.contains(toPathString(path)))); + } + + 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(() -> selectAutoStrategy(property)); + return idGeneratorType == SEQUENCE || (idGeneratorType == AUTO && selectAutoStrategy(property) == SEQUENCE); } private List resolveUpsertConflictPropertyPaths(PersistentEntity entity, List conflictProperties) { @@ -1542,7 +1605,7 @@ private String buildPostgresUpsert(String tableName, UpsertData data) { private String buildSqlServerUpsert(String tableName, UpsertData data) { return "MERGE INTO " + tableName + " WITH (HOLDLOCK) AS target " - + "USING (VALUES (" + data.valueExpressions() + ")) AS source (" + data.sourceColumns() + ") " + + "USING (VALUES (" + data.sourceValueExpressions() + ")) AS source (" + data.sourceColumns() + ") " + "ON " + upsertConflictPredicate(data) + upsertMatchedClause(data) + upsertInsertClause(data) @@ -1551,6 +1614,7 @@ private String buildSqlServerUpsert(String tableName, UpsertData 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 " @@ -1560,9 +1624,43 @@ private String buildOracleUpsert(String tableName, UpsertData data) { + upsertInsertClause(data); } + private List resolveGeneratedIdentityUpsertReturningColumns(PersistentEntity entity) { + boolean escape = shouldEscape(entity); + NamingStrategy namingStrategy = 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 = getMappedName(namingStrategy, associations, property); + columns.add(new UpsertReturningColumn(escape ? quote(columnName) : columnName, columnName, property.getDataType())); + }); + } + return columns; + } + + private List buildUpsertOutParameterBindings(List returningColumns) { + List outBindings = new ArrayList<>(returningColumns.size()); + for (UpsertReturningColumn returningColumn : returningColumns) { + outBindings.add(new QueryOutParameterBinding() { + @Override + public String getName() { + return returningColumn.name(); + } + + @Override + public DataType getDataType() { + return returningColumn.dataType(); + } + }); + } + return outBindings; + } + private String buildAnsiUpsert(String tableName, UpsertData data) { return "MERGE INTO " + tableName + " target " - + "USING (VALUES (" + data.valueExpressions() + ")) source (" + data.sourceColumns() + ") " + + "USING (VALUES (" + data.sourceValueExpressions() + ")) source (" + data.sourceColumns() + ") " + "ON (" + upsertConflictPredicate(data) + CLOSE_BRACKET + upsertMatchedClause(data) + upsertInsertClause(data); @@ -1592,13 +1690,12 @@ private String upsertMatchedClause(UpsertData data) { private String upsertInsertClause(UpsertData data) { return " WHEN NOT MATCHED THEN INSERT (" + data.columnNames() + ") VALUES (" + data.columns().stream() - .map(column -> "source." + column.source()) + .map(column -> column.sourceColumn() ? "source." + column.source() : column.value()) .collect(Collectors.joining(String.valueOf(COMMA))) + CLOSE_BRACKET; } private record UpsertData(List columns, - List values, List parameterBindings) { private String columnNames() { @@ -1608,11 +1705,21 @@ private String columnNames() { } private String valueExpressions() { - return String.join(String.valueOf(COMMA), values); + 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))); } @@ -1644,12 +1751,18 @@ private List updateColumnsOrConflict() { 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) { + } + private QueryParameterBinding createParameterBinding(String key, PersistentProperty property, String[] path) { return new QueryParameterBinding() { @Override 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 index 85ea21178df..63b999e8621 100644 --- 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 @@ -117,15 +117,17 @@ private String explicitUpsertUnsupportedReason(MethodMatchContext matchContext) if (DataAnnotationUtils.hasJsonEntityRepresentationAnnotation(rootEntity.getAnnotationMetadata())) { return "JSON entity representation is not supported"; } - List conflictProperties = conflictProperties(matchContext); - if (conflictProperties.isEmpty() && !rootEntity.hasIdentity() && !rootEntity.hasCompositeIdentity()) { - return "entity does not define an identity and no conflict properties were specified"; - } if (rootEntity.hasVersion()) { return "versioned entities are not supported"; } - if (rootEntity.getIdentityProperties().stream().anyMatch(PersistentProperty::isGenerated)) { - return "generated identity properties 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); } @@ -166,6 +168,9 @@ private MethodMatch upsertEntity() { 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, @@ -184,6 +189,7 @@ private MethodMatch upsertEntity() { mc.getAnnotationMetadata() ); List conflictProperties = conflictProperties(mc); + boolean returnGeneratedId = shouldReturnGeneratedId(mc, entityParameter); QueryResult queryResult = mc.getQueryBuilder().buildUpsert(annotationMetadataHierarchy, new QueryBuilder.UpsertQueryDefinition() { @Override public SourcePersistentEntity persistentEntity() { @@ -194,6 +200,11 @@ public SourcePersistentEntity persistentEntity() { public List conflictProperties() { return conflictProperties; } + + @Override + public boolean returnGeneratedId() { + return returnGeneratedId; + } }); methodMatchInfo @@ -209,6 +220,18 @@ public List conflictProperties() { }; } + private boolean shouldReturnGeneratedId(MethodMatchContext matchContext, + @Nullable ParameterElement entityParameter) { + boolean entityUpsert = entityParameter != null; + SourcePersistentEntity rootEntity = matchContext.getRootEntity(); + if (!rootEntity.hasIdentity() || rootEntity.getIdentityProperties().stream().noneMatch(PersistentProperty::isGenerated)) { + return false; + } + ClassElement returnType = TypeUtils.getMethodProducingItemType(matchContext.getMethodElement()); + return returnType != null + && (entityUpsert ? TypeUtils.isEntity(returnType) : TypeUtils.isIterableOfEntity(returnType)); + } + private List conflictProperties(MethodMatchContext matchContext) { return Arrays.asList(matchContext.getAnnotationMetadata().stringValues(Upsert.class, "conflictProperties")); } 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 c7f9da62f99..9d25672d80f 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 @@ -18,7 +18,11 @@ 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 @@ -432,6 +436,9 @@ 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 @@ -443,6 +450,18 @@ interface MyInterface extends GenericRepository { @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") @@ -482,6 +501,10 @@ class Test { 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 @@ -496,6 +519,22 @@ class Test { 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 @@ -515,12 +554,16 @@ 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(conflictProperties = "name") Test put(Test test); + + @Upsert(conflictProperties = "name") + List putAll(List tests); } @MappedEntity("upsert_test") @@ -575,6 +618,154 @@ class Test { 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(conflictProperties = "name") + Test put(Test test); + + @Upsert(conflictProperties = "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.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);' | ["name", "pages"] | [] | [] + } + + @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; + +@JdbcRepository(dialect=Dialect.ORACLE) +@io.micronaut.context.annotation.Executable +interface MyInterface extends GenericRepository { + @Upsert(conflictProperties = "name") + Test put(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() + + then: + getQuery(putMethod) == '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 ?' + getParameterPropertyPaths(putMethod) == ["name", "pages"] as String[] + getOutBindingParameters(putMethod)*.name == ["id"] + + where: + generationType << ["AUTO", "SEQUENCE"] + } + @Unroll void "test build upsert with multiple conflict properties for dialect - #dialect"() { given: @@ -774,6 +965,51 @@ class Test { "unknown conflict property" | "@Upsert(conflictProperties = \"missing\")" | "" | "@Id" | "" | "conflict property does not exist: missing" } + void "test build upsert fails with both entity and iterable entity parameters"() { + 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 + List put(Test test, 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; + } +} +""") + + then: + def ex = thrown(RuntimeException) + ex.message.contains("Cannot implement upsert method with both entity and iterable entity parameters") + } + void "POSTGRES test build save returning "() { given: def repository = buildRepository('test.BookRepository', """ 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 index 214481040f7..cf8f1a17cdd 100644 --- 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 @@ -44,16 +44,14 @@ abstract class AbstractUpsertSpec extends Specification { return context } - void setup() { + void cleanup() { warehouseInventoryRepository.deleteAll() customerProfileRepository.deleteAll() productReviewRepository.deleteAll() + cleanupAdditionalRepositories() } - void cleanup() { - warehouseInventoryRepository.deleteAll() - customerProfileRepository.deleteAll() - productReviewRepository.deleteAll() + protected void cleanupAdditionalRepositories() { } void "upsert method inserts and updates product review by assigned ID"() { @@ -90,7 +88,7 @@ abstract class AbstractUpsertSpec extends Specification { void "upsertAll method inserts and updates product reviews by assigned ID"() { given: ProductReview pr1 = new ProductReview(1L, "title 1", "content 1") - ProductReview pr2 = new ProductReview(1L, "title 2", "content 2") + ProductReview pr2 = new ProductReview(2L, "title 2", "content 2") when: List insertedList = productReviewRepository.upsertAll([pr1, pr2]).toList() @@ -163,7 +161,7 @@ abstract class AbstractUpsertSpec extends Specification { void "upsert annotation inserts and updates product reviews by assigned ID"() { given: ProductReview pr1 = new ProductReview(1L, "title 1", "content 1") - ProductReview pr2 = new ProductReview(1L, "title 2", "content 2") + ProductReview pr2 = new ProductReview(2L, "title 2", "content 2") when: List insertedList = productReviewRepository.putAll([pr1, pr2]).toList() @@ -202,7 +200,7 @@ abstract class AbstractUpsertSpec extends Specification { assertProductReview(found2, pr2) } - void "upsert annotation inserts and updates customer profile by email conflict property"() { + void "upsert by email conflict returns entity"() { given: CustomerProfile cp = new CustomerProfile("test@example.com", "test") @@ -233,7 +231,7 @@ abstract class AbstractUpsertSpec extends Specification { assertCustomerProfile(cp, found) } - void "upsertAll annotation inserts and updates customer profiles by email conflict property"() { + void "upsertAll by email conflict returns entities"() { given: CustomerProfile cp1 = new CustomerProfile("test1@example.com", "test 1") CustomerProfile cp2 = new CustomerProfile("test2@example.com", "test 2") @@ -275,84 +273,128 @@ abstract class AbstractUpsertSpec extends Specification { assertCustomerProfile(found2, cp2) } - void "upsert annotation inserts and updates warehouse inventory by sku and warehouse conflict properties"() { + void "upsert by email conflict does not return entity"() { + given: + CustomerProfile cp = new CustomerProfile("test@example.com", "test") + + when: + customerProfileRepository.upsertNoResult(cp) + List found = customerProfileRepository.findAll() + + then: + found.size() == 1 + found.get(0).id != null + assertCustomerProfile(found.get(0), cp) + + when: + cp.setDisplayName("test modified") + customerProfileRepository.upsertNoResult(cp) + found = customerProfileRepository.findAll() + + then: + found.get(0).id != null + assertCustomerProfile(found.get(0), cp) + } + + void "upsertAll by email conflict does not return entities"() { given: - WarehouseInventory wh1 = new WarehouseInventory("SKU-100", "Berlin", 12) - - when: - WarehouseInventory inserted = warehouseInventoryRepository.upsert(wh1) - List inventories = warehouseInventoryRepository.findAll().toList() - - then: - if (inserted.id != null) { - assert inserted.id == inventories[0].id - } - inserted.sku == "SKU-100" - inserted.warehouse == "Berlin" - inserted.quantity == 12 - inventories.size() == 1 - inventories[0].id != null - inventories[0].sku == "SKU-100" - inventories[0].warehouse == "Berlin" - inventories[0].quantity == 12 - - when: - Long inventoryId = inventories[0].id - WarehouseInventory updated = warehouseInventoryRepository.upsert(new WarehouseInventory("SKU-100", "Berlin", 18)) - inventories = warehouseInventoryRepository.findAll().toList() - - then: - if (updated.id != null) { - assert updated.id == inventoryId - } - updated.sku == "SKU-100" - updated.warehouse == "Berlin" - updated.quantity == 18 - inventories.size() == 1 - inventories[0].id == inventoryId - inventories[0].sku == "SKU-100" - inventories[0].warehouse == "Berlin" - inventories[0].quantity == 18 + CustomerProfile cp1 = new CustomerProfile("test1@example.com", "test 1") + CustomerProfile cp2 = new CustomerProfile("test2@example.com", "test 2") + + when: + customerProfileRepository.upsertAllNoResult([cp1, cp2]) + List found = customerProfileRepository.findAll() + + then: + found.size() == 2 + found.get(0).id != null + found.get(1).id != null + assertCustomerProfile(found.get(0), cp1) + assertCustomerProfile(found.get(1), cp2) + + when: + cp1.setDisplayName("test 1 modified") + cp2.setDisplayName("test 2 modified") + customerProfileRepository.upsertAllNoResult([cp1, cp2]) + found = customerProfileRepository.findAll() + + then: + found.size() == 2 + assertCustomerProfile(found.get(0), cp1) + assertCustomerProfile(found.get(1), cp2) } - void "upsertAll annotation inserts and updates warehouse inventory by sku and warehouse conflict properties"() { - when: - List inserted = warehouseInventoryRepository.upsertAll([ - new WarehouseInventory("SKU-200", "Berlin", 5), - new WarehouseInventory("SKU-200", "Paris", 8) - ]).toList() - List inventories = warehouseInventoryRepository.findAll().toList() - - then: - inserted.collect { it.sku } as Set == ["SKU-200"] as Set - inserted.collect { it.warehouse } as Set == ["Berlin", "Paris"] as Set - inserted.collect { it.quantity } as Set == [5, 8] as Set - inventories.size() == 2 - inventories.find { it.sku == "SKU-200" && it.warehouse == "Berlin" }.id != null - inventories.find { it.sku == "SKU-200" && it.warehouse == "Berlin" }.quantity == 5 - inventories.find { it.sku == "SKU-200" && it.warehouse == "Paris" }.id != null - inventories.find { it.sku == "SKU-200" && it.warehouse == "Paris" }.quantity == 8 - assertReturnedWarehouseInventoryIdsIfPresent(inserted, inventories) - - when: - Long berlinId = inventories.find { it.sku == "SKU-200" && it.warehouse == "Berlin" }.id - Long parisId = inventories.find { it.sku == "SKU-200" && it.warehouse == "Paris" }.id - List updated = warehouseInventoryRepository.upsertAll([ - new WarehouseInventory("SKU-200", "Berlin", 7), - new WarehouseInventory("SKU-200", "Paris", 11) - ]).toList() - inventories = warehouseInventoryRepository.findAll().toList() - - then: - assertReturnedWarehouseInventoryIdsIfPresent(updated, inventories) - updated.collect { it.sku } as Set == ["SKU-200"] as Set - updated.collect { it.warehouse } as Set == ["Berlin", "Paris"] as Set - updated.collect { it.quantity } as Set == [7, 11] as Set - inventories.size() == 2 - inventories.find { it.sku == "SKU-200" && it.warehouse == "Berlin" }.id == berlinId - inventories.find { it.sku == "SKU-200" && it.warehouse == "Berlin" }.quantity == 7 - inventories.find { it.sku == "SKU-200" && it.warehouse == "Paris" }.id == parisId - inventories.find { it.sku == "SKU-200" && it.warehouse == "Paris" }.quantity == 11 + 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) } private static void assertProductReview(ProductReview productReview1, ProductReview productReview2) { @@ -366,15 +408,9 @@ abstract class AbstractUpsertSpec extends Specification { assert customerProfile1.displayName == customerProfile2.displayName } - private static void assertReturnedWarehouseInventoryIdsIfPresent(List returned, List persisted) { - returned.each { WarehouseInventory warehouseInventory -> - if (warehouseInventory.id != null) { - WarehouseInventory persistedWarehouseInventory = persisted.find { - it.sku == warehouseInventory.sku && it.warehouse == warehouseInventory.warehouse - } - assert persistedWarehouseInventory != null - assert warehouseInventory.id == persistedWarehouseInventory.id - } - } + 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/repositories/upsert/CustomerProfileRepository.java b/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/CustomerProfileRepository.java index 9369fa3af4f..25ab73bc659 100644 --- 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 @@ -28,4 +28,10 @@ public interface CustomerProfileRepository extends CrudRepository upsertAll(Iterable customerProfiles); + + @Upsert(conflictProperties = "email") + void upsertNoResult(CustomerProfile customerProfile); + + @Upsert(conflictProperties = "email") + void upsertAllNoResult(Iterable customerProfiles); } From ddf0f9e1ce303406fced9dfc1fed6df0b34013b5 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 18 Jun 2026 18:11:03 +0200 Subject: [PATCH 17/57] Upsert implementation - added more tests --- .../visitors/finders/UpsertMethodMatcher.java | 12 ++- .../data/tck/tests/AbstractUpsertSpec.groovy | 77 +++++++++++++------ .../upsert/CustomerProfileRepository.java | 28 ++++++- 3 files changed, 90 insertions(+), 27 deletions(-) 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 index 63b999e8621..5d0e0587d0b 100644 --- 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 @@ -81,7 +81,7 @@ protected MethodMatch match(MethodMatchContext matchContext, List conflictProperties(MethodMatchContext matchContext) { 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 index cf8f1a17cdd..a2b6b6bc9b6 100644 --- 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 @@ -200,12 +200,12 @@ abstract class AbstractUpsertSpec extends Specification { assertProductReview(found2, pr2) } - void "upsert by email conflict returns entity"() { + void "#methodName by email conflict returns entity"() { given: CustomerProfile cp = new CustomerProfile("test@example.com", "test") when: - CustomerProfile inserted = customerProfileRepository.upsert(cp) + CustomerProfile inserted = upsertMethod(cp) then: inserted.id != null @@ -219,7 +219,7 @@ abstract class AbstractUpsertSpec extends Specification { when: cp.setDisplayName("test modified") - CustomerProfile updated = customerProfileRepository.upsert(cp) + CustomerProfile updated = upsertMethod(cp) then: updated == cp @@ -229,15 +229,50 @@ abstract class AbstractUpsertSpec extends Specification { then: assertCustomerProfile(cp, found) + + 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) + List found = customerProfileRepository.findAll() + + then: + found.size() == 1 + found.get(0).id != null + assertCustomerProfile(found.get(0), cp) + + when: + cp.setDisplayName("test modified") + upsertMethod(cp) + found = customerProfileRepository.findAll() + + then: + found.get(0).id != null + assertCustomerProfile(found.get(0), 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 "upsertAll by email conflict returns entities"() { + 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 = customerProfileRepository.upsertAll([cp1, cp2]).toList() + List inserted = upsertMethod([cp1, cp2]) then: inserted.size() == 2 @@ -257,7 +292,7 @@ abstract class AbstractUpsertSpec extends Specification { when: cp1.setDisplayName("test 1 modified") cp2.setDisplayName("test 2 modified") - List updated = customerProfileRepository.upsertAll([cp1, cp2]).toList() + List updated = upsertMethod([cp1, cp2]) then: updated.size() == 2 @@ -271,30 +306,24 @@ abstract class AbstractUpsertSpec extends Specification { then: assertCustomerProfile(found1, cp1) assertCustomerProfile(found2, cp2) + + where: + methodName | upsertMethod + "upsertAll" | { Iterable profiles -> customerProfileRepository.upsertAll(profiles) } + "upsertAllMono" | { Iterable profiles -> customerProfileRepository.upsertAllMono(profiles).block() } + "upsertAllFuture" | { Iterable profiles -> customerProfileRepository.upsertAllFuture(profiles).get() } } - void "upsert by email conflict does not return entity"() { - given: - CustomerProfile cp = new CustomerProfile("test@example.com", "test") - when: - customerProfileRepository.upsertNoResult(cp) - List found = customerProfileRepository.findAll() - then: - found.size() == 1 - found.get(0).id != null - assertCustomerProfile(found.get(0), cp) - when: - cp.setDisplayName("test modified") - customerProfileRepository.upsertNoResult(cp) - found = customerProfileRepository.findAll() - then: - found.get(0).id != null - assertCustomerProfile(found.get(0), cp) - } + + + + + + void "upsertAll by email conflict does not return entities"() { given: 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 index 25ab73bc659..b4a595587c5 100644 --- 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 @@ -18,8 +18,10 @@ 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.Mono; import java.util.List; +import java.util.concurrent.CompletableFuture; public interface CustomerProfileRepository extends CrudRepository { @@ -27,11 +29,35 @@ public interface CustomerProfileRepository extends CrudRepository upsertAll(Iterable customerProfiles); + Mono upsertMono(CustomerProfile profile); + + @Upsert(conflictProperties = "email") + CompletableFuture upsertFuture(CustomerProfile profile); @Upsert(conflictProperties = "email") void upsertNoResult(CustomerProfile customerProfile); + @Upsert(conflictProperties = "email") + Mono upsertMonoNoResult(CustomerProfile customerProfile); + + @Upsert(conflictProperties = "email") + CompletableFuture upsertFutureNoResult(CustomerProfile profile); + + @Upsert(conflictProperties = "email") + List upsertAll(Iterable customerProfiles); + + @Upsert(conflictProperties = "email") + Mono> upsertAllMono(Iterable profiles); + + @Upsert(conflictProperties = "email") + CompletableFuture> upsertAllFuture(Iterable profiles); + + + @Upsert(conflictProperties = "email") void upsertAllNoResult(Iterable customerProfiles); + + + + } From 44d7dc3fdf7e0713b10bb2d0e6c2dbf2f63be954 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 18 Jun 2026 18:45:35 +0200 Subject: [PATCH 18/57] Upsert implementation - added more tests --- .../data/tck/tests/AbstractUpsertSpec.groovy | 118 ++++-------------- .../upsert/CustomerProfileRepository.java | 11 +- 2 files changed, 31 insertions(+), 98 deletions(-) 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 index a2b6b6bc9b6..50266eb9ae6 100644 --- 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 @@ -54,12 +54,12 @@ abstract class AbstractUpsertSpec extends Specification { protected void cleanupAdditionalRepositories() { } - void "upsert method inserts and updates product review by assigned ID"() { + void "#methodName inserts and updates product review by assigned ID"() { given: ProductReview pr = new ProductReview(1L, "title new", "content new") when: - ProductReview inserted = productReviewRepository.upsert(pr) + ProductReview inserted = upsertMethod(pr) then: inserted == pr @@ -73,7 +73,7 @@ abstract class AbstractUpsertSpec extends Specification { when: pr.setTitle("title modified") pr.setContent("content modified") - ProductReview updated = productReviewRepository.upsert(pr) + ProductReview updated = upsertMethod(pr) then: updated == pr @@ -83,88 +83,20 @@ abstract class AbstractUpsertSpec extends Specification { then: assertProductReview(found, pr) - } - - void "upsertAll method 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 = productReviewRepository.upsertAll([pr1, pr2]).toList() - - 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 = productReviewRepository.upsertAll([pr1, pr2]).toList() - - 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) - } - - void "upsert annotation inserts and updates product review by assigned ID"() { - given: - ProductReview pr = new ProductReview(1L, "title new", "content new") - - when: - ProductReview inserted = productReviewRepository.put(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 = productReviewRepository.put(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 "upsert annotation inserts and updates product reviews by assigned ID"() { + 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 = productReviewRepository.putAll([pr1, pr2]).toList() + List insertedList = upsertMethod([pr1, pr2]) then: insertedList.size() == 2 @@ -184,7 +116,7 @@ abstract class AbstractUpsertSpec extends Specification { pr1.setContent("content 1 modified") pr2.setTitle("title 2 modified") pr2.setContent("content 2 modified") - List updatedList = productReviewRepository.putAll([pr1, pr2]).toList() + List updatedList = upsertMethod([pr1, pr2]) then: updatedList.size() == 2 @@ -198,6 +130,11 @@ abstract class AbstractUpsertSpec extends Specification { 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"() { @@ -310,28 +247,17 @@ abstract class AbstractUpsertSpec extends Specification { where: methodName | upsertMethod "upsertAll" | { Iterable profiles -> customerProfileRepository.upsertAll(profiles) } - "upsertAllMono" | { Iterable profiles -> customerProfileRepository.upsertAllMono(profiles).block() } + "upsertAllFlux" | { Iterable profiles -> customerProfileRepository.upsertAllFlux(profiles).collectList().block() } "upsertAllFuture" | { Iterable profiles -> customerProfileRepository.upsertAllFuture(profiles).get() } } - - - - - - - - - - - - void "upsertAll by email conflict does not return entities"() { + 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: - customerProfileRepository.upsertAllNoResult([cp1, cp2]) + upsertMethod([cp1, cp2]) List found = customerProfileRepository.findAll() then: @@ -344,13 +270,19 @@ abstract class AbstractUpsertSpec extends Specification { when: cp1.setDisplayName("test 1 modified") cp2.setDisplayName("test 2 modified") - customerProfileRepository.upsertAllNoResult([cp1, cp2]) + upsertMethod([cp1, cp2]) found = customerProfileRepository.findAll() then: found.size() == 2 assertCustomerProfile(found.get(0), cp1) assertCustomerProfile(found.get(1), cp2) + + 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 sku and warehouse conflict properties"() { 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 index b4a595587c5..f1ffb117106 100644 --- 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 @@ -18,6 +18,7 @@ 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; @@ -47,17 +48,17 @@ public interface CustomerProfileRepository extends CrudRepository upsertAll(Iterable customerProfiles); @Upsert(conflictProperties = "email") - Mono> upsertAllMono(Iterable profiles); + Flux upsertAllFlux(Iterable profiles); @Upsert(conflictProperties = "email") CompletableFuture> upsertAllFuture(Iterable profiles); - - @Upsert(conflictProperties = "email") void upsertAllNoResult(Iterable customerProfiles); + @Upsert(conflictProperties = "email") + Flux upsertAllFluxNoResult(Iterable profiles); - - + @Upsert(conflictProperties = "email") + CompletableFuture upsertAllFutureNoResult(Iterable profiles); } From 220d4e663bc079440ca6e6fbebbf34dda685de2d Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 19 Jun 2026 09:41:22 +0200 Subject: [PATCH 19/57] Upsert implementation - refactoring of OracleJdbcRepositoryOperations --- .../OracleJdbcRepositoryOperations.java | 97 +++++++++++-------- 1 file changed, 57 insertions(+), 40 deletions(-) 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 index 7cc4224c999..ed234d9e326 100644 --- 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 @@ -26,8 +26,9 @@ 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.runtime.QueryOutParameterBinding; 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; @@ -54,6 +55,7 @@ import java.util.Arrays; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.concurrent.ExecutorService; /** @@ -164,6 +166,36 @@ private void registerReturnParameters(OraclePreparedStatement ps, } } + private boolean shouldUseOracleUpsertReturning(SqlStoredQuery storedQuery) { + return isUpsertOperation(storedQuery) && !CollectionUtils.isEmpty(storedQuery.getOutParameterBindings()); + } + + 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 List readReturnedIds(OraclePreparedStatement oraclePreparedStatement, + RuntimePersistentProperty identity, + SqlStoredQuery storedQuery) throws SQLException { + List ids = new ArrayList<>(); + try (ResultSet returnedIds = oraclePreparedStatement.getReturnResultSet()) { + while (returnedIds.next()) { + ids.add(getGeneratedIdentity(returnedIds, 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); @@ -171,26 +203,22 @@ protected OracleJdbcEntityOperations(JdbcOperationContext ctx, RuntimePersistent @Override protected void execute() throws SQLException { - if (!isUpsertOperation(storedQuery) || CollectionUtils.isEmpty(storedQuery.getOutParameterBindings())) { + if (!shouldUseOracleUpsertReturning(storedQuery)) { super.execute(); return; } QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); try (PreparedStatement ps = ctx.connection.prepareStatement(storedQuery.getQuery())) { - OraclePreparedStatement oraclePreparedStatement = ps.unwrap(OraclePreparedStatement.class); - JdbcParameterBinder parameterBinder = new JdbcParameterBinder(ctx.connection, ps, storedQuery); - storedQuery.bindParameters(parameterBinder, ctx.invocationContext, entity, previousValues); - registerReturnParameters(oraclePreparedStatement, storedQuery, parameterBinder.currentIndex() - 1); + OraclePreparedStatement oraclePreparedStatement = unwrapOraclePreparedStatement(ps); + int inCount = bindParameters(ps, ctx, storedQuery, entity, previousValues); + registerReturnParameters(oraclePreparedStatement, storedQuery, inCount); rowsUpdated = oraclePreparedStatement.executeUpdate(); - try (ResultSet returnedIds = oraclePreparedStatement.getReturnResultSet()) { - if (returnedIds.next()) { - RuntimePersistentProperty identity = persistentEntity.getIdentity(); - Object id = getGeneratedIdentity(returnedIds, identity, storedQuery.getDialect()); - entity = updateEntityId(identity.getProperty(), entity, id); - } else { - throw new DataAccessException("Oracle upsert RETURNING clause produced no generated ID for entity: " + entity); - } + RuntimePersistentProperty identity = persistentEntity.getIdentity(); + List ids = readReturnedIds(oraclePreparedStatement, identity, storedQuery); + if (ids.isEmpty()) { + throw new DataAccessException("Oracle upsert RETURNING clause produced no generated ID for entity: " + entity); } + entity = updateEntityId(identity.getProperty(), entity, ids.get(0)); } catch (SQLException e) { DataAccessException dataAccessException = mapSqlException(e, ctx.dialect); if (dataAccessException != null) { @@ -208,54 +236,43 @@ protected OracleJdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersiste @Override protected void execute() { - if (!isUpsertOperation(storedQuery) || CollectionUtils.isEmpty(storedQuery.getOutParameterBindings())) { + if (!shouldUseOracleUpsertReturning(storedQuery)) { super.execute(); return; } QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); - long notVetoedCount = countNotVetoedEntities(); - if (notVetoedCount == 0) { + List notVetoedEntities = notVetoedEntities(); + if (notVetoedEntities.isEmpty()) { rowsUpdated = 0; return; } try (PreparedStatement ps = ctx.connection.prepareStatement(storedQuery.getQuery())) { - OraclePreparedStatement oraclePreparedStatement = ps.unwrap(OraclePreparedStatement.class); + OraclePreparedStatement oraclePreparedStatement = unwrapOraclePreparedStatement(ps); boolean returnParametersRegistered = false; - for (Data d : entities) { - if (d.vetoed) { - continue; - } - JdbcParameterBinder parameterBinder = new JdbcParameterBinder(ctx.connection, ps, storedQuery); - storedQuery.bindParameters(parameterBinder, ctx.invocationContext, d.entity, d.previousValues); + for (Data d : notVetoedEntities) { + int inCount = bindParameters(ps, ctx, storedQuery, d.entity, d.previousValues); if (!returnParametersRegistered) { - registerReturnParameters(oraclePreparedStatement, storedQuery, parameterBinder.currentIndex() - 1); + registerReturnParameters(oraclePreparedStatement, storedQuery, inCount); returnParametersRegistered = true; } ps.addBatch(); } rowsUpdated = Arrays.stream(ps.executeBatch()).sum(); - updateEntityIdsFromReturnedIds(oraclePreparedStatement); + updateEntityIdsFromReturnedIds(oraclePreparedStatement, notVetoedEntities); } catch (SQLException e) { throw new DataAccessException("Error executing batch Oracle SQL RETURNING: " + e.getMessage(), e); } } - private void updateEntityIdsFromReturnedIds(OraclePreparedStatement oraclePreparedStatement) throws SQLException { + private void updateEntityIdsFromReturnedIds(OraclePreparedStatement oraclePreparedStatement, + List notVetoedEntities) throws SQLException { RuntimePersistentProperty identity = persistentEntity.getIdentity(); - List ids = new ArrayList<>(); - try (ResultSet returnedIds = oraclePreparedStatement.getReturnResultSet()) { - while (returnedIds.next()) { - ids.add(getGeneratedIdentity(returnedIds, identity, storedQuery.getDialect())); - } - } + List ids = readReturnedIds(oraclePreparedStatement, identity, storedQuery); Iterator iterator = ids.iterator(); int updated = 0; - for (Data d : entities) { - if (d.vetoed) { - continue; - } + for (Data d : notVetoedEntities) { if (!iterator.hasNext()) { - throw new DataAccessException("Oracle upsert RETURNING clause produced " + updated + " generated IDs for " + countNotVetoedEntities() + " entities"); + 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); @@ -266,8 +283,8 @@ private void updateEntityIdsFromReturnedIds(OraclePreparedStatement oraclePrepar } } - private long countNotVetoedEntities() { - return entities.stream().filter(d -> !d.vetoed).count(); + private List notVetoedEntities() { + return entities.stream().filter(d -> !d.vetoed).toList(); } } } From 95af8a7ae9f9db6039137af268666b58b9d42302 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 19 Jun 2026 12:18:39 +0200 Subject: [PATCH 20/57] Upsert implementation - wip --- .../OracleJdbcRepositoryOperations.java | 29 ++++++-- .../jdbc/oraclexe/OracleXEUpsertSpec.groovy | 14 +++- .../visitors/finders/UpsertMethodMatcher.java | 13 +++- .../data/processor/sql/BuildInsertSpec.groovy | 23 +++++- .../data/tck/tests/AbstractUpsertSpec.groovy | 72 +++++++++++++------ 5 files changed, 116 insertions(+), 35 deletions(-) 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 index ed234d9e326..288a027e5eb 100644 --- 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 @@ -60,6 +60,15 @@ /** * 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) @@ -203,10 +212,14 @@ protected OracleJdbcEntityOperations(JdbcOperationContext ctx, RuntimePersistent @Override protected void execute() throws SQLException { - if (!shouldUseOracleUpsertReturning(storedQuery)) { + if (shouldUseOracleUpsertReturning(storedQuery)) { + upsert(); + } else { super.execute(); - return; } + } + + 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); @@ -217,8 +230,10 @@ protected void execute() 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); } - entity = updateEntityId(identity.getProperty(), entity, ids.get(0)); + entity = updateEntityId(identity.getProperty(), entity, ids.getFirst()); } catch (SQLException e) { DataAccessException dataAccessException = mapSqlException(e, ctx.dialect); if (dataAccessException != null) { @@ -236,10 +251,14 @@ protected OracleJdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersiste @Override protected void execute() { - if (!shouldUseOracleUpsertReturning(storedQuery)) { + if (shouldUseOracleUpsertReturning(storedQuery)) { + upsert(); + } else { super.execute(); - return; } + } + + private void upsert() { QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); List notVetoedEntities = notVetoedEntities(); if (notVetoedEntities.isEmpty()) { 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 index 3190000f433..40cc3df5b59 100644 --- 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 @@ -113,20 +113,30 @@ class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleTestPropert when: cp1.setDisplayName("test 1 modified") cp2.setDisplayName("test 2 modified") - List updated = customerProfileSequenceRepository.upsertAll([cp1, cp2]).toList() + 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]).toList() then: - updated.size() == 2 + 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) { 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 index 5d0e0587d0b..24076d14569 100644 --- 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 @@ -26,6 +26,7 @@ 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; @@ -189,7 +190,7 @@ private MethodMatch upsertEntity() { mc.getAnnotationMetadata() ); List conflictProperties = conflictProperties(mc); - boolean returnGeneratedId = shouldReturnGeneratedId(mc, entityParameter); + boolean returnGeneratedId = shouldUseOracleGeneratedIdReturning(mc, entityParameter); QueryResult queryResult = mc.getQueryBuilder().buildUpsert(annotationMetadataHierarchy, new QueryBuilder.UpsertQueryDefinition() { @Override public SourcePersistentEntity persistentEntity() { @@ -220,13 +221,19 @@ public boolean returnGeneratedId() { }; } - private boolean shouldReturnGeneratedId(MethodMatchContext matchContext, - @Nullable ParameterElement entityParameter) { + private boolean shouldUseOracleGeneratedIdReturning(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) || sqlQueryBuilder.getDialect() != Dialect.ORACLE) { + return false; + } + if (TypeUtils.doesReturnVoid(matchContext.getMethodElement())) { + return true; + } ClassElement returnType = TypeUtils.getMethodProducingItemType(matchContext.getMethodElement()); return returnType != null && (entityUpsert ? TypeUtils.isEntity(returnType) : producesEntityOrIterableOfEntity(returnType)); 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 9d25672d80f..9f572d44767 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 @@ -712,12 +712,24 @@ 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(conflictProperties = "name") Test put(Test test); + + @Upsert(conflictProperties = "name") + Mono putMono(Test test); + + @Upsert(conflictProperties = "name") + Flux putFlux(List tests); + + @Upsert(conflictProperties = "name") + void putNoResult(Test test); } @MappedEntity("upsert_test") @@ -756,11 +768,16 @@ class Test { 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: - getQuery(putMethod) == '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 ?' - getParameterPropertyPaths(putMethod) == ["name", "pages"] as String[] - getOutBindingParameters(putMethod)*.name == ["id"] + [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"] 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 index 50266eb9ae6..b5be317d184 100644 --- 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 @@ -152,7 +152,7 @@ abstract class AbstractUpsertSpec extends Specification { CustomerProfile found = customerProfileRepository.findById(cp.id).get() then: - assertCustomerProfile(cp, found) + assertCustomerProfile(found, cp) when: cp.setDisplayName("test modified") @@ -165,7 +165,7 @@ abstract class AbstractUpsertSpec extends Specification { found = customerProfileRepository.findById(cp.id).get() then: - assertCustomerProfile(cp, found) + assertCustomerProfile(found, cp) where: methodName | upsertMethod @@ -180,21 +180,23 @@ abstract class AbstractUpsertSpec extends Specification { when: upsertMethod(cp) - List found = customerProfileRepository.findAll() then: - found.size() == 1 - found.get(0).id != null - assertCustomerProfile(found.get(0), cp) + cp.id != null + + when: + CustomerProfile found = customerProfileRepository.findById(cp.id).get() + + then: + assertCustomerProfile(found, cp) when: cp.setDisplayName("test modified") upsertMethod(cp) - found = customerProfileRepository.findAll() + found = customerProfileRepository.findById(cp.id).get() then: - found.get(0).id != null - assertCustomerProfile(found.get(0), cp) + assertCustomerProfile(found, cp) where: methodName | upsertMethod @@ -229,20 +231,30 @@ abstract class AbstractUpsertSpec extends Specification { when: cp1.setDisplayName("test 1 modified") cp2.setDisplayName("test 2 modified") - List updated = upsertMethod([cp1, cp2]) + 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() == 2 + 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 @@ -258,25 +270,41 @@ abstract class AbstractUpsertSpec extends Specification { when: upsertMethod([cp1, cp2]) - List found = customerProfileRepository.findAll() then: - found.size() == 2 - found.get(0).id != null - found.get(1).id != null - assertCustomerProfile(found.get(0), cp1) - assertCustomerProfile(found.get(1), cp2) + 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") - upsertMethod([cp1, cp2]) - found = customerProfileRepository.findAll() + 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: - found.size() == 2 - assertCustomerProfile(found.get(0), cp1) - assertCustomerProfile(found.get(1), cp2) + assertCustomerProfile(found1, cp1) + assertCustomerProfile(found2, cp2) + assertCustomerProfile(found3, cp3) + assertCustomerProfile(found4, cp4) where: methodName | upsertMethod From 138528927e34a46b2206f2324def255dd5b99181 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 19 Jun 2026 12:26:19 +0200 Subject: [PATCH 21/57] Upsert implementation - updated upsert tests --- .../groovy/io/micronaut/data/jdbc/h2/H2UpsertSpec.groovy | 5 +++++ .../io/micronaut/data/jdbc/mariadb/MariaUpsertSpec.groovy | 5 +++++ .../io/micronaut/data/jdbc/mysql/MySqlUpsertSpec.groovy | 5 +++++ .../micronaut/data/jdbc/postgres/PostgresUpsertSpec.groovy | 5 +++++ .../micronaut/data/jdbc/sqlserver/SqlServerUpsertSpec.groovy | 5 +++++ .../groovy/io/micronaut/data/r2dbc/h2/H2UpsertSpec.groovy | 5 +++++ .../io/micronaut/data/r2dbc/mariadb/MariaDbUpsertSpec.groovy | 5 +++++ .../io/micronaut/data/r2dbc/mysql/MySqlUpsertSpec.groovy | 5 +++++ .../micronaut/data/r2dbc/oraclexe/OracleXEUpsertSpec.groovy | 5 +++++ .../micronaut/data/r2dbc/postgres/PostgresUpsertSpec.groovy | 5 +++++ .../data/r2dbc/sqlserver/SqlServerUpsertSpec.groovy | 5 +++++ 11 files changed, 55 insertions(+) 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 index 2ebf361930a..96ea41ac747 100644 --- 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 @@ -39,4 +39,9 @@ class H2UpsertSpec extends AbstractUpsertSpec implements H2TestPropertyProvider 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 index 21ee071f73c..903dd7ff138 100644 --- 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 @@ -39,4 +39,9 @@ class MariaUpsertSpec extends AbstractUpsertSpec implements MariaTestPropertyPro WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(MySqlWarehouseInventoryRepository) } + + @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/mysql/MySqlUpsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlUpsertSpec.groovy index 51a61560192..bf119b9d1e2 100644 --- 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 @@ -39,4 +39,9 @@ class MySqlUpsertSpec extends AbstractUpsertSpec implements MySQLTestPropertyPro WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(MySqlWarehouseInventoryRepository) } + + @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/postgres/PostgresUpsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/postgres/PostgresUpsertSpec.groovy index b20c29515e4..9e663d6589e 100644 --- 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 @@ -39,4 +39,9 @@ class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPrope WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(PostgresWarehouseInventoryRepository) } + + @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/sqlserver/SqlServerUpsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/sqlserver/SqlServerUpsertSpec.groovy index db58eb83dd3..aa35c58edf4 100644 --- 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 @@ -39,4 +39,9 @@ class SqlServerUpsertSpec extends AbstractUpsertSpec implements MSSQLTestPropert WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(MSWarehouseInventoryRepository) } + + @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/h2/H2UpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2UpsertSpec.groovy index 55f86ba4f91..390660f5b34 100644 --- 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 @@ -39,4 +39,9 @@ class H2UpsertSpec extends AbstractUpsertSpec implements H2TestPropertyProvider 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 index 94a1b73e63c..8834c8f07fd 100644 --- 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 @@ -39,4 +39,9 @@ class MariaDbUpsertSpec extends AbstractUpsertSpec implements MariaDbTestPropert WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(MySqlWarehouseInventoryRepository) } + + @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/mysql/MySqlUpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlUpsertSpec.groovy index a11077fc995..d683e5d11e3 100644 --- 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 @@ -39,4 +39,9 @@ class MySqlUpsertSpec extends AbstractUpsertSpec implements MySqlTestPropertyPro WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(MySqlWarehouseInventoryRepository) } + + @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/oraclexe/OracleXEUpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/OracleXEUpsertSpec.groovy index e14d917eb44..808413c824d 100644 --- 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 @@ -39,4 +39,9 @@ class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleXETestPrope WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(OracleXEWarehouseInventoryRepository) } + + @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/postgres/PostgresUpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresUpsertSpec.groovy index 060018287a5..2882df23fb1 100644 --- 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 @@ -39,4 +39,9 @@ class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPrope WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(PostgresWarehouseInventoryRepository) } + + @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/sqlserver/SqlServerUpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/sqlserver/SqlServerUpsertSpec.groovy index 5081d0d4e36..1a218c8ef8c 100644 --- 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 @@ -39,4 +39,9 @@ class SqlServerUpsertSpec extends AbstractUpsertSpec implements SqlServerTestPro WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(MSWarehouseInventoryRepository) } + + @Override + List packages() { + return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert") + } } From 8930e150c315bc3d53c7afa0064a4a50e6ea7894 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 19 Jun 2026 16:52:51 +0200 Subject: [PATCH 22/57] Upsert implementation - sql server upsert implementation when returning ids --- .../DefaultJdbcRepositoryOperations.java | 2 +- .../JdbcRepositoryOperationsConditions.java | 42 ++- .../OracleJdbcRepositoryOperations.java | 46 ++- .../SqlServerJdbcRepositoryOperations.java | 264 ++++++++++++++++++ .../query/builder/sql/SqlQueryBuilder.java | 174 +++++++----- .../visitors/finders/UpsertMethodMatcher.java | 12 +- .../data/processor/sql/BuildInsertSpec.groovy | 14 +- 7 files changed, 449 insertions(+), 105 deletions(-) create mode 100644 data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/SqlServerJdbcRepositoryOperations.java 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 8eb6caa0dc1..3292ad60425 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 @@ -1215,7 +1215,7 @@ protected JdbcEntitiesOperations getJdbcEntitiesOperations(JdbcOperationC * @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; 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 index 8e512179f29..66700ef36b5 100644 --- 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 @@ -24,20 +24,21 @@ import io.micronaut.inject.BeanDefinition; /** - * Condition that enables the default JDBC repository operations for non-Oracle datasources. + * 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 the Oracle dialect. + * 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.isOracleDialect(context); + return !JdbcRepositoryOperationsConditions.isOracleDialect(context) + && !JdbcRepositoryOperationsConditions.isSqlServerDialect(context); } } @@ -59,6 +60,24 @@ public boolean matches(ConditionContext 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. */ @@ -68,6 +87,7 @@ 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() { @@ -80,10 +100,24 @@ private JdbcRepositoryOperationsConditions() { * @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); + } + + private static boolean isDialect(ConditionContext context, String expectedDialect) { String dataSourceName = resolveDataSourceName(context); String dialectProperty = DATASOURCES + '.' + dataSourceName + '.' + DIALECT; String dialect = context.getProperty(dialectProperty, String.class).orElse(null); - return ORACLE_DIALECT.equalsIgnoreCase(dialect); + 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 index 288a027e5eb..abee9a2c47e 100644 --- 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 @@ -26,6 +26,7 @@ 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; @@ -157,6 +158,12 @@ protected JdbcEntitiesOperations getJdbcEntitiesOperations(JdbcOperationC 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 { @@ -175,10 +182,6 @@ private void registerReturnParameters(OraclePreparedStatement ps, } } - private boolean shouldUseOracleUpsertReturning(SqlStoredQuery storedQuery) { - return isUpsertOperation(storedQuery) && !CollectionUtils.isEmpty(storedQuery.getOutParameterBindings()); - } - private OraclePreparedStatement unwrapOraclePreparedStatement(PreparedStatement ps) throws SQLException { return ps.unwrap(OraclePreparedStatement.class); } @@ -193,13 +196,26 @@ private int bindParameters(PreparedStatement ps, 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 returnedIds = oraclePreparedStatement.getReturnResultSet()) { - while (returnedIds.next()) { - ids.add(getGeneratedIdentity(returnedIds, identity, storedQuery.getDialect())); + try (ResultSet resultSet = oraclePreparedStatement.getReturnResultSet()) { + while (resultSet.next()) { + ids.add(getGeneratedIdentity(resultSet, identity, storedQuery.getDialect())); } } return ids; @@ -227,13 +243,8 @@ private void upsert() throws SQLException { registerReturnParameters(oraclePreparedStatement, storedQuery, inCount); rowsUpdated = oraclePreparedStatement.executeUpdate(); RuntimePersistentProperty identity = persistentEntity.getIdentity(); - 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); - } - entity = updateEntityId(identity.getProperty(), entity, ids.getFirst()); + 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) { @@ -279,7 +290,12 @@ private void upsert() { rowsUpdated = Arrays.stream(ps.executeBatch()).sum(); updateEntityIdsFromReturnedIds(oraclePreparedStatement, notVetoedEntities); } catch (SQLException e) { - throw new DataAccessException("Error executing batch Oracle SQL RETURNING: " + e.getMessage(), e); + throw sqlExceptionToDataAccessException(e, ctx.dialect, + sqlException -> new DataAccessException( + "Error executing upsert statement: " + sqlException.getMessage(), + sqlException + ) + ); } } 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..d6baa4c2bf5 --- /dev/null +++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/SqlServerJdbcRepositoryOperations.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.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.annotation.Internal; +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) +@Internal +public 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 + */ + @Internal + @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-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 3aa2d574eff..105b43d1334 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 @@ -121,6 +121,7 @@ public class SqlQueryBuilder extends AbstractSqlLikeQueryBuilder { private static final String BLANK_SPACE = " "; private static final String INSERT_INTO = "INSERT INTO "; private static final String JDBC_REPO_ANNOTATION = "io.micronaut.data.jdbc.annotation.JdbcRepository"; + private static final String R2DBC_REPO_ANNOTATION = "io.micronaut.data.r2dbc.annotation.R2dbcRepository"; private static final String DIALECT_ATTR = "dialect"; private static final String REFERENCED_COLUMN_NAME = "referencedColumnName"; @@ -1391,25 +1392,49 @@ public QueryResult buildUpsert(AnnotationMetadata repositoryMetadata, UpsertQuer } UpsertData data = buildUpsertData(entity, definition.conflictProperties()); String tableName = getTableName(entity); + List returningColumns = Collections.emptyList(); + String sqlServerOutputColumn = null; + if (definition.returnGeneratedId() && (dialect == Dialect.ORACLE || dialect == Dialect.SQL_SERVER)) { + returningColumns = resolveGeneratedIdentityUpsertReturningColumns(entity); + if (!returningColumns.isEmpty()) { + 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()); + } + if (dialect == Dialect.SQL_SERVER) { + sqlServerOutputColumn = returningColumns.get(0).column(); + } + } + } String query = switch (dialect) { case H2 -> buildH2Upsert(tableName, data); case MYSQL -> buildMySqlUpsert(tableName, data); case POSTGRES -> buildPostgresUpsert(tableName, data); - case SQL_SERVER -> buildSqlServerUpsert(tableName, data); + case SQL_SERVER -> buildSqlServerUpsert(tableName, data, sqlServerOutputColumn); case ORACLE -> buildOracleUpsert(tableName, data); case ANSI -> buildAnsiUpsert(tableName, data); }; List parameterBindings = buildUpsertParameterBindings(data); + if (definition.returnGeneratedId() && dialect == Dialect.SQL_SERVER) { + if (!returningColumns.isEmpty()) { + return QueryResult.of( + query, + Collections.emptyList(), + parameterBindings, + buildUpsertOutParameterBindings(returningColumns), + Collections.emptyMap() + ); + } + } if (definition.returnGeneratedId() && dialect == Dialect.ORACLE) { - List returningColumns = resolveGeneratedIdentityUpsertReturningColumns(entity); if (!returningColumns.isEmpty()) { - if (returningColumns.size() > 1) { - throw new IllegalStateException("Oracle MERGE ... RETURNING supports a single generated identity for entity: " + entity.getName()); - } UpsertReturningColumn returningColumn = returningColumns.get(0); String outPlaceholder = formatParameter(parameterBindings.size() + 1).name(); query = query + " RETURNING " + returningColumn.column() + " INTO " + outPlaceholder; + if (repositoryMetadata.hasStereotype(R2DBC_REPO_ANNOTATION)) { + query = "BEGIN " + query + "; END;"; + } return QueryResult.of( query, Collections.emptyList(), @@ -1603,12 +1628,13 @@ private String buildPostgresUpsert(String tableName, UpsertData data) { .collect(Collectors.joining(String.valueOf(COMMA))); } - private String buildSqlServerUpsert(String tableName, UpsertData data) { + private String buildSqlServerUpsert(String tableName, UpsertData data, @Nullable String outputColumn) { return "MERGE INTO " + tableName + " WITH (HOLDLOCK) AS target " + "USING (VALUES (" + data.sourceValueExpressions() + ")) AS source (" + data.sourceColumns() + ") " + "ON " + upsertConflictPredicate(data) + upsertMatchedClause(data) + upsertInsertClause(data) + + (outputColumn == null ? "" : " OUTPUT inserted." + outputColumn) + ";"; } @@ -1695,74 +1721,6 @@ private String upsertInsertClause(UpsertData data) { + 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().get(0)) : 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) { - } - private QueryParameterBinding createParameterBinding(String key, PersistentProperty property, String[] path) { return new QueryParameterBinding() { @Override @@ -2530,6 +2488,74 @@ private void addToCollectionIfNotContains(Collection collection, T item) collection.add(item); } + 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().get(0)) : 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) { + } + private static final class DialectConfig { @Nullable Boolean escapeQueries; 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 index 24076d14569..c8fffc0d8ea 100644 --- 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 @@ -190,7 +190,7 @@ private MethodMatch upsertEntity() { mc.getAnnotationMetadata() ); List conflictProperties = conflictProperties(mc); - boolean returnGeneratedId = shouldUseOracleGeneratedIdReturning(mc, entityParameter); + boolean returnGeneratedId = shouldUseGeneratedIdReturning(mc, entityParameter); QueryResult queryResult = mc.getQueryBuilder().buildUpsert(annotationMetadataHierarchy, new QueryBuilder.UpsertQueryDefinition() { @Override public SourcePersistentEntity persistentEntity() { @@ -221,14 +221,18 @@ public boolean returnGeneratedId() { }; } - private boolean shouldUseOracleGeneratedIdReturning(MethodMatchContext matchContext, - @Nullable ParameterElement entityParameter) { + 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) || sqlQueryBuilder.getDialect() != Dialect.ORACLE) { + 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())) { 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 9f572d44767..0526a28e148 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 @@ -695,13 +695,13 @@ class Test { 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.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);' | ["name", "pages"] | [] | [] + 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.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 From ca5f2a589ba3e54da3c0be13833df18cec1063ee Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 19 Jun 2026 17:23:34 +0200 Subject: [PATCH 23/57] Upsert implementation - SqlQueryBuilder refactoring --- .../query/builder/sql/SqlQueryBuilder.java | 417 +--------------- .../builder/sql/SqlUpsertQueryBuilder.java | 469 ++++++++++++++++++ 2 files changed, 474 insertions(+), 412 deletions(-) create mode 100644 data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlUpsertQueryBuilder.java 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 105b43d1334..87eec908039 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 @@ -121,7 +121,6 @@ public class SqlQueryBuilder extends AbstractSqlLikeQueryBuilder { private static final String BLANK_SPACE = " "; private static final String INSERT_INTO = "INSERT INTO "; private static final String JDBC_REPO_ANNOTATION = "io.micronaut.data.jdbc.annotation.JdbcRepository"; - private static final String R2DBC_REPO_ANNOTATION = "io.micronaut.data.r2dbc.annotation.R2dbcRepository"; private static final String DIALECT_ATTR = "dialect"; private static final String REFERENCED_COLUMN_NAME = "referencedColumnName"; @@ -1380,348 +1379,10 @@ public DataType getDataType() { @Override public QueryResult buildUpsert(AnnotationMetadata repositoryMetadata, UpsertQueryDefinition definition) { - PersistentEntity entity = definition.persistentEntity(); - if (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 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 = getTableName(entity); - List returningColumns = Collections.emptyList(); - String sqlServerOutputColumn = null; - if (definition.returnGeneratedId() && (dialect == Dialect.ORACLE || dialect == Dialect.SQL_SERVER)) { - returningColumns = resolveGeneratedIdentityUpsertReturningColumns(entity); - if (!returningColumns.isEmpty()) { - 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()); - } - if (dialect == Dialect.SQL_SERVER) { - sqlServerOutputColumn = returningColumns.get(0).column(); - } - } - } - String query = switch (dialect) { - case H2 -> buildH2Upsert(tableName, data); - case MYSQL -> buildMySqlUpsert(tableName, data); - case POSTGRES -> buildPostgresUpsert(tableName, data); - case SQL_SERVER -> buildSqlServerUpsert(tableName, data, sqlServerOutputColumn); - case ORACLE -> buildOracleUpsert(tableName, data); - case ANSI -> buildAnsiUpsert(tableName, data); - }; - - List parameterBindings = buildUpsertParameterBindings(data); - if (definition.returnGeneratedId() && dialect == Dialect.SQL_SERVER) { - if (!returningColumns.isEmpty()) { - return QueryResult.of( - query, - Collections.emptyList(), - parameterBindings, - buildUpsertOutParameterBindings(returningColumns), - Collections.emptyMap() - ); - } - } - if (definition.returnGeneratedId() && dialect == Dialect.ORACLE) { - if (!returningColumns.isEmpty()) { - UpsertReturningColumn returningColumn = returningColumns.get(0); - String outPlaceholder = formatParameter(parameterBindings.size() + 1).name(); - query = query + " RETURNING " + returningColumn.column() + " INTO " + outPlaceholder; - if (repositoryMetadata.hasStereotype(R2DBC_REPO_ANNOTATION)) { - query = "BEGIN " + query + "; END;"; - } - return QueryResult.of( - query, - Collections.emptyList(), - parameterBindings, - buildUpsertOutParameterBindings(returningColumns), - Collections.emptyMap() - ); - } - } - - return QueryResult.of(query, Collections.emptyList(), parameterBindings, Collections.emptyMap()); + return new SqlUpsertQueryBuilder(this).build(repositoryMetadata, definition); } - private UpsertData buildUpsertData(PersistentEntity entity, List conflictProperties) { - boolean escape = shouldEscape(entity); - NamingStrategy namingStrategy = getNamingStrategy(entity); - List columns = new ArrayList<>(); - List values = new ArrayList<>(); - List parameterBindings = new ArrayList<>(); - List conflictPropertyPaths = resolveUpsertConflictPropertyPaths(entity, conflictProperties); - final String unescapedTableName = 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 (SqlQueryBuilderUtils.isNotForeign(associations) && isSequenceGeneratedProperty(property)) { - addGeneratedUpsertColumn(columns, namingStrategy, associations, property, escape, true, conflictPropertyPaths, 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) { - addWriteExpression(values, property); - String key = String.valueOf(values.size()); - String[] path = asStringPath(associations, property); - parameterBindings.add(createParameterBinding(key, property, path)); - - String columnName = getMappedName(namingStrategy, associations, property); - if (escape) { - columnName = quote(columnName); - } - columns.add(new UpsertColumn(columnName, values.get(values.size() - 1), "c" + sourceColumnCount(columns), true, property, List.of(path), identity, conflictPropertyPaths.contains(toPathString(path)))); - } - - private void addGeneratedUpsertColumn(List columns, - NamingStrategy namingStrategy, - List associations, - PersistentProperty property, - boolean escape, - boolean identity, - List conflictPropertyPaths, - String value) { - String[] path = asStringPath(associations, property); - String columnName = getMappedName(namingStrategy, associations, property); - if (escape) { - columnName = quote(columnName); - } - columns.add(new UpsertColumn(columnName, value, "", false, property, List.of(path), identity, conflictPropertyPaths.contains(toPathString(path)))); - } - - 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(() -> selectAutoStrategy(property)); - return idGeneratorType == SEQUENCE || (idGeneratorType == AUTO && 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(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 List buildUpsertParameterBindings(UpsertData data) { - if (dialect != Dialect.MYSQL) { - return data.parameterBindings(); - } - List parameterBindings = new ArrayList<>(data.parameterBindings()); - for (UpsertColumn updateColumn : data.updateColumnsOrConflict()) { - parameterBindings.add(createParameterBinding(String.valueOf(parameterBindings.size() + 1), updateColumn.property(), updateColumn.path().toArray(new String[0]))); - } - return parameterBindings; - } - - 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, @Nullable String outputColumn) { - return "MERGE INTO " + tableName + " WITH (HOLDLOCK) AS target " - + "USING (VALUES (" + data.sourceValueExpressions() + ")) AS source (" + data.sourceColumns() + ") " - + "ON " + upsertConflictPredicate(data) - + upsertMatchedClause(data) - + upsertInsertClause(data) - + (outputColumn == null ? "" : " OUTPUT inserted." + outputColumn) - + ";"; - } - - 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 List resolveGeneratedIdentityUpsertReturningColumns(PersistentEntity entity) { - boolean escape = shouldEscape(entity); - NamingStrategy namingStrategy = 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 = getMappedName(namingStrategy, associations, property); - columns.add(new UpsertReturningColumn(escape ? quote(columnName) : columnName, columnName, property.getDataType())); - }); - } - return columns; - } - - private List buildUpsertOutParameterBindings(List returningColumns) { - List outBindings = new ArrayList<>(returningColumns.size()); - for (UpsertReturningColumn returningColumn : returningColumns) { - outBindings.add(new QueryOutParameterBinding() { - @Override - public String getName() { - return returningColumn.name(); - } - - @Override - public DataType getDataType() { - return returningColumn.dataType(); - } - }); - } - return outBindings; - } - - 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 QueryParameterBinding createParameterBinding(String key, PersistentProperty property, String[] path) { + final QueryParameterBinding createParameterBinding(String key, PersistentProperty property, String[] path) { return new QueryParameterBinding() { @Override public String getName() { @@ -1750,7 +1411,7 @@ public String[] getPropertyPath() { }; } - private String[] asStringPath(List associations, PersistentProperty property) { + final String[] asStringPath(List associations, PersistentProperty property) { if (associations.isEmpty()) { return new String[]{property.getName()}; } @@ -1762,7 +1423,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"; @@ -1810,7 +1471,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) { @@ -2488,74 +2149,6 @@ private void addToCollectionIfNotContains(Collection collection, T item) collection.add(item); } - 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().get(0)) : 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) { - } - private static final class DialectConfig { @Nullable Boolean escapeQueries; 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..feb3c472ef1 --- /dev/null +++ b/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlUpsertQueryBuilder.java @@ -0,0 +1,469 @@ +/* + * 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; + +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 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); + List returningColumns = Collections.emptyList(); + String sqlServerOutputColumn = null; + if (definition.returnGeneratedId() && (dialect == Dialect.ORACLE || dialect == Dialect.SQL_SERVER)) { + returningColumns = resolveGeneratedIdentityUpsertReturningColumns(entity); + if (!returningColumns.isEmpty()) { + 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()); + } + if (dialect == Dialect.SQL_SERVER) { + sqlServerOutputColumn = returningColumns.get(0).column(); + } + } + } + String query = switch (dialect) { + case H2 -> buildH2Upsert(tableName, data); + case MYSQL -> buildMySqlUpsert(tableName, data); + case POSTGRES -> buildPostgresUpsert(tableName, data); + case SQL_SERVER -> buildSqlServerUpsert(tableName, data, sqlServerOutputColumn); + case ORACLE -> buildOracleUpsert(tableName, data); + case ANSI -> buildAnsiUpsert(tableName, data); + }; + + List parameterBindings = buildUpsertParameterBindings(data); + if (definition.returnGeneratedId() && dialect == Dialect.SQL_SERVER) { + if (!returningColumns.isEmpty()) { + return QueryResult.of( + query, + Collections.emptyList(), + parameterBindings, + buildUpsertOutParameterBindings(returningColumns), + Collections.emptyMap() + ); + } + } + if (definition.returnGeneratedId() && dialect == Dialect.ORACLE) { + if (!returningColumns.isEmpty()) { + UpsertReturningColumn returningColumn = returningColumns.get(0); + 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;"; + } + return QueryResult.of( + query, + Collections.emptyList(), + parameterBindings, + buildUpsertOutParameterBindings(returningColumns), + Collections.emptyMap() + ); + } + } + + return QueryResult.of(query, Collections.emptyList(), parameterBindings, Collections.emptyMap()); + } + + 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 (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); + } + columns.add(new UpsertColumn(columnName, values.get(values.size() - 1), "c" + sourceColumnCount(columns), true, property, List.of(path), identity, conflictPropertyPaths.contains(toPathString(path)))); + } + + 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); + } + columns.add(new UpsertColumn(columnName, value, "", false, property, List.of(path), identity, conflictPropertyPaths.contains(toPathString(path)))); + } + + 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 List buildUpsertParameterBindings(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 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, @Nullable String outputColumn) { + return "MERGE INTO " + tableName + " WITH (HOLDLOCK) AS target " + + "USING (VALUES (" + data.sourceValueExpressions() + ")) AS source (" + data.sourceColumns() + ") " + + "ON " + upsertConflictPredicate(data) + + upsertMatchedClause(data) + + upsertInsertClause(data) + + (outputColumn == null ? "" : " OUTPUT inserted." + outputColumn) + + ";"; + } + + 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 List resolveGeneratedIdentityUpsertReturningColumns(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 buildUpsertOutParameterBindings(List returningColumns) { + List outBindings = new ArrayList<>(returningColumns.size()); + for (UpsertReturningColumn returningColumn : returningColumns) { + outBindings.add(new QueryOutParameterBinding() { + @Override + public String getName() { + return returningColumn.name(); + } + + @Override + public DataType getDataType() { + return returningColumn.dataType(); + } + }); + } + return outBindings; + } + + 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().get(0)) : 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) { + } +} From 0ffe2e8f64fec8aa4179f67f078947ecdf8f23dc Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 19 Jun 2026 17:36:37 +0200 Subject: [PATCH 24/57] Upsert implementation - SqlUpsertQueryBuilder refactoring --- .../builder/sql/SqlUpsertQueryBuilder.java | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) 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 index feb3c472ef1..ccbc23d9827 100644 --- 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 @@ -70,44 +70,31 @@ QueryResult build(AnnotationMetadata repositoryMetadata, QueryBuilder.UpsertQuer } UpsertData data = buildUpsertData(entity, definition.conflictProperties()); String tableName = sqlQueryBuilder.getTableName(entity); - List returningColumns = Collections.emptyList(); - String sqlServerOutputColumn = null; - if (definition.returnGeneratedId() && (dialect == Dialect.ORACLE || dialect == Dialect.SQL_SERVER)) { - returningColumns = resolveGeneratedIdentityUpsertReturningColumns(entity); - if (!returningColumns.isEmpty()) { - 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()); - } - if (dialect == Dialect.SQL_SERVER) { - sqlServerOutputColumn = returningColumns.get(0).column(); - } - } - } + UpsertGeneratedIdReturning returning = resolveGeneratedIdReturning(entity, definition); String query = switch (dialect) { case H2 -> buildH2Upsert(tableName, data); case MYSQL -> buildMySqlUpsert(tableName, data); case POSTGRES -> buildPostgresUpsert(tableName, data); - case SQL_SERVER -> buildSqlServerUpsert(tableName, data, sqlServerOutputColumn); + case SQL_SERVER -> buildSqlServerUpsert(tableName, data, returning.sqlServerOutputColumn()); case ORACLE -> buildOracleUpsert(tableName, data); case ANSI -> buildAnsiUpsert(tableName, data); }; List parameterBindings = buildUpsertParameterBindings(data); if (definition.returnGeneratedId() && dialect == Dialect.SQL_SERVER) { - if (!returningColumns.isEmpty()) { + if (returning.hasColumns()) { return QueryResult.of( query, Collections.emptyList(), parameterBindings, - buildUpsertOutParameterBindings(returningColumns), + buildUpsertOutParameterBindings(returning.columns()), Collections.emptyMap() ); } } if (definition.returnGeneratedId() && dialect == Dialect.ORACLE) { - if (!returningColumns.isEmpty()) { - UpsertReturningColumn returningColumn = returningColumns.get(0); + if (returning.hasColumns()) { + UpsertReturningColumn returningColumn = returning.columns().get(0); String outPlaceholder = sqlQueryBuilder.formatParameter(parameterBindings.size() + 1).name(); query = query + " RETURNING " + returningColumn.column() + " INTO " + outPlaceholder; if (repositoryMetadata.hasStereotype(R2DBC_REPO_ANNOTATION)) { @@ -117,7 +104,7 @@ QueryResult build(AnnotationMetadata repositoryMetadata, QueryBuilder.UpsertQuer query, Collections.emptyList(), parameterBindings, - buildUpsertOutParameterBindings(returningColumns), + buildUpsertOutParameterBindings(returning.columns()), Collections.emptyMap() ); } @@ -126,6 +113,22 @@ QueryResult build(AnnotationMetadata repositoryMetadata, QueryBuilder.UpsertQuer return QueryResult.of(query, Collections.emptyList(), parameterBindings, Collections.emptyMap()); } + private UpsertGeneratedIdReturning resolveGeneratedIdReturning(PersistentEntity entity, QueryBuilder.UpsertQueryDefinition definition) { + if (!definition.returnGeneratedId() || (dialect != Dialect.ORACLE && dialect != Dialect.SQL_SERVER)) { + return UpsertGeneratedIdReturning.none(); + } + List returningColumns = resolveGeneratedIdentityUpsertReturningColumns(entity); + if (returningColumns.isEmpty()) { + return UpsertGeneratedIdReturning.none(); + } + 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()); + } + String sqlServerOutputColumn = dialect == Dialect.SQL_SERVER ? returningColumns.get(0).column() : null; + return new UpsertGeneratedIdReturning(returningColumns, sqlServerOutputColumn); + } + private UpsertData buildUpsertData(PersistentEntity entity, List conflictProperties) { boolean escape = sqlQueryBuilder.shouldEscape(entity); NamingStrategy namingStrategy = sqlQueryBuilder.getNamingStrategy(entity); @@ -466,4 +469,16 @@ private record UpsertReturningColumn(String column, String name, DataType dataType) { } + + private record UpsertGeneratedIdReturning(List columns, + @Nullable String sqlServerOutputColumn) { + + private static UpsertGeneratedIdReturning none() { + return new UpsertGeneratedIdReturning(Collections.emptyList(), null); + } + + private boolean hasColumns() { + return !columns.isEmpty(); + } + } } From f1c36d46316dd0f2282887b58d23bf97a930639c Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 19 Jun 2026 18:03:17 +0200 Subject: [PATCH 25/57] Upsert implementation - SqlUpsertQueryBuilder refactoring --- .../builder/sql/SqlUpsertQueryBuilder.java | 41 +++++++------------ 1 file changed, 14 insertions(+), 27 deletions(-) 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 index ccbc23d9827..00f68d98217 100644 --- 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 @@ -63,7 +63,7 @@ QueryResult build(AnnotationMetadata repositoryMetadata, QueryBuilder.UpsertQuer 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 an identity for entity: " + entity.getName()); + 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()); @@ -81,33 +81,20 @@ QueryResult build(AnnotationMetadata repositoryMetadata, QueryBuilder.UpsertQuer }; List parameterBindings = buildUpsertParameterBindings(data); - if (definition.returnGeneratedId() && dialect == Dialect.SQL_SERVER) { - if (returning.hasColumns()) { - return QueryResult.of( - query, - Collections.emptyList(), - parameterBindings, - buildUpsertOutParameterBindings(returning.columns()), - Collections.emptyMap() - ); - } + if (definition.returnGeneratedId() && dialect == Dialect.SQL_SERVER && returning.hasColumns()) { + List outParameterBindings = buildOutParameterBindings(returning.columns()); + return QueryResult.of(query, Collections.emptyList(), parameterBindings, outParameterBindings, Collections.emptyMap()); } - if (definition.returnGeneratedId() && dialect == Dialect.ORACLE) { - if (returning.hasColumns()) { - UpsertReturningColumn returningColumn = returning.columns().get(0); - 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;"; - } - return QueryResult.of( - query, - Collections.emptyList(), - parameterBindings, - buildUpsertOutParameterBindings(returning.columns()), - Collections.emptyMap() - ); + + if (definition.returnGeneratedId() && dialect == Dialect.ORACLE && returning.hasColumns()) { + UpsertReturningColumn returningColumn = returning.columns().get(0); + 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(returning.columns()); + return QueryResult.of(query, Collections.emptyList(), parameterBindings, outParameterBindings, Collections.emptyMap()); } return QueryResult.of(query, Collections.emptyList(), parameterBindings, Collections.emptyMap()); @@ -347,7 +334,7 @@ private List resolveGeneratedIdentityUpsertReturningColum return columns; } - private List buildUpsertOutParameterBindings(List returningColumns) { + private List buildOutParameterBindings(List returningColumns) { List outBindings = new ArrayList<>(returningColumns.size()); for (UpsertReturningColumn returningColumn : returningColumns) { outBindings.add(new QueryOutParameterBinding() { From 1e19364a19d209c88208deac10744214fbdf4ca4 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 19 Jun 2026 18:26:44 +0200 Subject: [PATCH 26/57] Upsert implementation - SqlUpsertQueryBuilder refactoring --- .../builder/sql/SqlUpsertQueryBuilder.java | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) 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 index 00f68d98217..45d21371135 100644 --- 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 @@ -68,36 +68,39 @@ QueryResult build(AnnotationMetadata repositoryMetadata, QueryBuilder.UpsertQuer 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); - UpsertGeneratedIdReturning returning = resolveGeneratedIdReturning(entity, definition); String query = switch (dialect) { case H2 -> buildH2Upsert(tableName, data); case MYSQL -> buildMySqlUpsert(tableName, data); case POSTGRES -> buildPostgresUpsert(tableName, data); - case SQL_SERVER -> buildSqlServerUpsert(tableName, data, returning.sqlServerOutputColumn()); + case SQL_SERVER -> buildSqlServerUpsert(tableName, data); case ORACLE -> buildOracleUpsert(tableName, data); case ANSI -> buildAnsiUpsert(tableName, data); }; List parameterBindings = buildUpsertParameterBindings(data); - if (definition.returnGeneratedId() && dialect == Dialect.SQL_SERVER && returning.hasColumns()) { - List outParameterBindings = buildOutParameterBindings(returning.columns()); - return QueryResult.of(query, Collections.emptyList(), parameterBindings, outParameterBindings, Collections.emptyMap()); - } - if (definition.returnGeneratedId() && dialect == Dialect.ORACLE && returning.hasColumns()) { - UpsertReturningColumn returningColumn = returning.columns().get(0); - 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;"; - } + UpsertGeneratedIdReturning returning = resolveGeneratedIdReturning(entity, definition); + if (returning.hasColumns()) { List outParameterBindings = buildOutParameterBindings(returning.columns()); + if (dialect == Dialect.SQL_SERVER) { + query = query + " OUTPUT inserted." + returning.requiredSqlServerOutputColumn() + ";"; + } else if (dialect == Dialect.ORACLE) { + UpsertReturningColumn returningColumn = returning.columns().get(0); + 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;"; + } + } return QueryResult.of(query, Collections.emptyList(), parameterBindings, outParameterBindings, Collections.emptyMap()); } - - return QueryResult.of(query, Collections.emptyList(), parameterBindings, Collections.emptyMap()); + if (dialect == Dialect.SQL_SERVER) { + query = query + ";"; + } + return QueryResult.of(query, parameterBindings); } private UpsertGeneratedIdReturning resolveGeneratedIdReturning(PersistentEntity entity, QueryBuilder.UpsertQueryDefinition definition) { @@ -296,14 +299,12 @@ private String buildPostgresUpsert(String tableName, UpsertData data) { .collect(Collectors.joining(String.valueOf(COMMA))); } - private String buildSqlServerUpsert(String tableName, UpsertData data, @Nullable String outputColumn) { + 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) - + (outputColumn == null ? "" : " OUTPUT inserted." + outputColumn) - + ";"; + + upsertInsertClause(data); } private String buildOracleUpsert(String tableName, UpsertData data) { @@ -467,5 +468,12 @@ private static UpsertGeneratedIdReturning none() { private boolean hasColumns() { return !columns.isEmpty(); } + + private String requiredSqlServerOutputColumn() { + if (sqlServerOutputColumn == null) { + throw new IllegalStateException("SQL Server MERGE ... OUTPUT requires a generated identity column"); + } + return sqlServerOutputColumn; + } } } From 17fff9123f2e40107beac13d5189c70054fd44cc Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 19 Jun 2026 18:44:39 +0200 Subject: [PATCH 27/57] Upsert implementation - SqlUpsertQueryBuilder refactoring --- .../builder/sql/SqlUpsertQueryBuilder.java | 64 ++++++------------- 1 file changed, 21 insertions(+), 43 deletions(-) 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 index 45d21371135..312affa686a 100644 --- 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 @@ -82,13 +82,13 @@ QueryResult build(AnnotationMetadata repositoryMetadata, QueryBuilder.UpsertQuer List parameterBindings = buildUpsertParameterBindings(data); - UpsertGeneratedIdReturning returning = resolveGeneratedIdReturning(entity, definition); - if (returning.hasColumns()) { - List outParameterBindings = buildOutParameterBindings(returning.columns()); + List returningColumns = resolveGeneratedIdReturning(entity, definition); + if (!returningColumns.isEmpty()) { + UpsertReturningColumn returningColumn = returningColumns.getFirst(); + List outParameterBindings = buildOutParameterBindings(returningColumn); if (dialect == Dialect.SQL_SERVER) { - query = query + " OUTPUT inserted." + returning.requiredSqlServerOutputColumn() + ";"; + query = query + " OUTPUT inserted." + returningColumn.column() + ";"; } else if (dialect == Dialect.ORACLE) { - UpsertReturningColumn returningColumn = returning.columns().get(0); String outPlaceholder = sqlQueryBuilder.formatParameter(parameterBindings.size() + 1).name(); query = query + " RETURNING " + returningColumn.column() + " INTO " + outPlaceholder; if (repositoryMetadata.hasStereotype(R2DBC_REPO_ANNOTATION)) { @@ -103,20 +103,19 @@ QueryResult build(AnnotationMetadata repositoryMetadata, QueryBuilder.UpsertQuer return QueryResult.of(query, parameterBindings); } - private UpsertGeneratedIdReturning resolveGeneratedIdReturning(PersistentEntity entity, QueryBuilder.UpsertQueryDefinition definition) { + private List resolveGeneratedIdReturning(PersistentEntity entity, QueryBuilder.UpsertQueryDefinition definition) { if (!definition.returnGeneratedId() || (dialect != Dialect.ORACLE && dialect != Dialect.SQL_SERVER)) { - return UpsertGeneratedIdReturning.none(); + return Collections.emptyList(); } List returningColumns = resolveGeneratedIdentityUpsertReturningColumns(entity); if (returningColumns.isEmpty()) { - return UpsertGeneratedIdReturning.none(); + return Collections.emptyList(); } 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()); } - String sqlServerOutputColumn = dialect == Dialect.SQL_SERVER ? returningColumns.get(0).column() : null; - return new UpsertGeneratedIdReturning(returningColumns, sqlServerOutputColumn); + return returningColumns; } private UpsertData buildUpsertData(PersistentEntity entity, List conflictProperties) { @@ -335,21 +334,19 @@ private List resolveGeneratedIdentityUpsertReturningColum return columns; } - private List buildOutParameterBindings(List returningColumns) { - List outBindings = new ArrayList<>(returningColumns.size()); - for (UpsertReturningColumn returningColumn : returningColumns) { - outBindings.add(new QueryOutParameterBinding() { - @Override - public String getName() { - return returningColumn.name(); - } + 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(); - } - }); - } + @Override + public DataType getDataType() { + return returningColumn.dataType(); + } + }); return outBindings; } @@ -457,23 +454,4 @@ private record UpsertReturningColumn(String column, String name, DataType dataType) { } - - private record UpsertGeneratedIdReturning(List columns, - @Nullable String sqlServerOutputColumn) { - - private static UpsertGeneratedIdReturning none() { - return new UpsertGeneratedIdReturning(Collections.emptyList(), null); - } - - private boolean hasColumns() { - return !columns.isEmpty(); - } - - private String requiredSqlServerOutputColumn() { - if (sqlServerOutputColumn == null) { - throw new IllegalStateException("SQL Server MERGE ... OUTPUT requires a generated identity column"); - } - return sqlServerOutputColumn; - } - } } From 9485276fb0c3e4824a77793e23218d5cf50d5746 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Tue, 23 Jun 2026 10:31:35 +0200 Subject: [PATCH 28/57] Upsert implementation - SqlUpsertQueryBuilder refactoring --- .../builder/sql/SqlUpsertQueryBuilder.java | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) 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 index 312affa686a..62142854409 100644 --- 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 @@ -30,7 +30,6 @@ 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; @@ -83,24 +82,25 @@ QueryResult build(AnnotationMetadata repositoryMetadata, QueryBuilder.UpsertQuer List parameterBindings = buildUpsertParameterBindings(data); List returningColumns = resolveGeneratedIdReturning(entity, definition); - if (!returningColumns.isEmpty()) { - UpsertReturningColumn returningColumn = returningColumns.getFirst(); - List outParameterBindings = buildOutParameterBindings(returningColumn); + if (returningColumns.isEmpty()) { 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;"; - } + query = query + ";"; } - return QueryResult.of(query, Collections.emptyList(), parameterBindings, outParameterBindings, Collections.emptyMap()); + return QueryResult.of(query, parameterBindings); } + + UpsertReturningColumn returningColumn = returningColumns.getFirst(); if (dialect == Dialect.SQL_SERVER) { - query = query + ";"; + 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;"; + } } - return QueryResult.of(query, parameterBindings); + List outParameterBindings = buildOutParameterBindings(returningColumn); + return QueryResult.of(query, Collections.emptyList(), parameterBindings, outParameterBindings, Collections.emptyMap()); } private List resolveGeneratedIdReturning(PersistentEntity entity, QueryBuilder.UpsertQueryDefinition definition) { @@ -180,7 +180,17 @@ private void addUpsertColumn(List columns, if (escape) { columnName = sqlQueryBuilder.quote(columnName); } - columns.add(new UpsertColumn(columnName, values.get(values.size() - 1), "c" + sourceColumnCount(columns), true, property, List.of(path), identity, conflictPropertyPaths.contains(toPathString(path)))); + + 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, @@ -196,7 +206,17 @@ private void addGeneratedUpsertColumn(List columns, if (escape) { columnName = sqlQueryBuilder.quote(columnName); } - columns.add(new UpsertColumn(columnName, value, "", false, property, List.of(path), identity, conflictPropertyPaths.contains(toPathString(path)))); + + UpsertColumn column = new UpsertColumn( + columnName, + value, + "", + false, + property, + List.of(path), + identity, + conflictPropertyPaths.contains(toPathString(path))); + columns.add(column); } private int sourceColumnCount(List columns) { @@ -436,7 +456,7 @@ private List updateColumns() { private List updateColumnsOrConflict() { List updateColumns = updateColumns(); - return updateColumns.isEmpty() ? List.of(conflictColumns().get(0)) : updateColumns; + return updateColumns.isEmpty() ? List.of(conflictColumns().getFirst()) : updateColumns; } } From 6c6c4c3e12a926f6e2ad19e62f28d5d2b15ee396 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Tue, 23 Jun 2026 11:25:27 +0200 Subject: [PATCH 29/57] Upsert implementation - SqlUpsertQueryBuilder refactoring --- .../builder/sql/SqlUpsertQueryBuilder.java | 105 +++++++++--------- 1 file changed, 53 insertions(+), 52 deletions(-) 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 index 62142854409..3091b9eee2c 100644 --- 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 @@ -30,6 +30,7 @@ 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; @@ -79,17 +80,16 @@ QueryResult build(AnnotationMetadata repositoryMetadata, QueryBuilder.UpsertQuer case ANSI -> buildAnsiUpsert(tableName, data); }; - List parameterBindings = buildUpsertParameterBindings(data); + List parameterBindings = buildParameterBindings(data); - List returningColumns = resolveGeneratedIdReturning(entity, definition); - if (returningColumns.isEmpty()) { + UpsertReturningColumn returningColumn = findGeneratedIdReturningColumn(entity, definition); + if (returningColumn == null) { if (dialect == Dialect.SQL_SERVER) { query = query + ";"; } return QueryResult.of(query, parameterBindings); } - UpsertReturningColumn returningColumn = returningColumns.getFirst(); if (dialect == Dialect.SQL_SERVER) { query = query + " OUTPUT inserted." + returningColumn.column() + ";"; } else if (dialect == Dialect.ORACLE) { @@ -103,19 +103,63 @@ QueryResult build(AnnotationMetadata repositoryMetadata, QueryBuilder.UpsertQuer return QueryResult.of(query, Collections.emptyList(), parameterBindings, outParameterBindings, Collections.emptyMap()); } - private List resolveGeneratedIdReturning(PersistentEntity entity, QueryBuilder.UpsertQueryDefinition definition) { + @Nullable + private UpsertReturningColumn findGeneratedIdReturningColumn(PersistentEntity entity, QueryBuilder.UpsertQueryDefinition definition) { if (!definition.returnGeneratedId() || (dialect != Dialect.ORACLE && dialect != Dialect.SQL_SERVER)) { - return Collections.emptyList(); + return null; } - List returningColumns = resolveGeneratedIdentityUpsertReturningColumns(entity); + List returningColumns = findGeneratedIdentityReturningColumns(entity); if (returningColumns.isEmpty()) { - return Collections.emptyList(); + 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; + 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) { @@ -294,17 +338,6 @@ private String buildMySqlUpsert(String tableName, UpsertData data) { .collect(Collectors.joining(String.valueOf(COMMA))); } - private List buildUpsertParameterBindings(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 String buildPostgresUpsert(String tableName, UpsertData data) { List updateColumns = data.updateColumns(); String conflict = buildInsertStatement(tableName, data) + " ON CONFLICT (" + data.conflictColumnNames() + CLOSE_BRACKET; @@ -338,38 +371,6 @@ private String buildOracleUpsert(String tableName, UpsertData data) { + upsertInsertClause(data); } - private List resolveGeneratedIdentityUpsertReturningColumns(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 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 String buildAnsiUpsert(String tableName, UpsertData data) { return "MERGE INTO " + tableName + " target " + "USING (VALUES (" + data.sourceValueExpressions() + ")) source (" + data.sourceColumns() + ") " From 56e53d3396f28fbfef315bd2a2ac990e0f65ed01 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Tue, 23 Jun 2026 12:57:09 +0200 Subject: [PATCH 30/57] Created SQL Server sequences before tables so generated columns can reference the sequence in DEFAULT NEXT VALUE FOR during table creation. --- .../query/builder/sql/SqlQueryBuilder.java | 23 +++++++++-- .../data/processor/sql/BuildTableSpec.groovy | 38 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) 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 87eec908039..f2da3d623bb 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"; } @@ -1109,6 +1118,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()) { 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', ''' From 52123fd3a91b77490d5715d31c7f7ac008592c78 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Tue, 23 Jun 2026 13:01:42 +0200 Subject: [PATCH 31/57] Added sqlserver and postgres tests for cases when upsert called on entity which contains id with GeneratedValue.Type.SEQUENCE --- .../jdbc/oraclexe/OracleXEUpsertSpec.groovy | 4 +- .../jdbc/postgres/PostgresUpsertSpec.groovy | 96 ++++++++++++++++++- .../jdbc/sqlserver/SqlServerUpsertSpec.groovy | 96 ++++++++++++++++++- .../upsert/CustomerProfileSequence.java | 77 +++++++++++++++ ...gresCustomerProfileSequenceRepository.java | 33 +++++++ .../upsert/CustomerProfileSequence.java | 77 +++++++++++++++ .../MSCustomerProfileSequenceRepository.java | 33 +++++++ .../builder/sql/SqlUpsertQueryBuilder.java | 2 +- 8 files changed, 413 insertions(+), 5 deletions(-) create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/CustomerProfileSequence.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresCustomerProfileSequenceRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/CustomerProfileSequence.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSCustomerProfileSequenceRepository.java 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 index 40cc3df5b59..edf7c3f1918 100644 --- 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 @@ -93,7 +93,7 @@ class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleTestPropert CustomerProfileSequence cp2 = new CustomerProfileSequence("test2@example.com", "test 2") when: - List inserted = customerProfileSequenceRepository.upsertAll([cp1, cp2]).toList() + List inserted = customerProfileSequenceRepository.upsertAll([cp1, cp2]) then: inserted.size() == 2 @@ -115,7 +115,7 @@ class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleTestPropert 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]).toList() + List updated = customerProfileSequenceRepository.upsertAll([cp1, cp2, cp3, cp4]) then: updated.size() == 4 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 index 9e663d6589e..92722e63159 100644 --- 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 @@ -15,7 +15,9 @@ */ 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.PostgresProductReviewRepository import io.micronaut.data.jdbc.postgres.upsert.PostgresWarehouseInventoryRepository import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository @@ -40,8 +42,100 @@ class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPrope return context.getBean(PostgresWarehouseInventoryRepository) } + PostgresCustomerProfileSequenceRepository getCustomerProfileSequenceRepository() { + return context.getBean(PostgresCustomerProfileSequenceRepository) + } + @Override List packages() { - return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert") + 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 index aa35c58edf4..100c21a8488 100644 --- 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 @@ -15,7 +15,9 @@ */ 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.MSProductReviewRepository import io.micronaut.data.jdbc.sqlserver.upsert.MSWarehouseInventoryRepository import io.micronaut.data.tck.repositories.upsert.CustomerProfileRepository @@ -40,8 +42,100 @@ class SqlServerUpsertSpec extends AbstractUpsertSpec implements MSSQLTestPropert return context.getBean(MSWarehouseInventoryRepository) } + MSCustomerProfileSequenceRepository getCustomerProfileSequenceRepository() { + return context.getBean(MSCustomerProfileSequenceRepository) + } + @Override List packages() { - return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert") + 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/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/PostgresCustomerProfileSequenceRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresCustomerProfileSequenceRepository.java new file mode 100644 index 00000000000..aef896b72e2 --- /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(conflictProperties = "email") + CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); + + @Upsert(conflictProperties = "email") + List upsertAll(Iterable customerProfiles); +} 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/MSCustomerProfileSequenceRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSCustomerProfileSequenceRepository.java new file mode 100644 index 00000000000..08412a27105 --- /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(conflictProperties = "email") + CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); + + @Upsert(conflictProperties = "email") + List upsertAll(Iterable customerProfiles); +} 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 index 3091b9eee2c..342bf1f33ad 100644 --- 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 @@ -188,7 +188,7 @@ private UpsertData buildUpsertData(PersistentEntity entity, List conflic if (identityConflict) { throw new IllegalStateException("Upsert requires a non-generated identity property: " + property.getName()); } - if (SqlQueryBuilderUtils.isNotForeign(associations) && isSequenceGeneratedProperty(property)) { + if (dialect != Dialect.SQL_SERVER && SqlQueryBuilderUtils.isNotForeign(associations) && isSequenceGeneratedProperty(property)) { addGeneratedUpsertColumn(columns, namingStrategy, associations, property, escape, true, conflictPropertyPaths, sqlQueryBuilder.getSequenceStatement(unescapedSchema, unescapedTableName, property)); } return; From 9358d580a5a439f8d449adb0d98fc4397b991e39 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Tue, 23 Jun 2026 13:59:46 +0200 Subject: [PATCH 32/57] Added tests for cases when upsert called on entity which contains id with GeneratedValue.Type.UUID --- .../data/jdbc/h2/H2UpsertSpec.groovy | 7 ++ .../data/jdbc/mariadb/MariaUpsertSpec.groovy | 7 ++ .../data/jdbc/mysql/MySqlUpsertSpec.groovy | 7 ++ .../jdbc/oraclexe/OracleXEUpsertSpec.groovy | 7 ++ .../jdbc/postgres/PostgresUpsertSpec.groovy | 7 ++ .../jdbc/sqlserver/SqlServerUpsertSpec.groovy | 7 ++ .../H2CustomerProfileUuidRepository.java | 24 +++++ .../MySqlCustomerProfileUuidRepository.java | 24 +++++ ...OracleXECustomerProfileUuidRepository.java | 24 +++++ ...PostgresCustomerProfileUuidRepository.java | 24 +++++ .../MSCustomerProfileUuidRepository.java | 24 +++++ .../data/tck/tests/AbstractUpsertSpec.groovy | 92 +++++++++++++++++++ .../entities/upsert/CustomerProfileUuid.java | 79 ++++++++++++++++ .../upsert/CustomerProfileUuidRepository.java | 32 +++++++ 14 files changed, 365 insertions(+) create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/h2/upsert/H2CustomerProfileUuidRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/upsert/MySqlCustomerProfileUuidRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/upsert/OracleXECustomerProfileUuidRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/postgres/upsert/PostgresCustomerProfileUuidRepository.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/sqlserver/upsert/MSCustomerProfileUuidRepository.java create mode 100644 data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/CustomerProfileUuid.java create mode 100644 data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/CustomerProfileUuidRepository.java 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 index 96ea41ac747..742a4e2f727 100644 --- 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 @@ -16,9 +16,11 @@ 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 @@ -35,6 +37,11 @@ class H2UpsertSpec extends AbstractUpsertSpec implements H2TestPropertyProvider return context.getBean(H2CustomerProfileRepository) } + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(H2CustomerProfileUuidRepository) + } + @Override WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(H2WarehouseInventoryRepository) 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 index 903dd7ff138..5e04388713a 100644 --- 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 @@ -16,9 +16,11 @@ 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 @@ -35,6 +37,11 @@ class MariaUpsertSpec extends AbstractUpsertSpec implements MariaTestPropertyPro return context.getBean(MySqlCustomerProfileRepository) } + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(MySqlCustomerProfileUuidRepository) + } + @Override WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(MySqlWarehouseInventoryRepository) 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 index bf119b9d1e2..0ae018a90d1 100644 --- 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 @@ -16,9 +16,11 @@ 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 @@ -35,6 +37,11 @@ class MySqlUpsertSpec extends AbstractUpsertSpec implements MySQLTestPropertyPro return context.getBean(MySqlCustomerProfileRepository) } + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(MySqlCustomerProfileUuidRepository) + } + @Override WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(MySqlWarehouseInventoryRepository) 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 index edf7c3f1918..ba84d710cee 100644 --- 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 @@ -18,9 +18,11 @@ 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 @@ -37,6 +39,11 @@ class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleTestPropert return context.getBean(OracleXECustomerProfileRepository) } + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(OracleXECustomerProfileUuidRepository) + } + @Override WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(OracleXEWarehouseInventoryRepository) 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 index 92722e63159..cf96c34b4d1 100644 --- 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 @@ -18,9 +18,11 @@ 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 @@ -37,6 +39,11 @@ class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPrope return context.getBean(PostgresCustomerProfileRepository) } + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(PostgresCustomerProfileUuidRepository) + } + @Override WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(PostgresWarehouseInventoryRepository) 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 index 100c21a8488..0144ee882a7 100644 --- 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 @@ -18,9 +18,11 @@ 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 @@ -37,6 +39,11 @@ class SqlServerUpsertSpec extends AbstractUpsertSpec implements MSSQLTestPropert return context.getBean(MSCustomerProfileRepository) } + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(MSCustomerProfileUuidRepository) + } + @Override WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(MSWarehouseInventoryRepository) 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/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/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/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/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-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractUpsertSpec.groovy b/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractUpsertSpec.groovy index b5be317d184..f751d1ad37f 100644 --- 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 @@ -17,9 +17,11 @@ 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 @@ -32,6 +34,8 @@ abstract class AbstractUpsertSpec extends Specification { abstract CustomerProfileRepository getCustomerProfileRepository() + abstract CustomerProfileUuidRepository getCustomerProfileUuidRepository() + abstract WarehouseInventoryRepository getWarehouseInventoryRepository() abstract Map getProperties() @@ -313,6 +317,89 @@ abstract class AbstractUpsertSpec extends Specification { "upsertAllFutureNoResult" | { Iterable profiles -> customerProfileRepository.upsertAllFutureNoResult(profiles).get() } } + void "upsert by email conflict returns entity when uuid is used"() { + 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"() { + 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) @@ -397,6 +484,11 @@ abstract class AbstractUpsertSpec extends Specification { 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 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..ee0c9f7e95b --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/jdbc/entities/upsert/CustomerProfileUuid.java @@ -0,0 +1,79 @@ +/* + * 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; + +import java.util.UUID; + +@MappedEntity +@Index(columns = "email", unique = true) +public class CustomerProfileUuid { + + @Id + @GeneratedValue(value = GeneratedValue.Type.UUID) + @Nullable + private UUID id; + + @NotBlank + private String email; + + @NotBlank + private String displayName; + + public CustomerProfileUuid() { + } + + public CustomerProfileUuid(String email, String displayName) { + this(null, email, displayName); + } + + public CustomerProfileUuid(@Nullable UUID id, String email, String displayName) { + this.id = id; + this.email = email; + this.displayName = displayName; + } + + @Nullable + public UUID getId() { + return id; + } + + public void setId(@Nullable UUID 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/repositories/upsert/CustomerProfileUuidRepository.java b/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/CustomerProfileUuidRepository.java new file mode 100644 index 00000000000..59bffe1842a --- /dev/null +++ b/data-tck/src/main/java/io/micronaut/data/tck/repositories/upsert/CustomerProfileUuidRepository.java @@ -0,0 +1,32 @@ +/* + * 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; +import java.util.UUID; + +public interface CustomerProfileUuidRepository extends CrudRepository { + + @Upsert(conflictProperties = "email") + CustomerProfileUuid upsert(CustomerProfileUuid customerProfile); + + @Upsert(conflictProperties = "email") + List upsertAll(Iterable customerProfiles); +} From 66089007a867e15ada4ed33abd7053056fde1f9a Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Tue, 23 Jun 2026 15:41:12 +0200 Subject: [PATCH 33/57] Added tests for cases when upsert called on entity which contains id with GeneratedValue.Type.UUID --- .../micronaut/data/jdbc/mariadb/MariaUpsertSpec.groovy | 5 +++++ .../micronaut/data/jdbc/mysql/MySqlUpsertSpec.groovy | 5 +++++ .../data/model/query/builder/sql/SqlQueryBuilder.java | 2 +- .../data/processor/sql/BuildInsertSpec.groovy | 2 +- .../micronaut/data/tck/tests/AbstractUpsertSpec.groovy | 10 ++++++++++ .../tck/jdbc/entities/upsert/CustomerProfileUuid.java | 10 ++++------ .../upsert/CustomerProfileUuidRepository.java | 3 +-- 7 files changed, 27 insertions(+), 10 deletions(-) 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 index 5e04388713a..aa53130f7dd 100644 --- 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 @@ -51,4 +51,9 @@ class MariaUpsertSpec extends AbstractUpsertSpec implements MariaTestPropertyPro 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 index 0ae018a90d1..66694a56efa 100644 --- 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 @@ -51,4 +51,9 @@ class MySqlUpsertSpec extends AbstractUpsertSpec implements MySQLTestPropertyPro List packages() { return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert") } + + @Override + protected boolean supportsGeneratedUuidReturning() { + return false + } } 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 f2da3d623bb..7e663b9a98a 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 @@ -1089,7 +1089,7 @@ private String addGeneratedStatementToColumn(SqlTableMapping table, SqlColumnMap // 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)"; 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 0526a28e148..f4904914318 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 @@ -201,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);' 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 index f751d1ad37f..f05ff17f89b 100644 --- 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 @@ -28,6 +28,8 @@ 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() @@ -318,6 +320,8 @@ abstract class AbstractUpsertSpec extends Specification { } void "upsert by email conflict returns entity when uuid is used"() { + assumeTrue(supportsGeneratedUuidReturning()) + given: CustomerProfileUuid cp = new CustomerProfileUuid("test@example.com", "test") @@ -349,6 +353,8 @@ abstract class AbstractUpsertSpec extends Specification { } 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") @@ -473,6 +479,10 @@ abstract class AbstractUpsertSpec extends Specification { 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 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 index ee0c9f7e95b..1a3dc5c7925 100644 --- 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 @@ -22,8 +22,6 @@ import jakarta.validation.constraints.NotBlank; import org.jspecify.annotations.Nullable; -import java.util.UUID; - @MappedEntity @Index(columns = "email", unique = true) public class CustomerProfileUuid { @@ -31,7 +29,7 @@ public class CustomerProfileUuid { @Id @GeneratedValue(value = GeneratedValue.Type.UUID) @Nullable - private UUID id; + private String id; @NotBlank private String email; @@ -46,18 +44,18 @@ public CustomerProfileUuid(String email, String displayName) { this(null, email, displayName); } - public CustomerProfileUuid(@Nullable UUID id, String email, String displayName) { + public CustomerProfileUuid(@Nullable String id, String email, String displayName) { this.id = id; this.email = email; this.displayName = displayName; } @Nullable - public UUID getId() { + public String getId() { return id; } - public void setId(@Nullable UUID id) { + public void setId(@Nullable String id) { this.id = id; } 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 index 59bffe1842a..854a9442e9e 100644 --- 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 @@ -20,9 +20,8 @@ import io.micronaut.data.tck.jdbc.entities.upsert.CustomerProfileUuid; import java.util.List; -import java.util.UUID; -public interface CustomerProfileUuidRepository extends CrudRepository { +public interface CustomerProfileUuidRepository extends CrudRepository { @Upsert(conflictProperties = "email") CustomerProfileUuid upsert(CustomerProfileUuid customerProfile); From eb3a486527d9ac2fe5bbd0052bd34fb64ae3f152 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 24 Jun 2026 14:23:36 +0200 Subject: [PATCH 34/57] Enabled uuid-ossp for Postgres R2DBC tests --- .../io/micronaut/data/r2dbc/postgres/PostgresDbInit.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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..0b1adc3527e 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 @@ -24,6 +24,7 @@ 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; @@ -83,7 +84,10 @@ public DefaultBasicR2dbcProperties onCreated(BeanCreatedEvent 0) { - try (Connection ignored = DriverManager.getConnection(url, info)) { + try (Connection connection = DriverManager.getConnection(url, info)) { + try (CallableStatement statement = connection.prepareCall("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";")) { + statement.execute(); + } last = null; break; } catch (SQLException e) { From 10ccfbff79c8c69c35986c88d19b8621c7d3e776 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 24 Jun 2026 14:25:08 +0200 Subject: [PATCH 35/57] Updated upsert tests for R2DBC --- .../data/r2dbc/h2/H2UpsertSpec.groovy | 7 ++++++ .../r2dbc/mariadb/MariaDbUpsertSpec.groovy | 12 ++++++++++ .../data/r2dbc/mysql/MySqlUpsertSpec.groovy | 12 ++++++++++ .../r2dbc/oraclexe/OracleXEUpsertSpec.groovy | 7 ++++++ .../r2dbc/postgres/PostgresUpsertSpec.groovy | 7 ++++++ .../sqlserver/SqlServerUpsertSpec.groovy | 7 ++++++ .../H2CustomerProfileUuidRepository.java | 24 +++++++++++++++++++ .../MySqlCustomerProfileUuidRepository.java | 24 +++++++++++++++++++ ...OracleXECustomerProfileUuidRepository.java | 24 +++++++++++++++++++ ...PostgresCustomerProfileUuidRepository.java | 24 +++++++++++++++++++ .../MSCustomerProfileUuidRepository.java | 24 +++++++++++++++++++ 11 files changed, 172 insertions(+) create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/h2/upsert/H2CustomerProfileUuidRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/upsert/MySqlCustomerProfileUuidRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXECustomerProfileUuidRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresCustomerProfileUuidRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSCustomerProfileUuidRepository.java 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 index 390660f5b34..703f9c31959 100644 --- 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 @@ -16,9 +16,11 @@ 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 @@ -35,6 +37,11 @@ class H2UpsertSpec extends AbstractUpsertSpec implements H2TestPropertyProvider return context.getBean(H2CustomerProfileRepository) } + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(H2CustomerProfileUuidRepository) + } + @Override WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(H2WarehouseInventoryRepository) 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 index 8834c8f07fd..db11aa41e65 100644 --- 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 @@ -16,9 +16,11 @@ 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 @@ -35,6 +37,11 @@ class MariaDbUpsertSpec extends AbstractUpsertSpec implements MariaDbTestPropert return context.getBean(MySqlCustomerProfileRepository) } + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(MySqlCustomerProfileUuidRepository) + } + @Override WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(MySqlWarehouseInventoryRepository) @@ -44,4 +51,9 @@ class MariaDbUpsertSpec extends AbstractUpsertSpec implements MariaDbTestPropert 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 index d683e5d11e3..6c9d5464a21 100644 --- 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 @@ -16,9 +16,11 @@ 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 @@ -35,6 +37,11 @@ class MySqlUpsertSpec extends AbstractUpsertSpec implements MySqlTestPropertyPro return context.getBean(MySqlCustomerProfileRepository) } + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(MySqlCustomerProfileUuidRepository) + } + @Override WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(MySqlWarehouseInventoryRepository) @@ -44,4 +51,9 @@ class MySqlUpsertSpec extends AbstractUpsertSpec implements MySqlTestPropertyPro 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/oraclexe/OracleXEUpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/OracleXEUpsertSpec.groovy index 808413c824d..b4741c785b3 100644 --- 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 @@ -16,9 +16,11 @@ package io.micronaut.data.r2dbc.oraclexe import io.micronaut.data.r2dbc.oraclexe.upsert.OracleXECustomerProfileRepository +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 @@ -35,6 +37,11 @@ class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleXETestPrope return context.getBean(OracleXECustomerProfileRepository) } + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(OracleXECustomerProfileUuidRepository) + } + @Override WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(OracleXEWarehouseInventoryRepository) 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 index 2882df23fb1..217522af6df 100644 --- 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 @@ -16,9 +16,11 @@ package io.micronaut.data.r2dbc.postgres import io.micronaut.data.r2dbc.postgres.upsert.PostgresCustomerProfileRepository +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 @@ -35,6 +37,11 @@ class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPrope return context.getBean(PostgresCustomerProfileRepository) } + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(PostgresCustomerProfileUuidRepository) + } + @Override WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(PostgresWarehouseInventoryRepository) 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 index 1a218c8ef8c..6d15e0f3af9 100644 --- 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 @@ -16,9 +16,11 @@ package io.micronaut.data.r2dbc.sqlserver import io.micronaut.data.r2dbc.sqlserver.upsert.MSCustomerProfileRepository +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 @@ -35,6 +37,11 @@ class SqlServerUpsertSpec extends AbstractUpsertSpec implements SqlServerTestPro return context.getBean(MSCustomerProfileRepository) } + @Override + CustomerProfileUuidRepository getCustomerProfileUuidRepository() { + return context.getBean(MSCustomerProfileUuidRepository) + } + @Override WarehouseInventoryRepository getWarehouseInventoryRepository() { return context.getBean(MSWarehouseInventoryRepository) 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/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/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/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/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 { +} From c7985a6cb31a96b4ed4c8aada922202bb635ee3d Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 24 Jun 2026 14:26:13 +0200 Subject: [PATCH 36/57] Upsert implementation for R2DBC --- .../DefaultR2dbcRepositoryOperations.java | 114 +++++-- .../OracleR2dbcRepositoryOperations.java | 281 ++++++++++++++++++ .../R2dbcRepositoryOperationsConditions.java | 109 +++++++ 3 files changed, 471 insertions(+), 33 deletions(-) create mode 100644 data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/OracleR2dbcRepositoryOperations.java create mode 100644 data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/R2dbcRepositoryOperationsConditions.java 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 11aa59b237e..d482e1e1c9f 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()); } @@ -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,10 @@ private boolean isOracleReturningQuery(SqlStoredQuery storedQuery) { || operationType == OperationType.DELETE_RETURNING); } + protected boolean isUpsertOperation(SqlStoredQuery storedQuery) { + return storedQuery.getOperationType() == OperationType.UPSERT; + } + @SuppressWarnings({"unchecked", "rawtypes"}) private SqlTypeMapper createOracleReturningMapper(SqlStoredQuery storedQuery) { OracleReturningMetadata metadata = getOracleReturningMetadata(storedQuery); @@ -674,7 +710,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 +756,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 +949,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 +1018,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 +1043,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 +1055,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 +1139,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 +1165,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 +1242,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 +1250,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 +1324,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, @@ -1401,7 +1437,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 { @@ -1424,15 +1461,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, @@ -1557,7 +1594,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); @@ -1612,6 +1651,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..773c63b1ff8 --- /dev/null +++ b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/OracleR2dbcRepositoryOperations.java @@ -0,0 +1,281 @@ +/* + * 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.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.DefaultSqlPreparedQuery; +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.SqlPreparedQuery; +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 isUpsertOperation(storedQuery) && CollectionUtils.isNotEmpty(storedQuery.getOutParameterBindings()); + } + + private Mono executeReturningId(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); + 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 -> resultReader.readDynamic(readable, out.name(), out.dataType())); + } + + @SuppressWarnings("unchecked") + private 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 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)) { + upsert(); + } else { + super.execute(); + } + } + + private void upsert() { + 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 executeReturningId(ctx, storedQuery, d.entity, 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)) { + upsert(); + } else { + super.execute(); + } + } + + private void upsert() { + 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 executeReturningId(ctx, storedQuery, d.entity, 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..dd1c118ca21 --- /dev/null +++ b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/R2dbcRepositoryOperationsConditions.java @@ -0,0 +1,109 @@ +/* + * 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.BeanResolutionContext; +import io.micronaut.context.Qualifier; +import io.micronaut.context.condition.Condition; +import io.micronaut.context.condition.ConditionContext; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.naming.Named; +import io.micronaut.inject.BeanDefinition; + +/** + * Condition that enables the default R2DBC repository operations for non-Oracle datasources. + */ +@Internal +final class DefaultR2dbcRepositoryOperationsCondition implements Condition { + + /** + * Checks whether the current datasource is not configured with the Oracle dialect. + * + * @param context The condition context + * @return {@code true} when default R2DBC operations should be enabled + */ + @Override + public boolean matches(ConditionContext context) { + return !R2dbcRepositoryOperationsConditions.isOracleDialect(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); + } +} + +/** + * 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 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) { + String dataSourceName = resolveDataSourceName(context); + String dialectProperty = DATASOURCES + '.' + dataSourceName + '.' + DIALECT; + String dialect = context.getProperty(dialectProperty, String.class).orElse(null); + return ORACLE_DIALECT.equalsIgnoreCase(dialect); + } + + /** + * Resolves the datasource name from the current qualifier, falling back to {@code default}. + * + * @param context The condition context + * @return The datasource name + */ + private static String resolveDataSourceName(ConditionContext context) { + BeanResolutionContext beanResolutionContext = context.getBeanResolutionContext(); + Qualifier currentQualifier = null; + if (beanResolutionContext != null) { + currentQualifier = beanResolutionContext.getCurrentQualifier(); + } + if (currentQualifier == null && context.getComponent() instanceof BeanDefinition definition) { + currentQualifier = definition.getDeclaredQualifier(); + } + if (currentQualifier instanceof Named named) { + return named.getName(); + } + return DEFAULT; + } +} From 5f3e7257309226dee8dfeeacbe53c0de7e60a706 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 24 Jun 2026 16:55:15 +0200 Subject: [PATCH 37/57] Upsert implementation for R2DBC --- .../DefaultR2dbcRepositoryOperations.java | 52 ++-- .../OracleR2dbcRepositoryOperations.java | 27 +- .../R2dbcRepositoryOperationsConditions.java | 42 ++- .../SqlServerR2dbcRepositoryOperations.java | 273 ++++++++++++++++++ 4 files changed, 351 insertions(+), 43 deletions(-) create mode 100644 data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/SqlServerR2dbcRepositoryOperations.java 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 d482e1e1c9f..bca540b9c21 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 @@ -522,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)))); } @@ -575,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); @@ -617,6 +617,32 @@ 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); @@ -677,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) { @@ -686,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 @@ -1526,16 +1545,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); 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 index 773c63b1ff8..7f7d2a567f2 100644 --- 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 @@ -24,6 +24,7 @@ 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; @@ -36,10 +37,8 @@ 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.DefaultSqlPreparedQuery; 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.SqlPreparedQuery; import io.micronaut.data.runtime.operations.internal.sql.SqlStoredQuery; import io.micronaut.json.JsonMapper; import io.r2dbc.spi.Connection; @@ -171,12 +170,15 @@ protected R2dbcEntitiesOperations getR2dbcEntitiesOperations(R2dbcOperati } private boolean shouldUseOracleUpsertReturning(SqlStoredQuery storedQuery) { - return isUpsertOperation(storedQuery) && CollectionUtils.isNotEmpty(storedQuery.getOutParameterBindings()); + return storedQuery.getDialect() == Dialect.ORACLE + && isUpsertOperation(storedQuery) + && CollectionUtils.isNotEmpty(storedQuery.getOutParameterBindings()); } private Mono executeReturningId(R2dbcOperationContext ctx, SqlStoredQuery storedQuery, T entity, + Class identityType, @Nullable Map previousValues) { SqlStoredQuery entityStoredQuery = prepareStoredQuery(storedQuery, entity); Statement statement = ctx.getConnection().createStatement(entityStoredQuery.getQuery()); @@ -190,19 +192,8 @@ private Mono executeReturningId(R2dbcOperationContext ctx, QueryOutParameterBinding out = outParameterBindings.getFirst(); OracleReturningMetadata metadata = getOracleReturningMetadata(entityStoredQuery); ColumnNameByIndexR2dbcResultReader resultReader = new ColumnNameByIndexR2dbcResultReader(conversionService, metadata.columnIndexesByName()); - return executeAndMapOracleReturningSingleNullable(statement, entityStoredQuery.getDialect(), readable -> resultReader.readDynamic(readable, out.name(), out.dataType())); - } - - @SuppressWarnings("unchecked") - private 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; + return executeAndMapOracleReturningSingleNullable(statement, entityStoredQuery.getDialect(), + readable -> mapOracleOutValue(readable, identityType, resultReader, out)); } protected class OracleR2dbcEntityOperations extends R2dbcEntityOperations { @@ -231,7 +222,7 @@ private void upsert() { if (d.vetoed) { return Mono.just(d); } - return executeReturningId(ctx, storedQuery, d.entity, d.previousValues) + return executeReturningId(ctx, storedQuery, d.entity, identityProperty.getType(), d.previousValues) .map(id -> { d.entity = updateEntityId(identityProperty, d.entity, id); return d; @@ -268,7 +259,7 @@ private void upsert() { if (d.vetoed) { return Mono.just(d); } - return executeReturningId(ctx, storedQuery, d.entity, d.previousValues) + return executeReturningId(ctx, storedQuery, d.entity, identityProperty.getType(), d.previousValues) .map(id -> { d.entity = updateEntityId(identityProperty, d.entity, id); return d; 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 index dd1c118ca21..148fe5ba3ef 100644 --- 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 @@ -24,20 +24,21 @@ import io.micronaut.inject.BeanDefinition; /** - * Condition that enables the default R2DBC repository operations for non-Oracle datasources. + * 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 the Oracle dialect. + * 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.isOracleDialect(context); + return !R2dbcRepositoryOperationsConditions.isOracleDialect(context) + && !R2dbcRepositoryOperationsConditions.isSqlServerDialect(context); } } @@ -59,6 +60,24 @@ public boolean matches(ConditionContext 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. */ @@ -68,6 +87,7 @@ 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() { @@ -80,10 +100,24 @@ private R2dbcRepositoryOperationsConditions() { * @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); + } + + private static boolean isDialect(ConditionContext context, String expectedDialect) { String dataSourceName = resolveDataSourceName(context); String dialectProperty = DATASOURCES + '.' + dataSourceName + '.' + DIALECT; String dialect = context.getProperty(dialectProperty, String.class).orElse(null); - return ORACLE_DIALECT.equalsIgnoreCase(dialect); + 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..d32b4683f04 --- /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 executeReturningId(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)) { + upsert(); + } else { + super.execute(); + } + } + + private void upsert() { + 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 executeReturningId(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)) { + upsert(); + } else { + super.execute(); + } + } + + private void upsert() { + 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 executeReturningId(ctx, storedQuery, d.entity, d.previousValues) + .map(id -> { + d.entity = updateEntityId(identityProperty, d.entity, id); + return d; + }); + }) + .collectList()); + } + } +} From 505ab5e9da0535d912380ef63140635adf783b2d Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 26 Jun 2026 10:25:04 +0200 Subject: [PATCH 38/57] Added GeneratedValue.Type.SEQUENCE related tests to r2dbc --- .../OracleR2dbcRepositoryOperations.java | 22 ++-- .../SqlServerR2dbcRepositoryOperations.java | 20 ++-- .../r2dbc/oraclexe/OracleXEUpsertSpec.groovy | 101 +++++++++++++++++- .../r2dbc/postgres/PostgresUpsertSpec.groovy | 96 ++++++++++++++++- .../sqlserver/SqlServerUpsertSpec.groovy | 96 ++++++++++++++++- .../upsert/CustomerProfileSequence.java | 77 +++++++++++++ ...leXECustomerProfileSequenceRepository.java | 33 ++++++ .../upsert/CustomerProfileSequence.java | 77 +++++++++++++ ...gresCustomerProfileSequenceRepository.java | 33 ++++++ .../upsert/CustomerProfileSequence.java | 77 +++++++++++++ .../MSCustomerProfileSequenceRepository.java | 33 ++++++ 11 files changed, 641 insertions(+), 24 deletions(-) create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/CustomerProfileSequence.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXECustomerProfileSequenceRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/CustomerProfileSequence.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresCustomerProfileSequenceRepository.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/CustomerProfileSequence.java create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSCustomerProfileSequenceRepository.java 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 index 7f7d2a567f2..66aa4091dcc 100644 --- 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 @@ -175,11 +175,11 @@ && isUpsertOperation(storedQuery) && CollectionUtils.isNotEmpty(storedQuery.getOutParameterBindings()); } - private Mono executeReturningId(R2dbcOperationContext ctx, - SqlStoredQuery storedQuery, - T entity, - Class identityType, - @Nullable Map previousValues) { + 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); @@ -209,20 +209,20 @@ protected OracleR2dbcEntityOperations(R2dbcOperationContext ctx, @Override protected void execute() throws RuntimeException { if (shouldUseOracleUpsertReturning(storedQuery)) { - upsert(); + executeUpsertReturning(); } else { super.execute(); } } - private void upsert() { + 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 executeReturningId(ctx, storedQuery, d.entity, identityProperty.getType(), d.previousValues) + return executeUpsertReturningId(ctx, storedQuery, d.entity, identityProperty.getType(), d.previousValues) .map(id -> { d.entity = updateEntityId(identityProperty, d.entity, id); return d; @@ -245,13 +245,13 @@ protected OracleR2dbcEntitiesOperations(R2dbcOperationContext ctx, @Override protected void execute() throws RuntimeException { if (shouldUseOracleUpsertReturning(storedQuery)) { - upsert(); + executeUpsertReturning(); } else { super.execute(); } } - private void upsert() { + private void executeUpsertReturning() { QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); BeanProperty identityProperty = persistentEntity.getIdentity().getProperty(); entities = entities.flatMap(list -> Flux.fromIterable(list) @@ -259,7 +259,7 @@ private void upsert() { if (d.vetoed) { return Mono.just(d); } - return executeReturningId(ctx, storedQuery, d.entity, identityProperty.getType(), d.previousValues) + return executeUpsertReturningId(ctx, storedQuery, d.entity, identityProperty.getType(), d.previousValues) .map(id -> { d.entity = updateEntityId(identityProperty, d.entity, id); return d; 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 index d32b4683f04..249d20d0b15 100644 --- 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 @@ -172,10 +172,10 @@ && isUpsertOperation(storedQuery) && CollectionUtils.isNotEmpty(storedQuery.getOutParameterBindings()); } - private Mono executeReturningId(R2dbcOperationContext ctx, - SqlStoredQuery storedQuery, - T entity, - @Nullable Map previousValues) { + 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); @@ -212,20 +212,20 @@ protected SqlServerR2dbcEntityOperations(R2dbcOperationContext ctx, @Override protected void execute() throws RuntimeException { if (shouldUseSqlServerUpsertReturning(storedQuery)) { - upsert(); + executeUpsertReturning(); } else { super.execute(); } } - private void upsert() { + 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 executeReturningId(ctx, storedQuery, d.entity, d.previousValues) + return executeUpsertReturningId(ctx, storedQuery, d.entity, d.previousValues) .map(id -> { d.entity = updateEntityId(identityProperty, d.entity, id); return d; @@ -247,13 +247,13 @@ protected SqlServerR2dbcEntitiesOperations(R2dbcOperationContext ctx, @Override protected void execute() throws RuntimeException { if (shouldUseSqlServerUpsertReturning(storedQuery)) { - upsert(); + executeUpsertReturning(); } else { super.execute(); } } - private void upsert() { + private void executeUpsertReturning() { QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); BeanProperty identityProperty = persistentEntity.getIdentity().getProperty(); entities = entities.flatMap(list -> Flux.fromIterable(list) @@ -261,7 +261,7 @@ private void upsert() { if (d.vetoed) { return Mono.just(d); } - return executeReturningId(ctx, storedQuery, d.entity, d.previousValues) + return executeUpsertReturningId(ctx, storedQuery, d.entity, d.previousValues) .map(id -> { d.entity = updateEntityId(identityProperty, d.entity, id); return d; 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 index b4741c785b3..f81df33f78b 100644 --- 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 @@ -15,7 +15,9 @@ */ 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 @@ -47,8 +49,105 @@ class OracleXEUpsertSpec extends AbstractUpsertSpec implements OracleXETestPrope return context.getBean(OracleXEWarehouseInventoryRepository) } + OracleXECustomerProfileSequenceRepository getCustomerProfileSequenceRepository() { + return context.getBean(OracleXECustomerProfileSequenceRepository) + } + @Override List packages() { - return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert") + 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/PostgresUpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresUpsertSpec.groovy index 217522af6df..642be913c00 100644 --- 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 @@ -15,7 +15,9 @@ */ 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 @@ -47,8 +49,100 @@ class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPrope return context.getBean(PostgresWarehouseInventoryRepository) } + PostgresCustomerProfileSequenceRepository getCustomerProfileSequenceRepository() { + return context.getBean(PostgresCustomerProfileSequenceRepository) + } + @Override List packages() { - return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert") + 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/sqlserver/SqlServerUpsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/sqlserver/SqlServerUpsertSpec.groovy index 6d15e0f3af9..7b039d93a54 100644 --- 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 @@ -15,7 +15,9 @@ */ 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 @@ -47,8 +49,100 @@ class SqlServerUpsertSpec extends AbstractUpsertSpec implements SqlServerTestPro return context.getBean(MSWarehouseInventoryRepository) } + MSCustomerProfileSequenceRepository getCustomerProfileSequenceRepository() { + return context.getBean(MSCustomerProfileSequenceRepository) + } + @Override List packages() { - return Arrays.asList("io.micronaut.data.tck.jdbc.entities.upsert") + 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/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/OracleXECustomerProfileSequenceRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/oraclexe/upsert/OracleXECustomerProfileSequenceRepository.java new file mode 100644 index 00000000000..6d76ec0ee85 --- /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(conflictProperties = "email") + CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); + + @Upsert(conflictProperties = "email") + List upsertAll(Iterable customerProfiles); +} 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/PostgresCustomerProfileSequenceRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/postgres/upsert/PostgresCustomerProfileSequenceRepository.java new file mode 100644 index 00000000000..9bf33dd4862 --- /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(conflictProperties = "email") + CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); + + @Upsert(conflictProperties = "email") + List upsertAll(Iterable customerProfiles); +} 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/MSCustomerProfileSequenceRepository.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/sqlserver/upsert/MSCustomerProfileSequenceRepository.java new file mode 100644 index 00000000000..949b4da430a --- /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(conflictProperties = "email") + CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); + + @Upsert(conflictProperties = "email") + List upsertAll(Iterable customerProfiles); +} From 608f085b3f96acf43f384a6e6d0c99d3f3bf5ca5 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 26 Jun 2026 15:00:11 +0200 Subject: [PATCH 39/57] Modified repository operations conditions --- .../JdbcRepositoryOperationsConditions.java | 103 ++++++++- ...cRepositoryOperationsConditionsSpec.groovy | 210 ++++++++++++++++++ .../R2dbcRepositoryOperationsConditions.java | 104 ++++++++- ...cRepositoryOperationsConditionsSpec.groovy | 176 +++++++++++++++ 4 files changed, 577 insertions(+), 16 deletions(-) create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/operations/JdbcRepositoryOperationsConditionsSpec.groovy create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/operations/R2dbcRepositoryOperationsConditionsSpec.groovy 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 index 66700ef36b5..7c1a14a22a3 100644 --- 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 @@ -21,8 +21,14 @@ import io.micronaut.context.condition.ConditionContext; import io.micronaut.core.annotation.Internal; import io.micronaut.core.naming.Named; +import io.micronaut.data.jdbc.config.DataJdbcConfiguration; import io.micronaut.inject.BeanDefinition; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + /** * Condition that enables the default JDBC repository operations for datasources without a specialized implementation. */ @@ -37,8 +43,7 @@ final class DefaultJdbcRepositoryOperationsCondition implements Condition { */ @Override public boolean matches(ConditionContext context) { - return !JdbcRepositoryOperationsConditions.isOracleDialect(context) - && !JdbcRepositoryOperationsConditions.isSqlServerDialect(context); + return JdbcRepositoryOperationsConditions.isDefaultOperationsDialect(context); } } @@ -113,31 +118,113 @@ 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 = resolveDataSourceName(context); + if (dataSourceName.isPresent()) { + return !isDialect(context, dataSourceName.get(), ORACLE_DIALECT) + && !isDialect(context, dataSourceName.get(), SQL_SERVER_DIALECT); + } + List dataSourceNames = resolveConfiguredDataSourceNames(context); + 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) { - String dataSourceName = resolveDataSourceName(context); + Optional dataSourceName = resolveDataSourceName(context); + if (dataSourceName.isPresent()) { + return isDialect(context, dataSourceName.get(), expectedDialect); + } + List dataSourceNames = resolveConfiguredDataSourceNames(context); + 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); } /** - * Resolves the datasource name from the current qualifier, falling back to {@code default}. + * Resolves all configured datasource names visible to the condition context. + * + *

This method is used when no current datasource qualifier is available yet. In that early + * bean-definition phase, the condition needs to know whether any configured datasource matches the + * operation type so the bean definition is not filtered out before {@code @EachBean(DataSource)} + * creates the qualified per-datasource beans. Property entries are used first; if the property + * resolver cannot enumerate them, the method falls back to the generated + * {@link DataJdbcConfiguration} bean definitions.

+ * + * @param context The condition context + * @return The configured datasource names + */ + private static List resolveConfiguredDataSourceNames(ConditionContext context) { + Collection dataSourceNames = context.getPropertyEntries(DATASOURCES); + if (!dataSourceNames.isEmpty()) { + return List.copyOf(dataSourceNames); + } + Collection beanDefinitions = context.findBeanDefinitions(DataJdbcConfiguration.class); + if (beanDefinitions.isEmpty()) { + return List.of(); + } + List names = new ArrayList<>(beanDefinitions.size()); + for (Object candidate : beanDefinitions) { + if (candidate instanceof BeanDefinition beanDefinition) { + Qualifier qualifier = beanDefinition.getDeclaredQualifier(); + if (qualifier instanceof Named named) { + names.add(named.getName()); + } + } + } + return List.copyOf(names); + } + + /** + * Resolves the datasource name from the current qualifier. + * + *

This method answers which datasource the current bean resolution is creating or resolving. + * For example, resolving {@code JdbcRepositoryOperations} with {@code @Named("mdb")} returns + * {@code mdb}. If Micronaut is evaluating the condition before it has selected a specific + * datasource-qualified bean, no current datasource exists and this method returns empty.

* * @param context The condition context - * @return The datasource name + * @return The datasource name, or empty when the condition is being evaluated without a datasource qualifier */ - private static String resolveDataSourceName(ConditionContext context) { + private 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 named.getName(); + return Optional.of(named.getName()); } - return DEFAULT; + return Optional.empty(); } } 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-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/R2dbcRepositoryOperationsConditions.java b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/R2dbcRepositoryOperationsConditions.java index 148fe5ba3ef..60079382747 100644 --- 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 @@ -21,8 +21,14 @@ import io.micronaut.context.condition.ConditionContext; import io.micronaut.core.annotation.Internal; import io.micronaut.core.naming.Named; +import io.micronaut.data.r2dbc.config.DataR2dbcConfiguration; import io.micronaut.inject.BeanDefinition; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + /** * Condition that enables the default R2DBC repository operations for datasources without a specialized implementation. */ @@ -37,8 +43,7 @@ final class DefaultR2dbcRepositoryOperationsCondition implements Condition { */ @Override public boolean matches(ConditionContext context) { - return !R2dbcRepositoryOperationsConditions.isOracleDialect(context) - && !R2dbcRepositoryOperationsConditions.isSqlServerDialect(context); + return R2dbcRepositoryOperationsConditions.isDefaultOperationsDialect(context); } } @@ -113,31 +118,114 @@ 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 = resolveDataSourceName(context); + if (dataSourceName.isPresent()) { + return !isDialect(context, dataSourceName.get(), ORACLE_DIALECT) + && !isDialect(context, dataSourceName.get(), SQL_SERVER_DIALECT); + } + List dataSourceNames = resolveConfiguredDataSourceNames(context); + 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) { - String dataSourceName = resolveDataSourceName(context); + Optional dataSourceName = resolveDataSourceName(context); + if (dataSourceName.isPresent()) { + return isDialect(context, dataSourceName.get(), expectedDialect); + } + List dataSourceNames = resolveConfiguredDataSourceNames(context); + 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); } /** - * Resolves the datasource name from the current qualifier, falling back to {@code default}. + * Resolves all configured datasource names visible to the condition context. + * + *

This method is used when no current datasource qualifier is available yet. In that early + * bean-definition phase, the condition needs to know whether any configured datasource matches the + * operation type so the bean definition is not filtered out before {@code @EachBean(ConnectionFactory)} + * creates the qualified per-datasource beans. Property entries are used first; if the property + * resolver cannot enumerate them, the method falls back to the generated + * {@link DataR2dbcConfiguration} bean definitions.

+ * + * @param context The condition context + * @return The configured datasource names + */ + private static List resolveConfiguredDataSourceNames(ConditionContext context) { + Collection dataSourceNames = context.getPropertyEntries(DATASOURCES); + if (!dataSourceNames.isEmpty()) { + return List.copyOf(dataSourceNames); + } + Collection beanDefinitions = context.findBeanDefinitions(DataR2dbcConfiguration.class); + if (beanDefinitions.isEmpty()) { + return List.of(); + } + List names = new ArrayList<>(beanDefinitions.size()); + for (Object candidate : beanDefinitions) { + if (candidate instanceof BeanDefinition beanDefinition) { + Qualifier qualifier = beanDefinition.getDeclaredQualifier(); + if (qualifier instanceof Named named) { + names.add(named.getName()); + } + } + } + return List.copyOf(names); + } + + /** + * Resolves the datasource name from the current qualifier. + * + *

This method answers which datasource the current bean resolution is creating or resolving. + * For example, resolving {@code R2dbcOperations} with {@code @Named("mdb")} returns {@code mdb}. + * If Micronaut is evaluating the condition before it has selected a specific datasource-qualified + * bean, no current datasource exists and this method returns empty.

* * @param context The condition context - * @return The datasource name + * @return The datasource name, or empty when the condition is being evaluated without a datasource qualifier */ - private static String resolveDataSourceName(ConditionContext context) { + private 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 named.getName(); + return Optional.of(named.getName()); } - return DEFAULT; + return Optional.empty(); } } 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' + } + } +} From 8ae5541c3fada522a15504c6a2c80bbd42758772 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Mon, 29 Jun 2026 13:31:42 +0200 Subject: [PATCH 40/57] Temp change to test upsert implementation --- .github/workflows/gradle.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index f1deb64cf99..4d4bec37dcc 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -7,7 +7,7 @@ name: Java CI on: push: branches: - - master + - upsert-impl - '[0-9]+.[0-9]+.x' pull_request: branches: From 6b6a93b661a19df5e88366f1b72ebef716de8ad8 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Tue, 30 Jun 2026 16:58:10 +0200 Subject: [PATCH 41/57] Ready --- data-r2dbc/src/test/resources/logback.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/data-r2dbc/src/test/resources/logback.xml b/data-r2dbc/src/test/resources/logback.xml index 9bd4b30beba..485ff7fb9c4 100644 --- a/data-r2dbc/src/test/resources/logback.xml +++ b/data-r2dbc/src/test/resources/logback.xml @@ -8,6 +8,8 @@ + + From 5156dcde825ad4a214f654fb8b33169c1b2c2d85 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 1 Jul 2026 10:21:48 +0200 Subject: [PATCH 42/57] Remove CustomerProfileUuid records after each test --- .../io/micronaut/data/tck/tests/AbstractUpsertSpec.groovy | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index f05ff17f89b..99f6ea5271d 100644 --- 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 @@ -51,9 +51,10 @@ abstract class AbstractUpsertSpec extends Specification { } void cleanup() { - warehouseInventoryRepository.deleteAll() - customerProfileRepository.deleteAll() productReviewRepository.deleteAll() + customerProfileRepository.deleteAll() + customerProfileUuidRepository.deleteAll() + warehouseInventoryRepository.deleteAll() cleanupAdditionalRepositories() } From 0b355fd10c2a36eaed5234d21a7a29a09fdfc6e1 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 1 Jul 2026 15:52:59 +0200 Subject: [PATCH 43/57] Fixed PostgresDbInit --- .../data/r2dbc/postgres/PostgresDbInit.java | 54 +++++++++++--- .../r2dbc/postgres/PostgresUpsertSpec.groovy | 12 +++ .../r2dbc/postgres/vector/PostgresDbInit.java | 74 ------------------- 3 files changed, 54 insertions(+), 86 deletions(-) delete mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/vector/PostgresDbInit.java 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 0b1adc3527e..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,10 +15,11 @@ */ 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; @@ -47,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; @@ -59,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")); @@ -80,14 +86,39 @@ public DefaultBasicR2dbcProperties onCreated(BeanCreatedEvent 0) { - try (Connection connection = DriverManager.getConnection(url, info)) { - try (CallableStatement statement = connection.prepareCall("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";")) { - statement.execute(); - } + try (Connection ignored = DriverManager.getConnection(url, info)) { last = null; break; } catch (SQLException e) { @@ -104,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 index 642be913c00..1de4cd9281a 100644 --- 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 @@ -26,6 +26,7 @@ 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 { @@ -53,6 +54,17 @@ class PostgresUpsertSpec extends AbstractUpsertSpec implements PostgresTestPrope 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") 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); - } -} From 9f4cb946f295356ecd1fc92ce05bf5f09220f993 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 1 Jul 2026 16:55:52 +0200 Subject: [PATCH 44/57] Replaced conflictProperties with conflictsOn in Upsert annotation --- ...leXECustomerProfileSequenceRepository.java | 4 ++-- ...gresCustomerProfileSequenceRepository.java | 4 ++-- .../MSCustomerProfileSequenceRepository.java | 4 ++-- .../io/micronaut/data/annotation/Upsert.java | 6 ++--- .../visitors/finders/UpsertMethodMatcher.java | 2 +- .../data/processor/sql/BuildInsertSpec.groovy | 22 ++++++++--------- ...leXECustomerProfileSequenceRepository.java | 4 ++-- ...gresCustomerProfileSequenceRepository.java | 4 ++-- .../MSCustomerProfileSequenceRepository.java | 4 ++-- .../upsert/CustomerProfileRepository.java | 24 +++++++++---------- .../upsert/CustomerProfileUuidRepository.java | 4 ++-- .../upsert/WarehouseInventoryRepository.java | 4 ++-- 12 files changed, 43 insertions(+), 43 deletions(-) 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 index f8d8f0caec6..7f8f271012a 100644 --- 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 @@ -25,9 +25,9 @@ @JdbcRepository(dialect = Dialect.ORACLE) public interface OracleXECustomerProfileSequenceRepository extends CrudRepository { - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") List upsertAll(Iterable customerProfiles); } 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 index aef896b72e2..3072b99f854 100644 --- 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 @@ -25,9 +25,9 @@ @JdbcRepository(dialect = Dialect.POSTGRES) public interface PostgresCustomerProfileSequenceRepository extends CrudRepository { - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") List upsertAll(Iterable customerProfiles); } 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 index 08412a27105..069b7df5c4f 100644 --- 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 @@ -25,9 +25,9 @@ @JdbcRepository(dialect = Dialect.SQL_SERVER) public interface MSCustomerProfileSequenceRepository extends CrudRepository { - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") List upsertAll(Iterable customerProfiles); } 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 index 2419863100f..7f7dd9679f3 100644 --- a/data-model/src/main/java/io/micronaut/data/annotation/Upsert.java +++ b/data-model/src/main/java/io/micronaut/data/annotation/Upsert.java @@ -38,7 +38,7 @@ * its parameter. *

*

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

* @@ -52,7 +52,7 @@ /** * The persistent entity properties to use as the conflict target. * - * @return The conflict properties + * @return The conflict target properties */ - String[] conflictProperties() default {}; + String[] conflictsOn() default {}; } 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 index c8fffc0d8ea..a1bae9fcbf4 100644 --- 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 @@ -252,7 +252,7 @@ private boolean producesEntityOrIterableOfEntity(@Nullable ClassElement type) { } private List conflictProperties(MethodMatchContext matchContext) { - return Arrays.asList(matchContext.getAnnotationMetadata().stringValues(Upsert.class, "conflictProperties")); + return Arrays.asList(matchContext.getAnnotationMetadata().stringValues(Upsert.class, "conflictsOn")); } } 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 f4904914318..7d8049f2f0c 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 @@ -559,10 +559,10 @@ import java.util.List; @JdbcRepository(dialect=Dialect.${dialect.name()}) @io.micronaut.context.annotation.Executable interface MyInterface extends GenericRepository { - @Upsert(conflictProperties = "name") + @Upsert(conflictsOn = "name") Test put(Test test); - @Upsert(conflictProperties = "name") + @Upsert(conflictsOn = "name") List putAll(List tests); } @@ -631,10 +631,10 @@ import java.util.List; @JdbcRepository(dialect=Dialect.${dialect.name()}) @io.micronaut.context.annotation.Executable interface MyInterface extends GenericRepository { - @Upsert(conflictProperties = "name") + @Upsert(conflictsOn = "name") Test put(Test test); - @Upsert(conflictProperties = "name") + @Upsert(conflictsOn = "name") List putAll(List tests); } @@ -719,16 +719,16 @@ import reactor.core.publisher.Mono; @JdbcRepository(dialect=Dialect.ORACLE) @io.micronaut.context.annotation.Executable interface MyInterface extends GenericRepository { - @Upsert(conflictProperties = "name") + @Upsert(conflictsOn = "name") Test put(Test test); - @Upsert(conflictProperties = "name") + @Upsert(conflictsOn = "name") Mono putMono(Test test); - @Upsert(conflictProperties = "name") + @Upsert(conflictsOn = "name") Flux putFlux(List tests); - @Upsert(conflictProperties = "name") + @Upsert(conflictsOn = "name") void putNoResult(Test test); } @@ -795,7 +795,7 @@ import io.micronaut.data.repository.GenericRepository; @JdbcRepository(dialect=Dialect.${dialect.name()}) @io.micronaut.context.annotation.Executable interface MyInterface extends GenericRepository { - @Upsert(conflictProperties = {"name", "pages"}) + @Upsert(conflictsOn = {"name", "pages"}) Test put(Test test); } @@ -978,8 +978,8 @@ class Test { "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(conflictProperties = \"\")" | "" | "@Id" | "" | "conflict property cannot be blank" - "unknown conflict property" | "@Upsert(conflictProperties = \"missing\")" | "" | "@Id" | "" | "conflict property does not exist: missing" + "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 build upsert fails with both entity and iterable entity parameters"() { 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 index 6d76ec0ee85..57c10637a40 100644 --- 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 @@ -25,9 +25,9 @@ @R2dbcRepository(dialect = Dialect.ORACLE) public interface OracleXECustomerProfileSequenceRepository extends CrudRepository { - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") List upsertAll(Iterable customerProfiles); } 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 index 9bf33dd4862..a41fb50561d 100644 --- 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 @@ -25,9 +25,9 @@ @R2dbcRepository(dialect = Dialect.POSTGRES) public interface PostgresCustomerProfileSequenceRepository extends CrudRepository { - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") List upsertAll(Iterable customerProfiles); } 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 index 949b4da430a..c9c47597d2e 100644 --- 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 @@ -25,9 +25,9 @@ @R2dbcRepository(dialect = Dialect.SQL_SERVER) public interface MSCustomerProfileSequenceRepository extends CrudRepository { - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") CustomerProfileSequence upsert(CustomerProfileSequence customerProfile); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") List upsertAll(Iterable customerProfiles); } 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 index f1ffb117106..e32935483b0 100644 --- 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 @@ -26,39 +26,39 @@ public interface CustomerProfileRepository extends CrudRepository { - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") CustomerProfile upsert(CustomerProfile customerProfile); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") Mono upsertMono(CustomerProfile profile); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") CompletableFuture upsertFuture(CustomerProfile profile); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") void upsertNoResult(CustomerProfile customerProfile); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") Mono upsertMonoNoResult(CustomerProfile customerProfile); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") CompletableFuture upsertFutureNoResult(CustomerProfile profile); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") List upsertAll(Iterable customerProfiles); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") Flux upsertAllFlux(Iterable profiles); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") CompletableFuture> upsertAllFuture(Iterable profiles); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") void upsertAllNoResult(Iterable customerProfiles); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") Flux upsertAllFluxNoResult(Iterable profiles); - @Upsert(conflictProperties = "email") + @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 index 854a9442e9e..5dfeb1e29fc 100644 --- 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 @@ -23,9 +23,9 @@ public interface CustomerProfileUuidRepository extends CrudRepository { - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") CustomerProfileUuid upsert(CustomerProfileUuid customerProfile); - @Upsert(conflictProperties = "email") + @Upsert(conflictsOn = "email") List upsertAll(Iterable customerProfiles); } 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 index 586cf3fc6e4..1721b9466bd 100644 --- 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 @@ -23,9 +23,9 @@ public interface WarehouseInventoryRepository extends CrudRepository { - @Upsert(conflictProperties = {"sku", "warehouse"}) + @Upsert(conflictsOn = {"sku", "warehouse"}) WarehouseInventory upsert(WarehouseInventory warehouseInventory); - @Upsert(conflictProperties = {"sku", "warehouse"}) + @Upsert(conflictsOn = {"sku", "warehouse"}) List upsertAll(Iterable warehouseInventories); } From 7c0129fe2d6dd9732508c5abae0dd5e5f4c4df08 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 1 Jul 2026 17:35:27 +0200 Subject: [PATCH 45/57] Rolled back temp changes --- data-r2dbc/src/test/resources/logback.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/data-r2dbc/src/test/resources/logback.xml b/data-r2dbc/src/test/resources/logback.xml index 485ff7fb9c4..9bd4b30beba 100644 --- a/data-r2dbc/src/test/resources/logback.xml +++ b/data-r2dbc/src/test/resources/logback.xml @@ -8,8 +8,6 @@ - - From 0a0fd14c57af7c8a49b8e26b6e78ee930ad74b00 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 2 Jul 2026 10:01:38 +0200 Subject: [PATCH 46/57] Added docs --- .../guide/shared/dataUpdates/upserts.adoc | 222 ++++++++++++++++++ src/main/docs/guide/toc.yml | 1 + 2 files changed, 223 insertions(+) create mode 100644 src/main/docs/guide/shared/dataUpdates/upserts.adoc 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..28cf2cad1df --- /dev/null +++ b/src/main/docs/guide/shared/dataUpdates/upserts.adoc @@ -0,0 +1,222 @@ +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. +Use ann:data.annotation.Upsert[] with `conflictsOn` to use another persistent property, or a set of persistent properties, as the conflict target. + +[source,java] +---- +Contact upsert(Contact contact); + +List upsertAll(Iterable contacts); + +@Upsert +Contact put(Contact contact); + +@Upsert(conflictsOn = "email") +Contact putByEmail(Contact contact); + +@Upsert(conflictsOn = {"provider", "email"}) +Contact putByProviderAndEmail(Contact contact); +---- + +The same repository method shapes are supported for asynchronous and reactive repositories where the repository type supports those return types: + +[source,java] +---- +@Upsert(conflictsOn = "email") +Mono upsertMono(Contact contact); + +@Upsert(conflictsOn = "email") +CompletableFuture upsertFuture(Contact contact); + +@Upsert(conflictsOn = "email") +Flux upsertAllFlux(Iterable contacts); + +@Upsert(conflictsOn = "email") +CompletableFuture> upsertAllFuture(Iterable contacts); +---- + +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: + +[source,java] +---- +@Upsert(conflictsOn = "email") +Contact putByEmail(Contact contact); + +@Upsert(conflictsOn = {"provider", "email"}) +Contact putByProviderAndEmail(Contact contact); +---- + +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.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. +|=== + +== JDBC and R2DBC + +JDBC and R2DBC repositories use the same compile-time matcher and SQL query builder for upsert methods, so the repository declarations and generated SQL shapes are the same for the same dialect. +The main differences are in runtime execution: + +* JDBC repositories execute blocking operations and may use JDBC batching for `upsertAll` when the dialect and driver support batching. +* R2DBC repositories execute through reactive publishers. Reactive return types such as `Mono` and `Flux` are intended for R2DBC/reactive repositories. +* Dialect-specific generated-id returning is runtime dependent. Oracle and SQL Server have dedicated handling for generated IDs on upsert. Other dialects may execute the upsert successfully but not mutate the entity with a database-generated value. +* R2DBC generated-id returning depends on driver support for the generated SQL and return mechanism. Do not assume every generated value that can be produced by the database can also be read back through every R2DBC driver. + +== Single and Batch Upsert + +A single-entity upsert method executes the generated upsert statement for one entity. + +[source,java] +---- +Contact upsert(Contact contact); +---- + +An iterable upsert method applies the same upsert operation to each entity. + +[source,java] +---- +List upsertAll(Iterable contacts); +---- + +`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 + +Assigned identifiers are the most portable option for upsert. + +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. + +Returning generated values from an upsert is not equally supported by every database and driver. +Micronaut Data can populate generated IDs only when the generated SQL and runtime implementation can read the value back. +Oracle uses `MERGE ... RETURNING ... INTO`. +SQL Server uses `MERGE ... OUTPUT inserted.`. +For other dialects, an inserted row may be created with a database-generated identity, but the returned entity is not guaranteed to contain that generated value unless the dialect/runtime supports returning it for upsert. + +== 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[]. + +Oracle JSON Duality views are updatable for supported insert, update, and delete use cases, but Micronaut Data's upsert implementation also needs to return generated values for some upsert methods. +`MERGE` with a `RETURNING` clause over a JSON view document may fail with the following Oracle error: + +[source] +---- +SQL Error: ORA-03001: unimplemented feature +---- + +For example: + +[source,sql] +---- +MERGE INTO "CONTACT_VIEW" target +USING (SELECT ? c0, ? DATA FROM DUAL) source +ON (JSON_VALUE(target.DATA,'$._id') = source.c0) +WHEN MATCHED THEN + UPDATE SET target.DATA = source.DATA +WHEN NOT MATCHED THEN + INSERT (DATA) VALUES (source.DATA) +RETURNING JSON_VALUE(DATA,'$._id') INTO ? +---- + +Because this cannot be implemented consistently across supported upsert method shapes, Micronaut Data rejects upsert methods for ann:data.annotation.JsonView[] entities. 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 From f7955b2a56e8e0d28615f8cff35b5b1fb4b982cd Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 2 Jul 2026 11:05:44 +0200 Subject: [PATCH 47/57] Upsert impl for sqlite --- .../data/model/query/builder/sql/SqlUpsertQueryBuilder.java | 2 +- .../io/micronaut/data/processor/sql/BuildInsertSpec.groovy | 4 ++++ src/main/docs/guide/shared/dataUpdates/upserts.adoc | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) 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 index 342bf1f33ad..588bb76ad28 100644 --- 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 @@ -74,7 +74,7 @@ QueryResult build(AnnotationMetadata repositoryMetadata, QueryBuilder.UpsertQuer String query = switch (dialect) { case H2 -> buildH2Upsert(tableName, data); case MYSQL -> buildMySqlUpsert(tableName, data); - case POSTGRES -> buildPostgresUpsert(tableName, data); + case POSTGRES, SQLITE -> buildPostgresUpsert(tableName, data); case SQL_SERVER -> buildSqlServerUpsert(tableName, data); case ORACLE -> buildOracleUpsert(tableName, data); case ANSI -> buildAnsiUpsert(tableName, data); 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 7d8049f2f0c..b2f202521cc 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 @@ -543,6 +543,7 @@ class Test { 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"] } @@ -615,6 +616,7 @@ class Test { 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"] } @@ -701,6 +703,7 @@ class Test { 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] } @@ -857,6 +860,7 @@ class Test { 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"] } diff --git a/src/main/docs/guide/shared/dataUpdates/upserts.adoc b/src/main/docs/guide/shared/dataUpdates/upserts.adoc index 28cf2cad1df..f925936614e 100644 --- a/src/main/docs/guide/shared/dataUpdates/upserts.adoc +++ b/src/main/docs/guide/shared/dataUpdates/upserts.adoc @@ -92,6 +92,10 @@ Micronaut Data generates dialect-specific SQL for upsert operations. |`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. From 5da34f064e224b1d925e1308a280646a211b80c9 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 2 Jul 2026 11:21:30 +0200 Subject: [PATCH 48/57] Upsert tests for sqlite --- .../SQLiteCustomerProfileRepository.java | 29 +++ .../SQLiteCustomerProfileUuidRepository.java | 24 ++ .../sqlite/SQLiteProductReviewRepository.java | 24 ++ .../data/jdbc/sqlite/SQLiteUpsertTest.java | 210 ++++++++++++++++++ .../SQLiteWarehouseInventoryRepository.java | 29 +++ 5 files changed, 316 insertions(+) create mode 100644 test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteCustomerProfileRepository.java create mode 100644 test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteCustomerProfileUuidRepository.java create mode 100644 test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteProductReviewRepository.java create mode 100644 test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteUpsertTest.java create mode 100644 test-suite-data-jdbc-sqlite/src/test/java/io/micronaut/data/jdbc/sqlite/SQLiteWarehouseInventoryRepository.java 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); +} From 6d83a7c3ce4c7afe844ae3d296f2507a1550c298 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 2 Jul 2026 12:59:17 +0200 Subject: [PATCH 49/57] Updated docs --- .../guide/shared/dataUpdates/upserts.adoc | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/src/main/docs/guide/shared/dataUpdates/upserts.adoc b/src/main/docs/guide/shared/dataUpdates/upserts.adoc index f925936614e..7392962715b 100644 --- a/src/main/docs/guide/shared/dataUpdates/upserts.adoc +++ b/src/main/docs/guide/shared/dataUpdates/upserts.adoc @@ -109,16 +109,6 @@ Micronaut Data generates dialect-specific SQL for upsert operations. |This is the generic SQL `MERGE` form. Actual support depends on the database used with the ANSI dialect. |=== -== JDBC and R2DBC - -JDBC and R2DBC repositories use the same compile-time matcher and SQL query builder for upsert methods, so the repository declarations and generated SQL shapes are the same for the same dialect. -The main differences are in runtime execution: - -* JDBC repositories execute blocking operations and may use JDBC batching for `upsertAll` when the dialect and driver support batching. -* R2DBC repositories execute through reactive publishers. Reactive return types such as `Mono` and `Flux` are intended for R2DBC/reactive repositories. -* Dialect-specific generated-id returning is runtime dependent. Oracle and SQL Server have dedicated handling for generated IDs on upsert. Other dialects may execute the upsert successfully but not mutate the entity with a database-generated value. -* R2DBC generated-id returning depends on driver support for the generated SQL and return mechanism. Do not assume every generated value that can be produced by the database can also be read back through every R2DBC driver. - == Single and Batch Upsert A single-entity upsert method executes the generated upsert statement for one entity. @@ -200,27 +190,3 @@ It only makes sense when the application needs to read the existing row under lo == 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[]. - -Oracle JSON Duality views are updatable for supported insert, update, and delete use cases, but Micronaut Data's upsert implementation also needs to return generated values for some upsert methods. -`MERGE` with a `RETURNING` clause over a JSON view document may fail with the following Oracle error: - -[source] ----- -SQL Error: ORA-03001: unimplemented feature ----- - -For example: - -[source,sql] ----- -MERGE INTO "CONTACT_VIEW" target -USING (SELECT ? c0, ? DATA FROM DUAL) source -ON (JSON_VALUE(target.DATA,'$._id') = source.c0) -WHEN MATCHED THEN - UPDATE SET target.DATA = source.DATA -WHEN NOT MATCHED THEN - INSERT (DATA) VALUES (source.DATA) -RETURNING JSON_VALUE(DATA,'$._id') INTO ? ----- - -Because this cannot be implemented consistently across supported upsert method shapes, Micronaut Data rejects upsert methods for ann:data.annotation.JsonView[] entities. From 2f7a7bfd68074d4ae8c256451b6d27fae4784bba Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 2 Jul 2026 13:26:08 +0200 Subject: [PATCH 50/57] Updated docs --- src/main/docs/guide/shared/dataUpdates/upserts.adoc | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/main/docs/guide/shared/dataUpdates/upserts.adoc b/src/main/docs/guide/shared/dataUpdates/upserts.adoc index 7392962715b..0d165a6c946 100644 --- a/src/main/docs/guide/shared/dataUpdates/upserts.adoc +++ b/src/main/docs/guide/shared/dataUpdates/upserts.adoc @@ -135,17 +135,9 @@ Use an explicit transaction when all items must commit or roll back together. == Generated Identity Values -Assigned identifiers are the most portable option for upsert. - 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. -Returning generated values from an upsert is not equally supported by every database and driver. -Micronaut Data can populate generated IDs only when the generated SQL and runtime implementation can read the value back. -Oracle uses `MERGE ... RETURNING ... INTO`. -SQL Server uses `MERGE ... OUTPUT inserted.`. -For other dialects, an inserted row may be created with a database-generated identity, but the returned entity is not guaranteed to contain that generated value unless the dialect/runtime supports returning it for upsert. - == Optimistic Locking Optimistic locking is not supported by the native upsert implementation. From ab1bed9538f706536702ccac1379d69166d4454b Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 2 Jul 2026 13:27:15 +0200 Subject: [PATCH 51/57] Rolled back --- .github/workflows/gradle.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 4d4bec37dcc..f1deb64cf99 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -7,7 +7,7 @@ name: Java CI on: push: branches: - - upsert-impl + - master - '[0-9]+.[0-9]+.x' pull_request: branches: From c0e0dd619f1a6e4f4bc0fde6f6ec647a6b24b2ce Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Fri, 3 Jul 2026 12:52:37 +0200 Subject: [PATCH 52/57] Addressed copilot comments --- .../io/micronaut/data/annotation/Upsert.java | 6 +-- .../visitors/finders/UpsertMethodMatcher.java | 6 +++ .../data/processor/sql/BuildInsertSpec.groovy | 50 +++++++++++++++++-- 3 files changed, 56 insertions(+), 6 deletions(-) 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 index 7f7dd9679f3..21f7e74a998 100644 --- a/data-model/src/main/java/io/micronaut/data/annotation/Upsert.java +++ b/data-model/src/main/java/io/micronaut/data/annotation/Upsert.java @@ -32,10 +32,10 @@ *

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

The annotated method must either be declared {@code void}, or have a return type that is the same as the type of - * its parameter. + *

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 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 index a1bae9fcbf4..6cc7973132c 100644 --- 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 @@ -79,6 +79,9 @@ public MethodMatch match(MethodMatchContext matchContext) { @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(); @@ -96,6 +99,9 @@ protected MethodMatch match(MethodMatchContext matchContext, List TypeUtils.isIterableOfEntity(p.getGenericType()) || TypeUtils.isEntity(p.getGenericType()))) { String unsupportedReason = explicitUpsertUnsupportedReason(matchContext); if (unsupportedReason != null) { 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 b2f202521cc..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 @@ -986,7 +986,46 @@ class Test { "unknown conflict property" | "@Upsert(conflictsOn = \"missing\")" | "" | "@Id" | "" | "conflict property does not exist: missing" } - void "test build upsert fails with both entity and iterable entity parameters"() { + 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.*; @@ -999,7 +1038,7 @@ import java.util.List; @io.micronaut.context.annotation.Executable interface MyInterface extends GenericRepository { @Upsert - List put(Test test, List tests); + ${method} } @MappedEntity("upsert_test") @@ -1028,7 +1067,12 @@ class Test { then: def ex = thrown(RuntimeException) - ex.message.contains("Cannot implement upsert method with both entity and iterable entity parameters") + 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 "() { From ae7af3e244c8814f6a8849f6f8932db46eb8a217 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 22 Jul 2026 13:47:46 +0200 Subject: [PATCH 53/57] Created DataSourceConfigurationUtils and moved resolveConfiguredDataSourceNames and resolveDataSourceName methods from JdbcRepositoryOperationsConditions and R2dbcRepositoryOperationsConditions to it --- .../JdbcRepositoryOperationsConditions.java | 81 +--------- .../R2dbcRepositoryOperationsConditions.java | 81 +--------- .../support/DataSourceConfigurationUtils.java | 134 ++++++++++++++++ .../DataSourceConfigurationUtilsSpec.groovy | 149 ++++++++++++++++++ 4 files changed, 293 insertions(+), 152 deletions(-) create mode 100644 data-runtime/src/main/java/io/micronaut/data/runtime/support/DataSourceConfigurationUtils.java create mode 100644 data-runtime/src/test/groovy/io/micronaut/data/runtime/support/DataSourceConfigurationUtilsSpec.groovy 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 index 7c1a14a22a3..c6adf82cc40 100644 --- 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 @@ -15,17 +15,12 @@ */ package io.micronaut.data.jdbc.operations; -import io.micronaut.context.BeanResolutionContext; -import io.micronaut.context.Qualifier; import io.micronaut.context.condition.Condition; import io.micronaut.context.condition.ConditionContext; import io.micronaut.core.annotation.Internal; -import io.micronaut.core.naming.Named; import io.micronaut.data.jdbc.config.DataJdbcConfiguration; -import io.micronaut.inject.BeanDefinition; +import io.micronaut.data.runtime.support.DataSourceConfigurationUtils; -import java.util.ArrayList; -import java.util.Collection; import java.util.List; import java.util.Optional; @@ -131,12 +126,12 @@ static boolean isSqlServerDialect(ConditionContext context) { * @return {@code true} when at least the current or one configured datasource should use default operations */ static boolean isDefaultOperationsDialect(ConditionContext context) { - Optional dataSourceName = resolveDataSourceName(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 = resolveConfiguredDataSourceNames(context); + List dataSourceNames = DataSourceConfigurationUtils.resolveConfiguredDataSourceNames(context, DATASOURCES, DataJdbcConfiguration.class); if (dataSourceNames.isEmpty()) { return true; } @@ -145,11 +140,11 @@ static boolean isDefaultOperationsDialect(ConditionContext context) { } private static boolean isDialect(ConditionContext context, String expectedDialect) { - Optional dataSourceName = resolveDataSourceName(context); + Optional dataSourceName = DataSourceConfigurationUtils.resolveDataSourceName(context); if (dataSourceName.isPresent()) { return isDialect(context, dataSourceName.get(), expectedDialect); } - List dataSourceNames = resolveConfiguredDataSourceNames(context); + List dataSourceNames = DataSourceConfigurationUtils.resolveConfiguredDataSourceNames(context, DATASOURCES, DataJdbcConfiguration.class); if (dataSourceNames.isEmpty()) { return isDialect(context, DEFAULT, expectedDialect); } @@ -161,70 +156,4 @@ private static boolean isDialect(ConditionContext context, String dataSourceName String dialect = context.getProperty(dialectProperty, String.class).orElse(null); return expectedDialect.equalsIgnoreCase(dialect); } - - /** - * Resolves all configured datasource names visible to the condition context. - * - *

This method is used when no current datasource qualifier is available yet. In that early - * bean-definition phase, the condition needs to know whether any configured datasource matches the - * operation type so the bean definition is not filtered out before {@code @EachBean(DataSource)} - * creates the qualified per-datasource beans. Property entries are used first; if the property - * resolver cannot enumerate them, the method falls back to the generated - * {@link DataJdbcConfiguration} bean definitions.

- * - * @param context The condition context - * @return The configured datasource names - */ - private static List resolveConfiguredDataSourceNames(ConditionContext context) { - Collection dataSourceNames = context.getPropertyEntries(DATASOURCES); - if (!dataSourceNames.isEmpty()) { - return List.copyOf(dataSourceNames); - } - Collection beanDefinitions = context.findBeanDefinitions(DataJdbcConfiguration.class); - if (beanDefinitions.isEmpty()) { - return List.of(); - } - List names = new ArrayList<>(beanDefinitions.size()); - for (Object candidate : beanDefinitions) { - if (candidate instanceof BeanDefinition beanDefinition) { - Qualifier qualifier = beanDefinition.getDeclaredQualifier(); - if (qualifier instanceof Named named) { - names.add(named.getName()); - } - } - } - return List.copyOf(names); - } - - /** - * Resolves the datasource name from the current qualifier. - * - *

This method answers which datasource the current bean resolution is creating or resolving. - * For example, resolving {@code JdbcRepositoryOperations} with {@code @Named("mdb")} returns - * {@code mdb}. If Micronaut is evaluating the condition before it has selected 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 - */ - private 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(); - } } 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 index 60079382747..54edc812230 100644 --- 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 @@ -15,17 +15,12 @@ */ package io.micronaut.data.r2dbc.operations; -import io.micronaut.context.BeanResolutionContext; -import io.micronaut.context.Qualifier; import io.micronaut.context.condition.Condition; import io.micronaut.context.condition.ConditionContext; import io.micronaut.core.annotation.Internal; -import io.micronaut.core.naming.Named; +import io.micronaut.data.runtime.support.DataSourceConfigurationUtils; import io.micronaut.data.r2dbc.config.DataR2dbcConfiguration; -import io.micronaut.inject.BeanDefinition; -import java.util.ArrayList; -import java.util.Collection; import java.util.List; import java.util.Optional; @@ -132,12 +127,12 @@ static boolean isSqlServerDialect(ConditionContext context) { * @return {@code true} when at least the current or one configured datasource should use default operations */ static boolean isDefaultOperationsDialect(ConditionContext context) { - Optional dataSourceName = resolveDataSourceName(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 = resolveConfiguredDataSourceNames(context); + List dataSourceNames = DataSourceConfigurationUtils.resolveConfiguredDataSourceNames(context, DATASOURCES, DataR2dbcConfiguration.class); if (dataSourceNames.isEmpty()) { return true; } @@ -146,11 +141,11 @@ static boolean isDefaultOperationsDialect(ConditionContext context) { } private static boolean isDialect(ConditionContext context, String expectedDialect) { - Optional dataSourceName = resolveDataSourceName(context); + Optional dataSourceName = DataSourceConfigurationUtils.resolveDataSourceName(context); if (dataSourceName.isPresent()) { return isDialect(context, dataSourceName.get(), expectedDialect); } - List dataSourceNames = resolveConfiguredDataSourceNames(context); + List dataSourceNames = DataSourceConfigurationUtils.resolveConfiguredDataSourceNames(context, DATASOURCES, DataR2dbcConfiguration.class); if (dataSourceNames.isEmpty()) { return isDialect(context, DEFAULT, expectedDialect); } @@ -162,70 +157,4 @@ private static boolean isDialect(ConditionContext context, String dataSourceName String dialect = context.getProperty(dialectProperty, String.class).orElse(null); return expectedDialect.equalsIgnoreCase(dialect); } - - /** - * Resolves all configured datasource names visible to the condition context. - * - *

This method is used when no current datasource qualifier is available yet. In that early - * bean-definition phase, the condition needs to know whether any configured datasource matches the - * operation type so the bean definition is not filtered out before {@code @EachBean(ConnectionFactory)} - * creates the qualified per-datasource beans. Property entries are used first; if the property - * resolver cannot enumerate them, the method falls back to the generated - * {@link DataR2dbcConfiguration} bean definitions.

- * - * @param context The condition context - * @return The configured datasource names - */ - private static List resolveConfiguredDataSourceNames(ConditionContext context) { - Collection dataSourceNames = context.getPropertyEntries(DATASOURCES); - if (!dataSourceNames.isEmpty()) { - return List.copyOf(dataSourceNames); - } - Collection beanDefinitions = context.findBeanDefinitions(DataR2dbcConfiguration.class); - if (beanDefinitions.isEmpty()) { - return List.of(); - } - List names = new ArrayList<>(beanDefinitions.size()); - for (Object candidate : beanDefinitions) { - if (candidate instanceof BeanDefinition beanDefinition) { - Qualifier qualifier = beanDefinition.getDeclaredQualifier(); - if (qualifier instanceof Named named) { - names.add(named.getName()); - } - } - } - return List.copyOf(names); - } - - /** - * Resolves the datasource name from the current qualifier. - * - *

This method answers which datasource the current bean resolution is creating or resolving. - * For example, resolving {@code R2dbcOperations} with {@code @Named("mdb")} returns {@code mdb}. - * If Micronaut is evaluating the condition before it has selected 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 - */ - private 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(); - } } 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 { + } +} From c159be957d0bab09650d555c7fe8436e522a12b2 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 22 Jul 2026 17:04:45 +0200 Subject: [PATCH 54/57] Removed final from SqlServerJdbcRepositoryOperations and OracleJdbcRepositoryOperations --- .../data/jdbc/operations/OracleJdbcRepositoryOperations.java | 5 +---- .../jdbc/operations/SqlServerJdbcRepositoryOperations.java | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) 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 index abee9a2c47e..b9ba53031fe 100644 --- 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 @@ -19,7 +19,6 @@ 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.util.CollectionUtils; import io.micronaut.data.connection.ConnectionOperations; import io.micronaut.data.exceptions.DataAccessException; @@ -74,8 +73,7 @@ @EachBean(DataSource.class) @Requires(classes = OraclePreparedStatement.class) @Requires(condition = OracleJdbcRepositoryOperationsCondition.class) -@Internal -public final class OracleJdbcRepositoryOperations extends DefaultJdbcRepositoryOperations { +final class OracleJdbcRepositoryOperations extends DefaultJdbcRepositoryOperations { /** * Default constructor. @@ -98,7 +96,6 @@ public final class OracleJdbcRepositoryOperations extends DefaultJdbcRepositoryO * @param conversionContextFactory The conversion context factory * @param sqlExceptionMapperList The SQL exception mapper list */ - @Internal @SuppressWarnings("ParameterNumber") OracleJdbcRepositoryOperations(@Parameter String dataSourceName, @Parameter DataJdbcConfiguration jdbcConfiguration, 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 index d6baa4c2bf5..8c17f874e47 100644 --- 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 @@ -19,7 +19,6 @@ 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.util.CollectionUtils; import io.micronaut.data.connection.ConnectionOperations; import io.micronaut.data.exceptions.DataAccessException; @@ -63,8 +62,7 @@ */ @EachBean(DataSource.class) @Requires(condition = SqlServerJdbcRepositoryOperationsCondition.class) -@Internal -public final class SqlServerJdbcRepositoryOperations extends DefaultJdbcRepositoryOperations { +final class SqlServerJdbcRepositoryOperations extends DefaultJdbcRepositoryOperations { /** * Default constructor. @@ -87,7 +85,6 @@ public final class SqlServerJdbcRepositoryOperations extends DefaultJdbcReposito * @param conversionContextFactory The conversion context factory * @param sqlExceptionMapperList The SQL exception mapper list */ - @Internal @SuppressWarnings("ParameterNumber") SqlServerJdbcRepositoryOperations(@Parameter String dataSourceName, @Parameter DataJdbcConfiguration jdbcConfiguration, From aee12454b3486c76dba6d5972bdb63a14d577b82 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Wed, 22 Jul 2026 17:05:03 +0200 Subject: [PATCH 55/57] Added javadoc to SqlUpsertQueryBuilder --- .../builder/sql/SqlUpsertQueryBuilder.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 index 588bb76ad28..d2f142e5aee 100644 --- 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 @@ -41,6 +41,23 @@ 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 = ','; From d81c4895a879ee6a54d0e02071af78c0acbc8f04 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 23 Jul 2026 11:07:20 +0200 Subject: [PATCH 56/57] Modified upsert docs to use snippet macro --- .../src/main/java/example/Flight.java | 43 ++++ .../main/java/example/FlightRepository.java | 32 +++ .../src/main/java/example/Passenger.java | 57 ++++++ .../java/example/PassengerRepository.java | 29 +++ .../java/example/oracle/FlightRepository.java | 10 + .../example/oracle/PassengerRepository.java | 10 + .../src/test/java/example/UpsertSpec.java | 187 ++++++++++++++++++ .../src/test/java/example/h2/UpsertSpec.java | 7 + .../test/java/example/oracle/UpsertSpec.java | 9 + .../src/main/java/example/Flight.java | 41 ++++ .../main/java/example/FlightRepository.java | 20 ++ .../src/test/java/example/UpsertTest.java | 64 ++++++ .../guide/shared/dataUpdates/upserts.adoc | 63 ++---- 13 files changed, 525 insertions(+), 47 deletions(-) create mode 100644 doc-examples/jdbc-example-java/src/main/java/example/Flight.java create mode 100644 doc-examples/jdbc-example-java/src/main/java/example/FlightRepository.java create mode 100644 doc-examples/jdbc-example-java/src/main/java/example/Passenger.java create mode 100644 doc-examples/jdbc-example-java/src/main/java/example/PassengerRepository.java create mode 100644 doc-examples/jdbc-example-java/src/main/java/example/oracle/FlightRepository.java create mode 100644 doc-examples/jdbc-example-java/src/main/java/example/oracle/PassengerRepository.java create mode 100644 doc-examples/jdbc-example-java/src/test/java/example/UpsertSpec.java create mode 100644 doc-examples/jdbc-example-java/src/test/java/example/h2/UpsertSpec.java create mode 100644 doc-examples/jdbc-example-java/src/test/java/example/oracle/UpsertSpec.java create mode 100644 doc-examples/r2dbc-example-java/src/main/java/example/Flight.java create mode 100644 doc-examples/r2dbc-example-java/src/main/java/example/FlightRepository.java create mode 100644 doc-examples/r2dbc-example-java/src/test/java/example/UpsertTest.java 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 index 0d165a6c946..faca7dda2c0 100644 --- a/src/main/docs/guide/shared/dataUpdates/upserts.adoc +++ b/src/main/docs/guide/shared/dataUpdates/upserts.adoc @@ -6,40 +6,27 @@ 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. -Use ann:data.annotation.Upsert[] with `conflictsOn` to use another persistent property, or a set of persistent properties, as the conflict target. +The following JDBC example uses the caller-provided flight number as the identity: -[source,java] ----- -Contact upsert(Contact contact); - -List upsertAll(Iterable contacts); +snippet::example.Flight[project-base="doc-examples/jdbc-example", source="main", tags="upsert-entity"] -@Upsert -Contact put(Contact contact); +Its repository declares single and iterable upsert methods by name, uses ann:data.annotation.Upsert[] with another method name, and includes asynchronous variants: -@Upsert(conflictsOn = "email") -Contact putByEmail(Contact contact); +snippet::example.FlightRepository[project-base="doc-examples/jdbc-example", source="main", tags="upsert-repository"] -@Upsert(conflictsOn = {"provider", "email"}) -Contact putByProviderAndEmail(Contact contact); ----- +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: -The same repository method shapes are supported for asynchronous and reactive repositories where the repository type supports those return types: +snippet::example.Passenger[project-base="doc-examples/jdbc-example", source="main", tags="upsert-entity"] -[source,java] ----- -@Upsert(conflictsOn = "email") -Mono upsertMono(Contact contact); +Its repository uses `email` as the conflict target and returns the persisted passenger for single-item operations: -@Upsert(conflictsOn = "email") -CompletableFuture upsertFuture(Contact contact); +snippet::example.PassengerRepository[project-base="doc-examples/jdbc-example", source="main", tags="upsert-repository"] -@Upsert(conflictsOn = "email") -Flux upsertAllFlux(Iterable contacts); +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: -@Upsert(conflictsOn = "email") -CompletableFuture> upsertAllFuture(Iterable contacts); ----- +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. @@ -52,16 +39,8 @@ It is not implemented for repositories that rely on implicit query execution, su 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: - -[source,java] ----- -@Upsert(conflictsOn = "email") -Contact putByEmail(Contact contact); - -@Upsert(conflictsOn = {"provider", "email"}) -Contact putByProviderAndEmail(Contact contact); ----- +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. @@ -111,19 +90,9 @@ Micronaut Data generates dialect-specific SQL for upsert operations. == Single and Batch Upsert -A single-entity upsert method executes the generated upsert statement for one entity. - -[source,java] ----- -Contact upsert(Contact contact); ----- +A single-entity upsert method, such as `FlightRepository.upsert(Flight)`, executes the generated upsert statement for one entity. -An iterable upsert method applies the same upsert operation to each entity. - -[source,java] ----- -List upsertAll(Iterable contacts); ----- +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. From 8a38d20585c93236d998deb4912888875220ccc8 Mon Sep 17 00:00:00 2001 From: Milenko Supic Date: Thu, 23 Jul 2026 14:05:23 +0200 Subject: [PATCH 57/57] Trigger CI build