Polishing.

Extract SequenceEntityCallbackDelegate from IdGeneratingBeforeSaveCallback. Renameto IdGeneratingEntityCallback and move callback to convert package.

Align return values and associate generated sequence value with the entity. Fix test. Add ticket references to tests.

Extract documentation partials.

See #1955
Original pull request: #2028
This commit is contained in:
Mark Paluch
2025-04-24 09:42:10 +02:00
parent d6121cbfe2
commit 0aecfcee73
11 changed files with 317 additions and 211 deletions

View File

@@ -33,13 +33,13 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.CustomConversions;
import org.springframework.data.convert.CustomConversions.StoreConversions;
import org.springframework.data.r2dbc.convert.IdGeneratingEntityCallback;
import org.springframework.data.r2dbc.convert.MappingR2dbcConverter;
import org.springframework.data.r2dbc.convert.R2dbcConverter;
import org.springframework.data.r2dbc.convert.R2dbcCustomConversions;
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;
@@ -185,14 +185,16 @@ public abstract class AbstractR2dbcConfiguration implements ApplicationContextAw
}
/**
* Register a {@link IdGeneratingBeforeSaveCallback} using
* Register a {@link IdGeneratingEntityCallback} using
* {@link #r2dbcMappingContext(Optional, R2dbcCustomConversions, RelationalManagedTypes)} and
* {@link #databaseClient()}
* {@link #databaseClient()}.
*
* @since 3.5
*/
@Bean
public IdGeneratingBeforeSaveCallback idGeneratingBeforeSaveCallback(
public IdGeneratingEntityCallback idGeneratingBeforeSaveCallback(
RelationalMappingContext relationalMappingContext, DatabaseClient databaseClient) {
return new IdGeneratingBeforeSaveCallback(relationalMappingContext, getDialect(lookupConnectionFactory()),
return new IdGeneratingEntityCallback(relationalMappingContext, getDialect(lookupConnectionFactory()),
databaseClient);
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright 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.convert;
import reactor.core.publisher.Mono;
import org.springframework.data.mapping.PersistentPropertyAccessor;
import org.springframework.data.mapping.context.MappingContext;
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.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.util.Assert;
/**
* Callback for generating identifier values through a database sequence.
*
* @author Mikhail Polivakha
* @author Mark Paluch
* @since 3.5
*/
public class IdGeneratingEntityCallback implements BeforeSaveCallback<Object> {
private final MappingContext<RelationalPersistentEntity<?>, ? extends RelationalPersistentProperty> context;
private final SequenceEntityCallbackDelegate delegate;
public IdGeneratingEntityCallback(
MappingContext<RelationalPersistentEntity<?>, ? extends RelationalPersistentProperty> context,
R2dbcDialect dialect,
DatabaseClient databaseClient) {
this.context = context;
this.delegate = new SequenceEntityCallbackDelegate(dialect, databaseClient);
}
@Override
public Mono<Object> onBeforeSave(Object entity, OutboundRow row, SqlIdentifier table) {
Assert.notNull(entity, "Entity must not be null");
RelationalPersistentEntity<?> persistentEntity = context.getRequiredPersistentEntity(entity.getClass());
if (!persistentEntity.hasIdProperty()) {
return Mono.just(entity);
}
RelationalPersistentProperty property = persistentEntity.getRequiredIdProperty();
PersistentPropertyAccessor<Object> accessor = persistentEntity.getPropertyAccessor(entity);
if (!persistentEntity.isNew(entity) || delegate.hasValue(property, accessor) || !property.hasSequence()) {
return Mono.just(entity);
}
Mono<Object> idGenerator = delegate.generateSequenceValue(property, row, accessor);
return idGenerator.defaultIfEmpty(entity);
}
}

View File

@@ -0,0 +1,108 @@
/*
* Copyright 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.convert;
import reactor.core.publisher.Mono;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.data.mapping.PersistentProperty;
import org.springframework.data.mapping.PersistentPropertyAccessor;
import org.springframework.data.r2dbc.mapping.OutboundRow;
import org.springframework.data.relational.core.dialect.Dialect;
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
import org.springframework.data.relational.core.sql.SqlIdentifier;
import org.springframework.data.util.ReflectionUtils;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.r2dbc.core.Parameter;
import org.springframework.util.ClassUtils;
import org.springframework.util.NumberUtils;
/**
* Support class for generating identifier values through a database sequence.
*
* @author Mikhail Polivakha
* @author Mark Paluch
* @since 3.5
* @see org.springframework.data.relational.core.mapping.Sequence
*/
class SequenceEntityCallbackDelegate {
private static final Log LOG = LogFactory.getLog(SequenceEntityCallbackDelegate.class);
private final Dialect dialect;
private final DatabaseClient databaseClient;
public SequenceEntityCallbackDelegate(Dialect dialect, DatabaseClient databaseClient) {
this.dialect = dialect;
this.databaseClient = databaseClient;
}
@SuppressWarnings("unchecked")
protected Mono<Object> generateSequenceValue(RelationalPersistentProperty property, OutboundRow row,
PersistentPropertyAccessor<Object> accessor) {
Class<?> targetType = ClassUtils.resolvePrimitiveIfNecessary(property.getType());
return getSequenceValue(property).map(it -> {
Object sequenceValue = it;
if (sequenceValue instanceof Number && Number.class.isAssignableFrom(targetType)) {
sequenceValue = NumberUtils.convertNumberToTargetClass((Number) sequenceValue,
(Class<? extends Number>) targetType);
}
row.append(property.getColumnName(), Parameter.from(sequenceValue));
accessor.setProperty(property, sequenceValue);
return accessor.getBean();
});
}
protected boolean hasValue(PersistentProperty<?> property, PersistentPropertyAccessor<Object> propertyAccessor) {
Object identifier = propertyAccessor.getProperty(property);
if (property.getType().isPrimitive()) {
Object primitiveDefault = ReflectionUtils.getPrimitiveDefault(property.getType());
return !primitiveDefault.equals(identifier);
}
return identifier != null;
}
private Mono<Object> getSequenceValue(RelationalPersistentProperty property) {
SqlIdentifier sequence = property.getSequence();
if (sequence != null && !dialect.getIdGeneration().sequencesSupported()) {
LOG.warn("""
Entity type '%s' is marked for sequence usage but configured dialect '%s'
does not support sequences. Falling back to identity columns.
""".formatted(property.getOwner().getType(), ClassUtils.getQualifiedName(dialect.getClass())));
return Mono.empty();
}
String sql = dialect.getIdGeneration().createSequenceQuery(sequence);
return databaseClient //
.sql(sql) //
.map((r, rowMetadata) -> r.get(0)) //
.one();
}
}

View File

@@ -1,104 +0,0 @@
/*
* 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<Object> {
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<Object> 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<Object> 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()));
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2025 the original author or authors.
* Copyright 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.
@@ -14,18 +14,19 @@
* limitations under the License.
*/
package org.springframework.data.r2dbc.core.mapping;
package org.springframework.data.r2dbc.convert;
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 static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
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;
@@ -36,73 +37,70 @@ 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}.
* Unit tests for {@link IdGeneratingEntityCallback}.
*
* @author Mikhail Polivakha
* @author Mark Paluch
*/
class IdGeneratingBeforeSaveCallbackTest {
class IdGeneratingEntityCallbackTest {
R2dbcMappingContext r2dbcMappingContext = new R2dbcMappingContext();
DatabaseClient databaseClient = mock(DatabaseClient.class, RETURNS_DEEP_STUBS);
@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,
MySqlDialect dialect = MySqlDialect.INSTANCE;
IdGeneratingEntityCallback callback = new IdGeneratingEntityCallback(r2dbcMappingContext, dialect,
databaseClient);
OutboundRow row = new OutboundRow("name", Parameter.from("my_name"));
SimpleEntity entity = new SimpleEntity();
Publisher<Object> publisher = callback.onBeforeSave(entity, row, SqlIdentifier.unquoted("simple_entity"));
callback.onBeforeSave(entity, row, SqlIdentifier.unquoted("simple_entity")).as(StepVerifier::create)
.expectNext(entity).verifyComplete();
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,
PostgresDialect dialect = PostgresDialect.INSTANCE;
IdGeneratingEntityCallback callback = new IdGeneratingEntityCallback(r2dbcMappingContext, dialect,
databaseClient);
OutboundRow row = new OutboundRow("name", Parameter.from("my_name"));
SimpleEntity entity = new SimpleEntity();
Publisher<Object> publisher = callback.onBeforeSave(entity, row, SqlIdentifier.unquoted("simple_entity"));
StepVerifier.create(publisher).expectNext(entity).expectComplete().verify();
callback.onBeforeSave(entity, row, SqlIdentifier.unquoted("simple_entity")).as(StepVerifier::create)
.expectNext(entity).verifyComplete();
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,
IdGeneratingEntityCallback callback = new IdGeneratingEntityCallback(r2dbcMappingContext, dialect,
databaseClient);
OutboundRow row = new OutboundRow("name", Parameter.from("my_name"));
WithSequence entity = new WithSequence();
Publisher<Object> publisher = callback.onBeforeSave(entity, row, SqlIdentifier.unquoted("simple_entity"));
StepVerifier.create(publisher).expectNext(entity).expectComplete().verify();
callback.onBeforeSave(entity, row, SqlIdentifier.unquoted("simple_entity")).as(StepVerifier::create)
.expectNext(entity).verifyComplete();
assertThat(row).hasSize(2)
.containsEntry(SqlIdentifier.unquoted("id"), Parameter.from(generatedId));
assertThat(entity.id).isEqualTo(generatedId);
}
static class SimpleEntity {

View File

@@ -15,7 +15,13 @@
*/
package org.springframework.data.r2dbc.repository;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.*;
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 java.util.Collections;
import java.util.Map;
@@ -25,6 +31,7 @@ import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan.Filter;
@@ -45,12 +52,6 @@ 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;
/**
* Integration tests for {@link LegoSetRepository} using {@link R2dbcRepositoryFactory} against Postgres.
*
@@ -156,17 +157,19 @@ public class PostgresR2dbcRepositoryIntegrationTests extends AbstractR2dbcReposi
}).verifyComplete();
}
@Test
@Test // GH-1955
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" //
+ ");");
template.execute("DROP SEQUENCE IF EXISTS target_sequence");
template.execute("CREATE SEQUENCE target_sequence START WITH 15");
template.execute("""
CREATE TABLE with_id_from_sequence(
id BIGINT PRIMARY KEY,
name TEXT NOT NULL
);""");
WithIdFromSequence entity = new WithIdFromSequence(null, "Jordane");
withIdFromSequenceRepository.save(entity).as(StepVerifier::create).expectNextCount(1).verifyComplete();
@@ -178,26 +181,29 @@ public class PostgresR2dbcRepositoryIntegrationTests extends AbstractR2dbcReposi
}).verifyComplete();
}
@Test
@Test // GH-1955
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("DROP SEQUENCE IF EXISTS target_sequence");
template.execute("CREATE SEQUENCE target_sequence");
template.execute("""
CREATE TABLE with_id_from_sequence(
id BIGINT PRIMARY KEY,
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 -> {
withIdFromSequenceRepository.findAll().as(StepVerifier::create).consumeNextWith(actual -> {
assertThat(actual.jsonValue).isNotNull().isEqualTo(4);
assertThat(actual.jsonValue.asString()).isEqualTo("NewName");
assertThat(actual.id).isNotNull().isEqualTo(4);
assertThat(actual.name).isEqualTo("NewName");
}).verifyComplete();
}