From 4b1cb02f9ae0a7460e0b6bdd5741aa1688b41c5e Mon Sep 17 00:00:00 2001 From: mipo256 Date: Mon, 14 Apr 2025 16:18:02 +0300 Subject: [PATCH 1/3] Polishing Signed-off-by: mipo256 --- .../convert/IdGeneratingEntityCallback.java | 6 +++--- .../r2dbc/convert/MappingR2dbcConverter.java | 17 +++++++---------- .../data/r2dbc/core/R2dbcEntityTemplate.java | 10 +++++----- .../data/r2dbc/query/UpdateMapper.java | 2 +- .../core/dialect/PostgresDialect.java | 1 + 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/IdGeneratingEntityCallback.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/IdGeneratingEntityCallback.java index 7d6f988d0a..878fd1b944 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/IdGeneratingEntityCallback.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/IdGeneratingEntityCallback.java @@ -56,14 +56,14 @@ public Object onBeforeSave(Object aggregate, MutableAggregateChange aggr return aggregate; } - RelationalPersistentProperty property = entity.getRequiredIdProperty(); + RelationalPersistentProperty idProperty = entity.getRequiredIdProperty(); PersistentPropertyAccessor accessor = entity.getPropertyAccessor(aggregate); - if (!entity.isNew(aggregate) || delegate.hasValue(property, accessor) || !property.hasSequence()) { + if (!entity.isNew(aggregate) || delegate.hasValue(idProperty, accessor) || !idProperty.hasSequence()) { return aggregate; } - delegate.generateSequenceValue(property, accessor); + delegate.generateSequenceValue(idProperty, accessor); return accessor.getBean(); } diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/convert/MappingR2dbcConverter.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/convert/MappingR2dbcConverter.java index bf5f82b789..82f96e1e30 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/convert/MappingR2dbcConverter.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/convert/MappingR2dbcConverter.java @@ -186,11 +186,11 @@ private void writeInternal(Object source, OutboundRow sink, Class userClass) RelationalPersistentEntity entity = getRequiredPersistentEntity(userClass); PersistentPropertyAccessor propertyAccessor = entity.getPropertyAccessor(source); - writeProperties(sink, entity, propertyAccessor, entity.isNew(source)); + writeProperties(sink, entity, propertyAccessor); } private void writeProperties(OutboundRow sink, RelationalPersistentEntity entity, - PersistentPropertyAccessor accessor, boolean isNew) { + PersistentPropertyAccessor accessor) { for (RelationalPersistentProperty property : entity) { @@ -213,15 +213,14 @@ private void writeProperties(OutboundRow sink, RelationalPersistentEntity ent } if (getConversions().isSimpleType(value.getClass())) { - writeSimpleInternal(sink, value, isNew, property); + writeSimpleInternal(sink, value, property); } else { - writePropertyInternal(sink, value, isNew, property); + writePropertyInternal(sink, value, property); } } } - private void writeSimpleInternal(OutboundRow sink, Object value, boolean isNew, - RelationalPersistentProperty property) { + private void writeSimpleInternal(OutboundRow sink, Object value, RelationalPersistentProperty property) { Object result = getPotentiallyConvertedSimpleWrite(value); @@ -229,8 +228,7 @@ private void writeSimpleInternal(OutboundRow sink, Object value, boolean isNew, Parameter.fromOrEmpty(result, getPotentiallyConvertedSimpleNullType(property.getType()))); } - private void writePropertyInternal(OutboundRow sink, Object value, boolean isNew, - RelationalPersistentProperty property) { + private void writePropertyInternal(OutboundRow sink, Object value, RelationalPersistentProperty property) { TypeInformation valueType = TypeInformation.of(value.getClass()); @@ -239,7 +237,7 @@ private void writePropertyInternal(OutboundRow sink, Object value, boolean isNew if (valueType.getActualType() != null && valueType.getRequiredActualType().isCollectionLike()) { // pass-thru nested collections - writeSimpleInternal(sink, value, isNew, property); + writeSimpleInternal(sink, value, property); return; } @@ -310,7 +308,6 @@ private Class getPotentiallyConvertedSimpleNullType(Class type) { if (customTarget.isPresent()) { return customTarget.get(); - } if (type.isEnum()) { diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplate.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplate.java index 7ec6c70cef..f277c6266f 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplate.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplate.java @@ -521,15 +521,15 @@ private void potentiallyRemoveId(RelationalPersistentEntity persistentEntity, return; } - SqlIdentifier columnName = idProperty.getColumnName(); - Parameter parameter = outboundRow.get(columnName); + SqlIdentifier idColumnName = idProperty.getColumnName(); + Parameter parameter = outboundRow.get(idColumnName); - if (shouldSkipIdValue(parameter, idProperty)) { - outboundRow.remove(columnName); + if (shouldSkipIdValue(parameter)) { + outboundRow.remove(idColumnName); } } - private boolean shouldSkipIdValue(@Nullable Parameter value, RelationalPersistentProperty property) { + private boolean shouldSkipIdValue(@Nullable Parameter value) { if (value == null || value.getValue() == null) { return true; diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/UpdateMapper.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/UpdateMapper.java index d770e10800..fb9eec7ed4 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/UpdateMapper.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/query/UpdateMapper.java @@ -83,7 +83,7 @@ public BoundAssignments getMappedObject(BindMarkers markers, Update update, Tabl * @param entity related {@link RelationalPersistentEntity}, can be {@literal null}. * @return the mapped {@link BoundAssignments}. */ - public BoundAssignments getMappedObject(BindMarkers markers, Map assignments, + public BoundAssignments getMappedObject(BindMarkers markers, Map assignments, Table table, @Nullable RelationalPersistentEntity entity) { Assert.notNull(markers, "BindMarkers must not be null"); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java index 6979c365e9..2bf62bcd76 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java @@ -57,6 +57,7 @@ public class PostgresDialect extends AbstractDialect { private IdentifierProcessing identifierProcessing = IdentifierProcessing.create(Quoting.ANSI, LetterCasing.LOWER_CASE); + private IdGeneration idGeneration = new IdGeneration() { @Override From d8185effc88f89b4ae00c53622561aa919050268 Mon Sep 17 00:00:00 2001 From: mipo256 Date: Mon, 14 Apr 2025 16:18:54 +0300 Subject: [PATCH 2/3] R2DBC support for @Sequence annotation Signed-off-by: mipo256 --- .../config/AbstractR2dbcConfiguration.java | 14 ++ .../IdGeneratingBeforeSaveCallback.java | 104 +++++++++++++++ .../IdGeneratingBeforeSaveCallbackTest.java | 124 ++++++++++++++++++ ...stgresR2dbcRepositoryIntegrationTests.java | 86 ++++++++++-- 4 files changed, 319 insertions(+), 9 deletions(-) create mode 100644 spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/mapping/IdGeneratingBeforeSaveCallback.java create mode 100644 spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/mapping/IdGeneratingBeforeSaveCallbackTest.java diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/config/AbstractR2dbcConfiguration.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/config/AbstractR2dbcConfiguration.java index 4862af4135..58f80741ce 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/config/AbstractR2dbcConfiguration.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/config/AbstractR2dbcConfiguration.java @@ -39,12 +39,14 @@ import org.springframework.data.r2dbc.core.DefaultReactiveDataAccessStrategy; import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; import org.springframework.data.r2dbc.core.ReactiveDataAccessStrategy; +import org.springframework.data.r2dbc.core.mapping.IdGeneratingBeforeSaveCallback; import org.springframework.data.r2dbc.dialect.DialectResolver; import org.springframework.data.r2dbc.dialect.R2dbcDialect; import org.springframework.data.r2dbc.mapping.R2dbcMappingContext; import org.springframework.data.relational.RelationalManagedTypes; import org.springframework.data.relational.core.mapping.DefaultNamingStrategy; import org.springframework.data.relational.core.mapping.NamingStrategy; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.Table; import org.springframework.data.util.TypeScanner; import org.springframework.lang.Nullable; @@ -182,6 +184,18 @@ public R2dbcMappingContext r2dbcMappingContext(Optional namingSt return context; } + /** + * Register a {@link IdGeneratingBeforeSaveCallback} using + * {@link #r2dbcMappingContext(Optional, R2dbcCustomConversions, RelationalManagedTypes)} and + * {@link #databaseClient()} + */ + @Bean + public IdGeneratingBeforeSaveCallback idGeneratingBeforeSaveCallback( + RelationalMappingContext relationalMappingContext, DatabaseClient databaseClient) { + return new IdGeneratingBeforeSaveCallback(relationalMappingContext, getDialect(lookupConnectionFactory()), + databaseClient); + } + /** * Creates a {@link ReactiveDataAccessStrategy} using the configured * {@link #r2dbcConverter(R2dbcMappingContext, R2dbcCustomConversions) R2dbcConverter}. diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/mapping/IdGeneratingBeforeSaveCallback.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/mapping/IdGeneratingBeforeSaveCallback.java new file mode 100644 index 0000000000..5dea28e0cb --- /dev/null +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/mapping/IdGeneratingBeforeSaveCallback.java @@ -0,0 +1,104 @@ +/* + * Copyright 2020-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.r2dbc.core.mapping; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; +import org.springframework.data.r2dbc.dialect.R2dbcDialect; +import org.springframework.data.r2dbc.mapping.OutboundRow; +import org.springframework.data.r2dbc.mapping.event.BeforeSaveCallback; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.r2dbc.core.Parameter; +import org.springframework.util.Assert; + +import reactor.core.publisher.Mono; + +/** + * R2DBC Callback for generating ID via the database sequence. + * + * @author Mikhail Polivakha + */ +public class IdGeneratingBeforeSaveCallback implements BeforeSaveCallback { + + private static final Log LOG = LogFactory.getLog(IdGeneratingBeforeSaveCallback.class); + + private final RelationalMappingContext relationalMappingContext; + private final R2dbcDialect dialect; + + private final DatabaseClient databaseClient; + + public IdGeneratingBeforeSaveCallback(RelationalMappingContext relationalMappingContext, R2dbcDialect dialect, + DatabaseClient databaseClient) { + this.relationalMappingContext = relationalMappingContext; + this.dialect = dialect; + this.databaseClient = databaseClient; + } + + @Override + public Publisher onBeforeSave(Object entity, OutboundRow row, SqlIdentifier table) { + Assert.notNull(entity, "The aggregate cannot be null at this point"); + + RelationalPersistentEntity persistentEntity = relationalMappingContext.getPersistentEntity(entity.getClass()); + + if (!persistentEntity.hasIdProperty() || // + !persistentEntity.getIdProperty().hasSequence() || // + !persistentEntity.isNew(entity) // + ) { + return Mono.just(entity); + } + + RelationalPersistentProperty property = persistentEntity.getIdProperty(); + SqlIdentifier idSequence = property.getSequence(); + + if (dialect.getIdGeneration().sequencesSupported()) { + return fetchIdFromSeq(entity, row, persistentEntity, idSequence); + } else { + illegalSequenceUsageWarning(entity); + } + + return Mono.just(entity); + } + + private Mono fetchIdFromSeq(Object entity, OutboundRow row, RelationalPersistentEntity persistentEntity, + SqlIdentifier idSequence) { + String sequenceQuery = dialect.getIdGeneration().createSequenceQuery(idSequence); + + return databaseClient // + .sql(sequenceQuery) // + .map((r, rowMetadata) -> r.get(0)) // + .one() // + .map(fetchedId -> { // + row.put( // + persistentEntity.getIdColumn().toSql(dialect.getIdentifierProcessing()), // + Parameter.from(fetchedId) // + ); + return entity; + }); + } + + private static void illegalSequenceUsageWarning(Object entity) { + LOG.warn(""" + It seems you're trying to insert an aggregate of type '%s' annotated with @Sequence, but the problem is RDBMS you're + working with does not support sequences as such. Falling back to identity columns + """.stripIndent().formatted(entity.getClass().getName())); + } +} diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/mapping/IdGeneratingBeforeSaveCallbackTest.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/mapping/IdGeneratingBeforeSaveCallbackTest.java new file mode 100644 index 0000000000..87029fec86 --- /dev/null +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/core/mapping/IdGeneratingBeforeSaveCallbackTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2020-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.r2dbc.core.mapping; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.function.BiFunction; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.reactivestreams.Publisher; +import org.springframework.data.annotation.Id; +import org.springframework.data.r2dbc.dialect.MySqlDialect; +import org.springframework.data.r2dbc.dialect.PostgresDialect; +import org.springframework.data.r2dbc.mapping.OutboundRow; +import org.springframework.data.r2dbc.mapping.R2dbcMappingContext; +import org.springframework.data.relational.core.mapping.Sequence; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.r2dbc.core.Parameter; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Unit tests for {@link IdGeneratingBeforeSaveCallback}. + * + * @author Mikhail Polivakha + */ +class IdGeneratingBeforeSaveCallbackTest { + + @Test + void testIdGenerationIsNotSupported() { + R2dbcMappingContext r2dbcMappingContext = new R2dbcMappingContext(); + r2dbcMappingContext.getPersistentEntity(SimpleEntity.class); + MySqlDialect dialect = MySqlDialect.INSTANCE; + DatabaseClient databaseClient = mock(DatabaseClient.class); + + IdGeneratingBeforeSaveCallback callback = new IdGeneratingBeforeSaveCallback(r2dbcMappingContext, dialect, + databaseClient); + + OutboundRow row = new OutboundRow("name", Parameter.from("my_name")); + SimpleEntity entity = new SimpleEntity(); + Publisher publisher = callback.onBeforeSave(entity, row, SqlIdentifier.unquoted("simple_entity")); + + StepVerifier.create(publisher).expectNext(entity).expectComplete().verify(); + assertThat(row).hasSize(1); // id is not added + } + + @Test + void testEntityIsNotAnnotatedWithSequence() { + R2dbcMappingContext r2dbcMappingContext = new R2dbcMappingContext(); + r2dbcMappingContext.getPersistentEntity(SimpleEntity.class); + PostgresDialect dialect = PostgresDialect.INSTANCE; + DatabaseClient databaseClient = mock(DatabaseClient.class); + + IdGeneratingBeforeSaveCallback callback = new IdGeneratingBeforeSaveCallback(r2dbcMappingContext, dialect, + databaseClient); + + OutboundRow row = new OutboundRow("name", Parameter.from("my_name")); + SimpleEntity entity = new SimpleEntity(); + Publisher publisher = callback.onBeforeSave(entity, row, SqlIdentifier.unquoted("simple_entity")); + + StepVerifier.create(publisher).expectNext(entity).expectComplete().verify(); + assertThat(row).hasSize(1); // id is not added + } + + @Test + void testIdGeneratedFromSequenceHappyPath() { + R2dbcMappingContext r2dbcMappingContext = new R2dbcMappingContext(); + r2dbcMappingContext.getPersistentEntity(WithSequence.class); + PostgresDialect dialect = PostgresDialect.INSTANCE; + DatabaseClient databaseClient = mock(DatabaseClient.class, RETURNS_DEEP_STUBS); + long generatedId = 1L; + + when(databaseClient.sql(Mockito.anyString()).map(Mockito.any(BiFunction.class)).one()).thenReturn( + Mono.just(generatedId)); + + IdGeneratingBeforeSaveCallback callback = new IdGeneratingBeforeSaveCallback(r2dbcMappingContext, dialect, + databaseClient); + + OutboundRow row = new OutboundRow("name", Parameter.from("my_name")); + WithSequence entity = new WithSequence(); + Publisher publisher = callback.onBeforeSave(entity, row, SqlIdentifier.unquoted("simple_entity")); + + StepVerifier.create(publisher).expectNext(entity).expectComplete().verify(); + assertThat(row).hasSize(2) + .containsEntry(SqlIdentifier.unquoted("id"), Parameter.from(generatedId)); + } + + static class SimpleEntity { + + @Id + private Long id; + + private String name; + } + + static class WithSequence { + + @Id + @Sequence(sequence = "seq_name") + private Long id; + + private String name; + } +} diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/PostgresR2dbcRepositoryIntegrationTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/PostgresR2dbcRepositoryIntegrationTests.java index bf3e4bcb19..5c04f12e56 100644 --- a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/PostgresR2dbcRepositoryIntegrationTests.java +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/PostgresR2dbcRepositoryIntegrationTests.java @@ -15,8 +15,13 @@ */ package org.springframework.data.r2dbc.repository; -import io.r2dbc.postgresql.codec.Json; -import io.r2dbc.spi.ConnectionFactory; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.Map; + +import javax.sql.DataSource; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.RegisterExtension; @@ -32,22 +37,20 @@ import org.springframework.data.r2dbc.repository.support.R2dbcRepositoryFactory; import org.springframework.data.r2dbc.testing.ExternalDatabase; import org.springframework.data.r2dbc.testing.PostgresTestSupport; +import org.springframework.data.relational.core.mapping.Sequence; import org.springframework.data.relational.core.mapping.Table; import org.springframework.data.repository.reactive.ReactiveCrudRepository; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.r2dbc.core.DatabaseClient; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; + +import io.r2dbc.postgresql.codec.Json; +import io.r2dbc.spi.ConnectionFactory; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -import javax.sql.DataSource; -import java.util.Collections; -import java.util.Map; - -import static org.assertj.core.api.Assertions.*; - /** * Integration tests for {@link LegoSetRepository} using {@link R2dbcRepositoryFactory} against Postgres. * @@ -62,12 +65,14 @@ public class PostgresR2dbcRepositoryIntegrationTests extends AbstractR2dbcReposi @Autowired WithJsonRepository withJsonRepository; + @Autowired WithIdFromSequenceRepository withIdFromSequenceRepository; + @Autowired WithHStoreRepository hstoreRepositoryWith; @Configuration @EnableR2dbcRepositories(considerNestedRepositories = true, includeFilters = @Filter( - classes = { PostgresLegoSetRepository.class, WithJsonRepository.class, WithHStoreRepository.class }, + classes = { PostgresLegoSetRepository.class, WithJsonRepository.class, WithHStoreRepository.class, WithIdFromSequenceRepository.class }, type = FilterType.ASSIGNABLE_TYPE)) static class IntegrationTestConfiguration extends AbstractR2dbcConfiguration { @@ -151,6 +156,51 @@ void shouldSaveAndLoadJson() { }).verifyComplete(); } + @Test + void shouldInsertWithAutoGeneratedId() { + + JdbcTemplate template = new JdbcTemplate(createDataSource()); + + template.execute("DROP TABLE IF EXISTS with_id_from_sequence"); + template.execute("CREATE SEQUENCE IF NOT EXISTS target_sequence START WITH 15"); + template.execute("CREATE TABLE with_id_from_sequence(\n" // + + " id BIGINT PRIMARY KEY,\n" // + + " name TEXT NOT NULL" // + + ");"); + + WithIdFromSequence entity = new WithIdFromSequence(null, "Jordane"); + withIdFromSequenceRepository.save(entity).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + + withIdFromSequenceRepository.findAll().as(StepVerifier::create).consumeNextWith(actual -> { + + assertThat(actual.id).isNotNull().isEqualTo(15); + assertThat(actual.name).isEqualTo("Jordane"); + }).verifyComplete(); + } + + @Test + void shouldUpdateNoIdGenerationHappens() { + + JdbcTemplate template = new JdbcTemplate(createDataSource()); + + template.execute("DROP TABLE IF EXISTS with_id_from_sequence"); + template.execute("CREATE SEQUENCE IF NOT EXISTS target_sequence"); + template.execute("CREATE TABLE with_id_from_sequence(\n" // + + " id BIGINT PRIMARY KEY,\n" // + + " name TEXT NOT NULL" // + + ");"); + template.execute("INSERT INTO with_id_from_sequence VALUES(4, 'Alex');"); + + WithIdFromSequence entity = new WithIdFromSequence(4L, "NewName"); + withIdFromSequenceRepository.save(entity).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + + withJsonRepository.findAll().as(StepVerifier::create).consumeNextWith(actual -> { + + assertThat(actual.jsonValue).isNotNull().isEqualTo(4); + assertThat(actual.jsonValue.asString()).isEqualTo("NewName"); + }).verifyComplete(); + } + @Test // gh-492 void shouldSaveAndLoadHStore() { @@ -188,6 +238,24 @@ interface WithJsonRepository extends ReactiveCrudRepository { } + static class WithIdFromSequence { + + @Id + @Sequence(sequence = "target_sequence") + Long id; + + String name; + + public WithIdFromSequence(Long id, String name) { + this.id = id; + this.name = name; + } + } + + interface WithIdFromSequenceRepository extends ReactiveCrudRepository { + + } + @Table("with_hstore") static class WithHStore { From 758529e97d7f890d660389df7942aaf2fb8e80e4 Mon Sep 17 00:00:00 2001 From: mipo256 Date: Mon, 14 Apr 2025 16:59:05 +0300 Subject: [PATCH 3/3] Added adoc documentation for @Sequence in Spring Data R2DBC Signed-off-by: mipo256 --- .../modules/ROOT/pages/r2dbc/sequences.adoc | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/main/antora/modules/ROOT/pages/r2dbc/sequences.adoc diff --git a/src/main/antora/modules/ROOT/pages/r2dbc/sequences.adoc b/src/main/antora/modules/ROOT/pages/r2dbc/sequences.adoc new file mode 100644 index 0000000000..ea5e0fd3da --- /dev/null +++ b/src/main/antora/modules/ROOT/pages/r2dbc/sequences.adoc @@ -0,0 +1,46 @@ +[[r2dbc.sequences]] += Sequences Support + +Since Spring Data R2DBC 3.5, properties that are annotated with `@Id` and thus represent +an Id property can additionally be annotated with `@Sequence`. This signals, that the Id property +value would be fetched from the configured sequence during an `INSERT` statement. By default, +without `@Sequence`, the identity column is assumed. Consider the following entity. + +.Entity with Id generation from sequence +[source,java] +---- +@Table +class MyEntity { + + @Id + @Sequence( + sequence = "my_seq", + schema = "public" + ) + private Long id; + + private String name; +} +---- + +When persisting this entity, before the SQL `INSERT` Spring Data will issue an additional `SELECT` +statement to fetch the next value from the sequence. For instance, for PostgreSQL the query, issued by +Spring Data, would look like this: + +.Select for next sequence value in PostgreSQL +[source,sql] +---- +SELECT nextval('public.my_seq'); +---- + +The fetched Id would later be included in the `VALUES` list during an insert: + +.Insert statement enriched with Id value +[source,sql] +---- +INSERT INTO "my_entity"("id", "name") VALUES(?, ?); +---- + +For now, the sequence support is provided for almost every dialect supported by Spring Data R2DBC. +The only exception is MySQL, since MySQL does not have sequences as such. +