Support for ID generation by sequence.

Ids can be annotated with @Sequence to specify a sequence to pull id values from.

Closes #1923
Original pull request #1955

Signed-off-by: mipo256 <mikhailpolivakha@gmail.com>

Some accidential changes removed.
Signed-off-by: schauder <jens.schauder@broadcom.com>
This commit is contained in:
Mikhail2048
2024-10-26 13:04:22 +03:00
committed by Jens Schauder
parent b51c77b50a
commit d1c996008c
33 changed files with 716 additions and 39 deletions

View File

@@ -118,8 +118,13 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy {
Assert.notEmpty(insertSubjects, "Batch insert must contain at least one InsertSubject");
SqlIdentifierParameterSource[] sqlParameterSources = insertSubjects.stream()
.map(insertSubject -> sqlParametersFactory.forInsert(insertSubject.getInstance(), domainType,
insertSubject.getIdentifier(), idValueSource))
.map(insertSubject -> sqlParametersFactory.forInsert( //
insertSubject.getInstance(), //
domainType, //
insertSubject.getIdentifier(), //
idValueSource //
) //
) //
.toArray(SqlIdentifierParameterSource[]::new);
String insertSql = sql(domainType).getInsert(sqlParameterSources[0].getIdentifiers());
@@ -280,7 +285,8 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy {
@Override
public <T> Stream<T> streamAll(Class<T> domainType) {
return operations.queryForStream(sql(domainType).getFindAll(), new MapSqlParameterSource(), getEntityRowMapper(domainType));
return operations.queryForStream(sql(domainType).getFindAll(), new MapSqlParameterSource(),
getEntityRowMapper(domainType));
}
@Override
@@ -364,7 +370,8 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy {
@Override
public <T> Stream<T> streamAll(Class<T> domainType, Sort sort) {
return operations.queryForStream(sql(domainType).getFindAll(sort), new MapSqlParameterSource(), getEntityRowMapper(domainType));
return operations.queryForStream(sql(domainType).getFindAll(sort), new MapSqlParameterSource(),
getEntityRowMapper(domainType));
}
@Override
@@ -479,5 +486,4 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy {
return baseProperty.getOwner().getType();
}
}

View File

@@ -0,0 +1,71 @@
package org.springframework.data.jdbc.core.mapping;
import java.util.Map;
import java.util.Optional;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration;
import org.springframework.data.mapping.PersistentPropertyAccessor;
import org.springframework.data.relational.core.conversion.MutableAggregateChange;
import org.springframework.data.relational.core.dialect.Dialect;
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
import org.springframework.data.relational.core.mapping.event.BeforeSaveCallback;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations;
import org.springframework.util.Assert;
/**
* Callback for generating ID via the database sequence. By default, it is registered as a
* bean in {@link AbstractJdbcConfiguration}
*
* @author Mikhail Polivakha
*/
public class IdGeneratingBeforeSaveCallback implements BeforeSaveCallback<Object> {
private static final Log LOG = LogFactory.getLog(IdGeneratingBeforeSaveCallback.class);
private final RelationalMappingContext relationalMappingContext;
private final Dialect dialect;
private final NamedParameterJdbcOperations operations;
public IdGeneratingBeforeSaveCallback(
RelationalMappingContext relationalMappingContext,
Dialect dialect,
NamedParameterJdbcOperations namedParameterJdbcOperations
) {
this.relationalMappingContext = relationalMappingContext;
this.dialect = dialect;
this.operations = namedParameterJdbcOperations;
}
@Override
public Object onBeforeSave(Object aggregate, MutableAggregateChange<Object> aggregateChange) {
Assert.notNull(aggregate, "The aggregate cannot be null at this point");
RelationalPersistentEntity<?> persistentEntity = relationalMappingContext.getPersistentEntity(aggregate.getClass());
Optional<String> idTargetSequence = persistentEntity.getIdTargetSequence();
if (dialect.getIdGeneration().sequencesSupported()) {
if (persistentEntity.getIdProperty() != null) {
idTargetSequence
.map(s -> dialect.getIdGeneration().nextValueFromSequenceSelect(s))
.ifPresent(sql -> {
Long idValue = operations.queryForObject(sql, Map.of(), (rs, rowNum) -> rs.getLong(1));
PersistentPropertyAccessor<Object> propertyAccessor = persistentEntity.getPropertyAccessor(aggregate);
propertyAccessor.setProperty(persistentEntity.getRequiredIdProperty(), idValue);
});
}
} else {
if (idTargetSequence.isPresent()) {
LOG.warn("""
It seems you're trying to insert an aggregate of type '%s' annotated with @TargetSequence, but the problem is RDBMS you're
working with does not support sequences as such. Falling back to identity columns
"""
.formatted(aggregate.getClass().getName())
);
}
}
return aggregate;
}
}

View File

@@ -38,6 +38,7 @@ import org.springframework.data.jdbc.core.JdbcAggregateOperations;
import org.springframework.data.jdbc.core.JdbcAggregateTemplate;
import org.springframework.data.jdbc.core.convert.*;
import org.springframework.data.jdbc.core.dialect.JdbcDialect;
import org.springframework.data.jdbc.core.mapping.IdGeneratingBeforeSaveCallback;
import org.springframework.data.jdbc.core.mapping.JdbcMappingContext;
import org.springframework.data.jdbc.core.mapping.JdbcSimpleTypes;
import org.springframework.data.mapping.model.SimpleTypeHolder;
@@ -119,6 +120,22 @@ public class AbstractJdbcConfiguration implements ApplicationContextAware {
return mappingContext;
}
/**
* Creates a {@link IdGeneratingBeforeSaveCallback} bean using the configured
* {@link #jdbcMappingContext(Optional, JdbcCustomConversions, RelationalManagedTypes)} and
* {@link #jdbcDialect(NamedParameterJdbcOperations)}.
*
* @return must not be {@literal null}.
*/
@Bean
public IdGeneratingBeforeSaveCallback idGeneratingBeforeSaveCallback(
JdbcMappingContext mappingContext,
NamedParameterJdbcOperations operations,
Dialect dialect
) {
return new IdGeneratingBeforeSaveCallback(mappingContext, dialect, operations);
}
/**
* Creates a {@link RelationalConverter} using the configured
* {@link #jdbcMappingContext(Optional, JdbcCustomConversions, RelationalManagedTypes)}.

View File

@@ -33,7 +33,6 @@ import org.springframework.data.convert.ReadingConverter;
import org.springframework.data.convert.WritingConverter;
import org.springframework.data.jdbc.core.mapping.JdbcMappingContext;
import org.springframework.data.relational.core.conversion.IdValueSource;
import org.springframework.data.relational.core.dialect.AnsiDialect;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
import org.springframework.data.relational.core.sql.SqlIdentifier;
@@ -49,7 +48,6 @@ class SqlParametersFactoryTest {
RelationalMappingContext context = new JdbcMappingContext();
RelationResolver relationResolver = mock(RelationResolver.class);
MappingJdbcConverter converter = new MappingJdbcConverter(context, relationResolver);
AnsiDialect dialect = AnsiDialect.INSTANCE;
SqlParametersFactory sqlParametersFactory = new SqlParametersFactory(context, converter);
@Test // DATAJDBC-412

View File

@@ -0,0 +1,121 @@
package org.springframework.data.jdbc.core.mapping;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.conversion.MutableAggregateChange;
import org.springframework.data.relational.core.dialect.MySqlDialect;
import org.springframework.data.relational.core.dialect.PostgresDialect;
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
import org.springframework.data.relational.core.mapping.Table;
import org.springframework.data.relational.core.mapping.TargetSequence;
import org.springframework.data.relational.core.sql.IdentifierProcessing;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations;
/**
* Unit tests for {@link IdGeneratingBeforeSaveCallback}
*
* @author Mikhail Polivakha
*/
class IdGeneratingBeforeSaveCallbackTest {
@Test
void test_mySqlDialect_sequenceGenerationIsNotSupported() {
// given
RelationalMappingContext relationalMappingContext = new RelationalMappingContext();
MySqlDialect mySqlDialect = new MySqlDialect(IdentifierProcessing.NONE);
NamedParameterJdbcOperations operations = mock(NamedParameterJdbcOperations.class);
// and
IdGeneratingBeforeSaveCallback subject = new IdGeneratingBeforeSaveCallback(relationalMappingContext, mySqlDialect, operations);
NoSequenceEntity entity = new NoSequenceEntity();
// when
Object processed = subject.onBeforeSave(entity, MutableAggregateChange.forSave(entity));
// then
Assertions.assertThat(processed).isSameAs(entity);
Assertions.assertThat(processed).usingRecursiveComparison().isEqualTo(entity);
}
@Test
void test_EntityIsNotMarkedWithTargetSequence() {
// given
RelationalMappingContext relationalMappingContext = new RelationalMappingContext();
PostgresDialect mySqlDialect = PostgresDialect.INSTANCE;
NamedParameterJdbcOperations operations = mock(NamedParameterJdbcOperations.class);
// and
IdGeneratingBeforeSaveCallback subject = new IdGeneratingBeforeSaveCallback(relationalMappingContext, mySqlDialect, operations);
NoSequenceEntity entity = new NoSequenceEntity();
// when
Object processed = subject.onBeforeSave(entity, MutableAggregateChange.forSave(entity));
// then
Assertions.assertThat(processed).isSameAs(entity);
Assertions.assertThat(processed).usingRecursiveComparison().isEqualTo(entity);
}
@Test
void test_EntityIdIsPopulatedFromSequence() {
// given
RelationalMappingContext relationalMappingContext = new RelationalMappingContext();
relationalMappingContext.getRequiredPersistentEntity(EntityWithSequence.class);
PostgresDialect mySqlDialect = PostgresDialect.INSTANCE;
NamedParameterJdbcOperations operations = mock(NamedParameterJdbcOperations.class);
// and
long generatedId = 112L;
when(operations.queryForObject(anyString(), anyMap(), any(RowMapper.class))).thenReturn(generatedId);
// and
IdGeneratingBeforeSaveCallback subject = new IdGeneratingBeforeSaveCallback(relationalMappingContext, mySqlDialect, operations);
EntityWithSequence entity = new EntityWithSequence();
// when
Object processed = subject.onBeforeSave(entity, MutableAggregateChange.forSave(entity));
// then
Assertions.assertThat(processed).isSameAs(entity);
Assertions
.assertThat(processed)
.usingRecursiveComparison()
.ignoringFields("id")
.isEqualTo(entity);
Assertions.assertThat(entity.getId()).isEqualTo(generatedId);
}
@Table
static class NoSequenceEntity {
@Id
private Long id;
private Long name;
}
@Table
static class EntityWithSequence {
@Id
@TargetSequence(value = "id_seq", schema = "public")
private Long id;
private Long name;
public Long getId() {
return id;
}
}
}

View File

@@ -42,7 +42,6 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.PropertiesFactoryBean;
import org.springframework.context.ApplicationListener;
@@ -52,16 +51,7 @@ import org.springframework.context.annotation.Import;
import org.springframework.core.io.ClassPathResource;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.data.annotation.Id;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.ExampleMatcher;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Window;
import org.springframework.data.domain.*;
import org.springframework.data.jdbc.core.mapping.AggregateReference;
import org.springframework.data.jdbc.repository.query.Modifying;
import org.springframework.data.jdbc.repository.query.Query;
@@ -75,6 +65,7 @@ import org.springframework.data.jdbc.testing.TestDatabaseFeatures;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.MappedCollection;
import org.springframework.data.relational.core.mapping.Table;
import org.springframework.data.relational.core.mapping.TargetSequence;
import org.springframework.data.relational.core.mapping.event.AbstractRelationalEvent;
import org.springframework.data.relational.core.mapping.event.AfterConvertEvent;
import org.springframework.data.relational.core.sql.LockMode;
@@ -115,8 +106,8 @@ public class JdbcRepositoryIntegrationTests {
@Autowired DummyEntityRepository repository;
@Autowired MyEventListener eventListener;
@Autowired RootRepository rootRepository;
@Autowired WithDelimitedColumnRepository withDelimitedColumnRepository;
@Autowired EntityWithSequenceRepository entityWithSequenceRepository;
@BeforeEach
public void before() {
@@ -135,6 +126,28 @@ public class JdbcRepositoryIntegrationTests {
"id_Prop = " + entity.getIdProp())).isEqualTo(1);
}
@Test
@EnabledOnFeature(value = TestDatabaseFeatures.Feature.SUPPORTS_SEQUENCES)
public void saveEntityWithTargetSequenceSpecified() {
EntityWithSequence first = entityWithSequenceRepository.save(new EntityWithSequence("first"));
EntityWithSequence second = entityWithSequenceRepository.save(new EntityWithSequence("second"));
assertThat(first.getId()).isNotNull();
assertThat(second.getId()).isNotNull();
assertThat(first.getId()).isLessThan(second.getId());
assertThat(first.getName()).isEqualTo("first");
assertThat(second.getName()).isEqualTo("second");
}
@Test
@EnabledOnFeature(value = TestDatabaseFeatures.Feature.SUPPORTS_SEQUENCES)
public void batchInsertEntityWithTargetSequenceSpecified() {
Iterable<EntityWithSequence> results = entityWithSequenceRepository
.saveAll(List.of(new EntityWithSequence("first"), new EntityWithSequence("second")));
assertThat(results).hasSize(2).extracting(EntityWithSequence::getId).containsExactly(1L, 2L);
}
@Test // DATAJDBC-95
public void saveAndLoadAnEntity() {
@@ -1515,6 +1528,8 @@ public class JdbcRepositoryIntegrationTests {
interface WithDelimitedColumnRepository extends CrudRepository<WithDelimitedColumn, Long> {}
interface EntityWithSequenceRepository extends CrudRepository<EntityWithSequence, Long> {}
@Configuration
@Import(TestConfiguration.class)
static class Config {
@@ -1536,6 +1551,11 @@ public class JdbcRepositoryIntegrationTests {
return factory.getRepository(WithDelimitedColumnRepository.class);
}
@Bean
EntityWithSequenceRepository entityWithSequenceRepository() {
return factory.getRepository(EntityWithSequenceRepository.class);
}
@Bean
NamedQueries namedQueries() throws IOException {
@@ -1839,6 +1859,31 @@ public class JdbcRepositoryIntegrationTests {
return entity;
}
static class EntityWithSequence {
@Id
@TargetSequence(sequence = "entity_sequence") private Long id;
private String name;
public EntityWithSequence(Long id, String name) {
this.id = id;
this.name = name;
}
public EntityWithSequence(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
static class DummyEntity {
String name;

View File

@@ -0,0 +1,27 @@
package org.springframework.data.jdbc.testing;
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;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.test.context.junit.jupiter.EnabledIf;
/**
* Annotation that allows to disable a particular test to be executed on a particular database
*
* @author Mikhail Polivakha
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ExtendWith(DisabledOnDatabaseExecutionCondition.class)
public @interface DisabledOnDatabase {
/**
* The database on which the test is not supposed to run on
*/
DatabaseType database();
}

View File

@@ -0,0 +1,36 @@
package org.springframework.data.jdbc.testing;
import org.apache.commons.lang3.ArrayUtils;
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
import org.junit.jupiter.api.extension.ExecutionCondition;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.test.context.junit.jupiter.SpringExtension;
/**
* {@link ExecutionCondition} for the {@link DisabledOnDatabase} annotation
*
* @author Mikhail Polivakha
*/
public class DisabledOnDatabaseExecutionCondition implements ExecutionCondition {
@Override
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
ApplicationContext applicationContext = SpringExtension.getApplicationContext(context);
MergedAnnotation<DisabledOnDatabase> disabledOnDatabaseMergedAnnotation = MergedAnnotations
.from(context.getRequiredTestMethod(), MergedAnnotations.SearchStrategy.DIRECT)
.get(DisabledOnDatabase.class);
DatabaseType database = disabledOnDatabaseMergedAnnotation.getEnum("database", DatabaseType.class);
if (ArrayUtils.contains(applicationContext.getEnvironment().getActiveProfiles(), database.getProfile())) {
return ConditionEvaluationResult.disabled(
"The test method '%s' is disabled for '%s' because of the @DisabledOnDatabase annotation".formatted(context.getRequiredTestMethod().getName(), database)
);
}
return ConditionEvaluationResult.enabled("The test method '%s' is enabled".formatted(context.getRequiredTestMethod()));
}
}

View File

@@ -36,11 +36,15 @@ import org.springframework.context.annotation.Profile;
import org.springframework.data.convert.CustomConversions;
import org.springframework.data.jdbc.core.convert.*;
import org.springframework.data.jdbc.core.dialect.JdbcDialect;
import org.springframework.data.jdbc.core.mapping.IdGeneratingBeforeSaveCallback;
import org.springframework.data.jdbc.core.mapping.JdbcMappingContext;
import org.springframework.data.jdbc.core.mapping.JdbcSimpleTypes;
import org.springframework.data.jdbc.repository.config.DialectResolver;
import org.springframework.data.jdbc.repository.support.JdbcRepositoryFactory;
import org.springframework.data.mapping.callback.EntityCallback;
import org.springframework.data.mapping.callback.EntityCallbacks;
import org.springframework.data.mapping.model.SimpleTypeHolder;
import org.springframework.data.relational.RelationalManagedTypes;
import org.springframework.data.relational.core.dialect.Dialect;
import org.springframework.data.relational.core.mapping.DefaultNamingStrategy;
import org.springframework.data.relational.core.mapping.NamingStrategy;
@@ -81,10 +85,16 @@ public class TestConfiguration {
JdbcRepositoryFactory jdbcRepositoryFactory(
@Qualifier("defaultDataAccessStrategy") DataAccessStrategy dataAccessStrategy, RelationalMappingContext context,
Dialect dialect, JdbcConverter converter, Optional<List<NamedQueries>> namedQueries,
List<EntityCallback<?>> callbacks,
List<EvaluationContextExtension> evaulationContextExtensions) {
JdbcRepositoryFactory factory = new JdbcRepositoryFactory(dataAccessStrategy, context, converter, dialect,
publisher, namedParameterJdbcTemplate());
factory.setEntityCallbacks(
EntityCallbacks.create(callbacks.toArray(new EntityCallback[0]))
);
namedQueries.map(it -> it.iterator().next()).ifPresent(factory::setNamedQueries);
factory.setEvaluationContextProvider(
@@ -164,6 +174,21 @@ public class TestConfiguration {
new DefaultJdbcTypeFactory(template.getJdbcOperations(), arrayColumns));
}
/**
* Creates a {@link IdGeneratingBeforeSaveCallback} bean using the configured
* {@link #jdbcDialect(NamedParameterJdbcOperations)}.
*
* @return must not be {@literal null}.
*/
@Bean
public IdGeneratingBeforeSaveCallback idGeneratingBeforeSaveCallback(
JdbcMappingContext mappingContext,
NamedParameterJdbcOperations operations,
Dialect dialect
) {
return new IdGeneratingBeforeSaveCallback(mappingContext, dialect, operations);
}
@Bean
Dialect jdbcDialect(NamedParameterJdbcOperations operations) {
return DialectResolver.getDialect(operations.getJdbcOperations());

View File

@@ -30,6 +30,7 @@ import org.springframework.jdbc.core.JdbcOperations;
*
* @author Jens Schauder
* @author Chirag Tailor
* @author Mikhail Polivakha
*/
public class TestDatabaseFeatures {
@@ -79,6 +80,10 @@ public class TestDatabaseFeatures {
assumeThat(database).isNotIn(Database.MySql, Database.MariaDb, Database.SqlServer);
}
private void supportsSequences() {
assumeThat(database).isNotIn(Database.MySql);
}
private void supportsWhereInTuples() {
assumeThat(database).isIn(Database.MySql, Database.PostgreSql);
}
@@ -117,6 +122,7 @@ public class TestDatabaseFeatures {
SUPPORTS_NULL_PRECEDENCE(TestDatabaseFeatures::supportsNullPrecedence),
IS_POSTGRES(f -> f.databaseIs(Database.PostgreSql)), //
WHERE_IN_TUPLE(TestDatabaseFeatures::supportsWhereInTuples), //
SUPPORTS_SEQUENCES(TestDatabaseFeatures::supportsSequences), //
IS_HSQL(f -> f.databaseIs(Database.Hsql));
private final Consumer<TestDatabaseFeatures> featureMethod;

View File

@@ -3,6 +3,8 @@ DROP TABLE ROOT;
DROP TABLE INTERMEDIATE;
DROP TABLE LEAF;
DROP TABLE WITH_DELIMITED_COLUMN;
DROP TABLE ENTITY_WITH_SEQUENCE;
DROP SEQUENCE ENTITY_SEQUENCE;
CREATE TABLE dummy_entity
(
@@ -45,4 +47,12 @@ CREATE TABLE WITH_DELIMITED_COLUMN
ID BIGINT GENERATED BY DEFAULT AS IDENTITY ( START WITH 1 ) PRIMARY KEY,
"ORG.XTUNIT.IDENTIFIER" VARCHAR(100),
STYPE VARCHAR(100)
);
);
CREATE TABLE ENTITY_WITH_SEQUENCE
(
ID BIGINT,
NAME VARCHAR(100)
);
CREATE SEQUENCE ENTITY_SEQUENCE START WITH 1 INCREMENT BY 1 NO MAXVALUE;

View File

@@ -39,4 +39,12 @@ CREATE TABLE WITH_DELIMITED_COLUMN
ID BIGINT GENERATED BY DEFAULT AS IDENTITY ( START WITH 1 ) PRIMARY KEY,
"ORG.XTUNIT.IDENTIFIER" VARCHAR(100),
STYPE VARCHAR(100)
);
);
CREATE TABLE ENTITY_WITH_SEQUENCE
(
ID BIGINT,
NAME VARCHAR(100)
);
CREATE SEQUENCE ENTITY_SEQUENCE START WITH 1 INCREMENT BY 1 NO MAXVALUE;

View File

@@ -39,4 +39,12 @@ CREATE TABLE WITH_DELIMITED_COLUMN
ID BIGINT GENERATED BY DEFAULT AS IDENTITY ( START WITH 1 ) PRIMARY KEY,
"ORG.XTUNIT.IDENTIFIER" VARCHAR(100),
STYPE VARCHAR(100)
);
);
CREATE TABLE ENTITY_WITH_SEQUENCE
(
ID BIGINT,
NAME VARCHAR(100)
);
CREATE SEQUENCE ENTITY_SEQUENCE START WITH 1 INCREMENT BY 1 NO MAXVALUE;

View File

@@ -39,4 +39,12 @@ CREATE TABLE WITH_DELIMITED_COLUMN
ID BIGINT AUTO_INCREMENT PRIMARY KEY,
`ORG.XTUNIT.IDENTIFIER` VARCHAR(100),
STYPE VARCHAR(100)
);
);
CREATE TABLE ENTITY_WITH_SEQUENCE
(
ID BIGINT,
NAME VARCHAR(100)
);
CREATE SEQUENCE ENTITY_SEQUENCE START WITH 1 INCREMENT BY 1 NO MAXVALUE;

View File

@@ -3,6 +3,8 @@ DROP TABLE IF EXISTS ROOT;
DROP TABLE IF EXISTS INTERMEDIATE;
DROP TABLE IF EXISTS LEAF;
DROP TABLE IF EXISTS WITH_DELIMITED_COLUMN;
DROP TABLE IF EXISTS ENTITY_WITH_SEQUENCE;
DROP SEQUENCE IF EXISTS ENTITY_SEQUENCE;
CREATE TABLE dummy_entity
(
@@ -45,4 +47,12 @@ CREATE TABLE WITH_DELIMITED_COLUMN
ID BIGINT IDENTITY PRIMARY KEY,
"ORG.XTUNIT.IDENTIFIER" VARCHAR(100),
STYPE VARCHAR(100)
);
);
CREATE TABLE ENTITY_WITH_SEQUENCE
(
ID BIGINT,
NAME VARCHAR(100)
);
CREATE SEQUENCE ENTITY_SEQUENCE START WITH 1 INCREMENT BY 1 NO MAXVALUE;

View File

@@ -3,6 +3,8 @@ DROP TABLE ROOT CASCADE CONSTRAINTS PURGE;
DROP TABLE INTERMEDIATE CASCADE CONSTRAINTS PURGE;
DROP TABLE LEAF CASCADE CONSTRAINTS PURGE;
DROP TABLE WITH_DELIMITED_COLUMN CASCADE CONSTRAINTS PURGE;
DROP TABLE ENTITY_WITH_SEQUENCE CASCADE CONSTRAINTS PURGE;
DROP SEQUENCE ENTITY_SEQUENCE;
CREATE TABLE DUMMY_ENTITY
(
@@ -46,3 +48,11 @@ CREATE TABLE WITH_DELIMITED_COLUMN
"ORG.XTUNIT.IDENTIFIER" VARCHAR(100),
STYPE VARCHAR(100)
)
CREATE TABLE ENTITY_WITH_SEQUENCE
(
ID BIGINT,
NAME VARCHAR(100)
);
CREATE SEQUENCE ENTITY_SEQUENCE START WITH 1 INCREMENT BY 1 NO MAXVALUE;

View File

@@ -3,6 +3,8 @@ DROP TABLE ROOT;
DROP TABLE INTERMEDIATE;
DROP TABLE LEAF;
DROP TABLE WITH_DELIMITED_COLUMN;
DROP TABLE ENTITY_WITH_SEQUENCE;
DROP SEQUENCE ENTITY_SEQUENCE;
CREATE TABLE dummy_entity
(
@@ -45,4 +47,12 @@ CREATE TABLE "WITH_DELIMITED_COLUMN"
ID SERIAL PRIMARY KEY,
"ORG.XTUNIT.IDENTIFIER" VARCHAR(100),
"STYPE" VARCHAR(100)
);
);
CREATE TABLE ENTITY_WITH_SEQUENCE
(
ID BIGINT,
NAME VARCHAR(100)
);
CREATE SEQUENCE ENTITY_SEQUENCE START WITH 1 INCREMENT BY 1 NO MAXVALUE;

View File

@@ -15,6 +15,8 @@
*/
package org.springframework.data.relational.core.conversion;
import java.util.Optional;
import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
@@ -22,6 +24,7 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentProp
* Enumeration describing the source of a value for an id property.
*
* @author Chirag Tailor
* @author Mikhail Polivakha
* @since 2.4
*/
public enum IdValueSource {
@@ -39,7 +42,12 @@ public enum IdValueSource {
/**
* There is no id property, and therefore no id value source.
*/
NONE;
NONE,
/**
* The id should be dervied from the database sequence
*/
SEQUENCE;
/**
* Returns the appropriate {@link IdValueSource} for the instance: {@link IdValueSource#NONE} when the entity has no
@@ -48,6 +56,11 @@ public enum IdValueSource {
*/
public static <T> IdValueSource forInstance(Object instance, RelationalPersistentEntity<T> persistentEntity) {
Optional<String> idTargetSequence = persistentEntity.getIdTargetSequence();
if (idTargetSequence.isPresent()) {
return IdValueSource.SEQUENCE;
}
Object idValue = persistentEntity.getIdentifierAccessor(instance).getIdentifier();
RelationalPersistentProperty idProperty = persistentEntity.getIdProperty();
if (idProperty == null) {

View File

@@ -25,6 +25,7 @@ import org.springframework.data.relational.core.sql.LockOptions;
* An SQL dialect for DB2.
*
* @author Jens Schauder
* @author Mikhail Polivakha
* @since 2.0
*/
public class Db2Dialect extends AbstractDialect {
@@ -39,6 +40,16 @@ public class Db2Dialect extends AbstractDialect {
public boolean supportedForBatchOperations() {
return false;
}
/**
* This workaround (non-ANSI SQL way of querying sequence) exists for the same reasons it exists for {@link HsqlDbDialect}
*
* @see HsqlDbDialect#getIdGeneration()#nextValueFromSequenceSelect(String)
*/
@Override
public String nextValueFromSequenceSelect(String sequenceName) {
return "SELECT NEXT VALUE FOR %s FROM SYSCAT.SEQUENCES LIMIT 1".formatted(sequenceName);
}
};
protected Db2Dialect() {}

View File

@@ -146,5 +146,5 @@ public interface Dialect {
default boolean supportsSingleQueryLoading() {
return true;
};
}
}

View File

@@ -31,6 +31,7 @@ import org.springframework.util.ClassUtils;
* @author Myeonghyeon Lee
* @author Christph Strobl
* @author Jens Schauder
* @author Mikhail Polivakha
* @since 2.0
*/
public class H2Dialect extends AbstractDialect {
@@ -113,4 +114,15 @@ public class H2Dialect extends AbstractDialect {
public boolean supportsSingleQueryLoading() {
return false;
}
@Override
public IdGeneration getIdGeneration() {
return new IdGeneration() {
@Override
public String nextValueFromSequenceSelect(String sequenceName) {
return "SELECT NEXT VALUE FOR %s".formatted(sequenceName);
}
};
}
}

View File

@@ -20,6 +20,7 @@ package org.springframework.data.relational.core.dialect;
*
* @author Jens Schauder
* @author Myeonghyeon Lee
* @author Mikhail Polivakha
*/
public class HsqlDbDialect extends AbstractDialect {
@@ -64,4 +65,22 @@ public class HsqlDbDialect extends AbstractDialect {
return Position.AFTER_ORDER_BY;
}
};
@Override
public IdGeneration getIdGeneration() {
return new IdGeneration() {
/**
* One may think that this is an over-complication, but it is actually not.
* There is no a direct way to query the next value for the sequence, only to use it as an expression
* inside other queries (SELECT/INSERT). Therefore, such a workaround is required
*
* @see <a href="https://github.com/jOOQ/jOOQ/issues/3762">The way JOOQ solves this problem</a>
*/
@Override
public String nextValueFromSequenceSelect(String sequenceName) {
return "SELECT NEXT VALUE FOR %s AS msq FROM INFORMATION_SCHEMA.SEQUENCES LIMIT 1".formatted(sequenceName);
}
};
}
}

View File

@@ -21,10 +21,13 @@ import java.sql.PreparedStatement;
import org.springframework.data.relational.core.sql.SqlIdentifier;
/**
* Describes how obtaining generated ids after an insert works for a given JDBC driver.
* Encapsulates various properties that are related to ID generation process and specific to
* given {@link Dialect}
*
* @author Jens Schauder
* @author Chirag Tailor
* @author Mikhail Polivakha
*
* @since 2.1
*/
public interface IdGeneration {
@@ -59,6 +62,13 @@ public interface IdGeneration {
return id.getReference();
}
/**
* @return {@literal true} in case the sequences are supported by the underlying database, {@literal false} otherwise
*/
default boolean sequencesSupported() {
return true;
}
/**
* Does the driver support id generation for batch operations.
* <p>
@@ -71,4 +81,16 @@ public interface IdGeneration {
default boolean supportedForBatchOperations() {
return true;
}
/**
* The SQL statement that allows retrieving the next value from the passed sequence
*
* @param sequenceName the sequence name to get the enxt value for
* @return SQL string
*/
default String nextValueFromSequenceSelect(String sequenceName) {
throw new UnsupportedOperationException(
"Currently, there is no support for sequence generation for %s dialect. If you need it, please, submit a ticket".formatted(this.getClass().getSimpleName())
);
}
}

View File

@@ -18,12 +18,14 @@ package org.springframework.data.relational.core.dialect;
import java.util.Arrays;
import java.util.Collection;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.sql.IdentifierProcessing;
/**
* A SQL dialect for MariaDb.
*
* @author Jens Schauder
* @author Mikhail Polivakha
* @since 2.3
*/
public class MariaDbDialect extends MySqlDialect {
@@ -38,4 +40,15 @@ public class MariaDbDialect extends MySqlDialect {
TimestampAtUtcToOffsetDateTimeConverter.INSTANCE,
NumberToBooleanConverter.INSTANCE);
}
@Override
public IdGeneration getIdGeneration() {
return new IdGeneration() {
@Override
public String nextValueFromSequenceSelect(String sequenceName) {
return "SELECT NEXTVAL(%s)".formatted(sequenceName);
}
};
}
}

View File

@@ -141,4 +141,15 @@ public class MySqlDialect extends AbstractDialect {
public OrderByNullPrecedence orderByNullHandling() {
return OrderByNullPrecedence.NONE;
}
@Override
public IdGeneration getIdGeneration() {
return new IdGeneration() {
@Override
public boolean sequencesSupported() {
return false;
}
};
}
}

View File

@@ -27,6 +27,7 @@ import static java.util.Arrays.*;
* An SQL dialect for Oracle.
*
* @author Jens Schauder
* @author Mikahil Polivakha
* @since 2.1
*/
public class OracleDialect extends AnsiDialect {
@@ -37,6 +38,7 @@ public class OracleDialect extends AnsiDialect {
public static final OracleDialect INSTANCE = new OracleDialect();
private static final IdGeneration ID_GENERATION = new IdGeneration() {
@Override
public boolean driverRequiresKeyColumnNames() {
return true;
@@ -46,6 +48,11 @@ public class OracleDialect extends AnsiDialect {
public String getKeyColumnName(SqlIdentifier id) {
return id.toSql(INSTANCE.getIdentifierProcessing());
}
@Override
public String nextValueFromSequenceSelect(String sequenceName) {
return "SELECT %s.nextval FROM DUAL".formatted(sequenceName);
}
};
protected OracleDialect() {}

View File

@@ -42,6 +42,7 @@ import org.springframework.data.relational.core.sql.TableLike;
* @author Myeonghyeon Lee
* @author Jens Schauder
* @author Nikita Konev
* @author Mikhail Polivakha
* @since 1.1
*/
public class PostgresDialect extends AbstractDialect {
@@ -130,17 +131,10 @@ public class PostgresDialect extends AbstractDialect {
// without schema
String tableName = last.toSql(this.identifierProcessing);
switch (lockOptions.getLockMode()) {
case PESSIMISTIC_WRITE:
return "FOR UPDATE OF " + tableName;
case PESSIMISTIC_READ:
return "FOR SHARE OF " + tableName;
default:
return "";
}
return switch (lockOptions.getLockMode()) {
case PESSIMISTIC_WRITE -> "FOR UPDATE OF " + tableName;
case PESSIMISTIC_READ -> "FOR SHARE OF " + tableName;
};
}
@Override
@@ -163,4 +157,15 @@ public class PostgresDialect extends AbstractDialect {
public SimpleFunction getExistsFunction() {
return Functions.least(Functions.count(SQL.literalOf(1)), SQL.literalOf(1));
}
@Override
public IdGeneration getIdGeneration() {
return new IdGeneration() {
@Override
public String nextValueFromSequenceSelect(String sequenceName) {
return "SELECT nextval('%s')".formatted(sequenceName);
}
};
}
}

View File

@@ -42,6 +42,11 @@ public class SqlServerDialect extends AbstractDialect {
public boolean supportedForBatchOperations() {
return false;
}
@Override
public String nextValueFromSequenceSelect(String sequenceName) {
return "SELECT NEXT VALUE FOR %s".formatted(sequenceName);
}
};
private static final IdentifierProcessing IDENTIFIER_PROCESSING = IdentifierProcessing

View File

@@ -17,6 +17,7 @@ package org.springframework.data.relational.core.mapping;
import java.util.Optional;
import org.jetbrains.annotations.NotNull;
import org.springframework.data.mapping.model.BasicPersistentEntity;
import org.springframework.data.relational.core.sql.SqlIdentifier;
import org.springframework.data.util.Lazy;
@@ -46,6 +47,8 @@ class BasicRelationalPersistentEntity<T> extends BasicPersistentEntity<T, Relati
private final Lazy<SqlIdentifier> tableName;
private final @Nullable Expression tableNameExpression;
private final Lazy<String> idTargetSequenceName;
private final Lazy<Optional<SqlIdentifier>> schemaName;
private final @Nullable Expression schemaNameExpression;
private final ExpressionEvaluator expressionEvaluator;
@@ -87,6 +90,8 @@ class BasicRelationalPersistentEntity<T> extends BasicPersistentEntity<T, Relati
this.schemaName = defaultSchema;
this.schemaNameExpression = null;
}
this.idTargetSequenceName = Lazy.of(this::determineTargetSequenceName);
}
/**
@@ -159,8 +164,43 @@ class BasicRelationalPersistentEntity<T> extends BasicPersistentEntity<T, Relati
return getRequiredIdProperty().getColumnName();
}
@Override
public Optional<String> getIdTargetSequence() {
return idTargetSequenceName.getOptional();
}
@Override
public String toString() {
return String.format("BasicRelationalPersistentEntity<%s>", getType());
}
private @Nullable String determineTargetSequenceName() {
RelationalPersistentProperty idProperty = getIdProperty();
if (idProperty != null && idProperty.isAnnotationPresent(TargetSequence.class)) {
TargetSequence requiredAnnotation = idProperty.getRequiredAnnotation(TargetSequence.class);
if (!StringUtils.hasText(requiredAnnotation.sequence()) && !StringUtils.hasText(requiredAnnotation.value())) {
throw new IllegalStateException("""
For the persistent entity '%s' the @TargetSequence annotation was specified for the @Id, however, neither
the value() nor the sequence() attributes are specified
"""
);
} else {
String sequenceFullyQualifiedName = getSequenceName(requiredAnnotation);
if (StringUtils.hasText(requiredAnnotation.schema())) {
return String.join(".", requiredAnnotation.schema(), sequenceFullyQualifiedName);
}
return sequenceFullyQualifiedName;
}
} else {
return null;
}
}
@NotNull
private static String getSequenceName(TargetSequence requiredAnnotation) {
return Optional.of(requiredAnnotation.sequence())
.filter(s -> !s.isBlank())
.orElse(requiredAnnotation.value());
}
}

View File

@@ -17,6 +17,7 @@ package org.springframework.data.relational.core.mapping;
import java.lang.annotation.Annotation;
import java.util.Iterator;
import java.util.Optional;
import org.springframework.core.env.Environment;
import org.springframework.data.mapping.*;
@@ -31,6 +32,7 @@ import org.springframework.lang.Nullable;
* Embedded entity extension for a {@link Embedded entity}.
*
* @author Mark Paluch
* @author Mikhail Polivakha
* @since 3.2
*/
class EmbeddedRelationalPersistentEntity<T> implements RelationalPersistentEntity<T> {
@@ -54,6 +56,11 @@ class EmbeddedRelationalPersistentEntity<T> implements RelationalPersistentEntit
throw new MappingException("Embedded entity does not have an id column");
}
@Override
public Optional<String> getIdTargetSequence() {
return Optional.empty();
}
@Override
public void addPersistentProperty(RelationalPersistentProperty property) {
throw new UnsupportedOperationException();

View File

@@ -15,6 +15,8 @@
*/
package org.springframework.data.relational.core.mapping;
import java.util.Optional;
import org.springframework.data.mapping.model.MutablePersistentEntity;
import org.springframework.data.relational.core.sql.SqlIdentifier;
@@ -25,6 +27,7 @@ import org.springframework.data.relational.core.sql.SqlIdentifier;
* @author Jens Schauder
* @author Oliver Gierke
* @author Mark Paluch
* @author Mikhail Polivakha
*/
public interface RelationalPersistentEntity<T> extends MutablePersistentEntity<T, RelationalPersistentProperty> {
@@ -52,4 +55,8 @@ public interface RelationalPersistentEntity<T> extends MutablePersistentEntity<T
*/
SqlIdentifier getIdColumn();
/**
* @return the target sequence that should be used for id generation
*/
Optional<String> getIdTargetSequence();
}

View File

@@ -0,0 +1,43 @@
package org.springframework.data.relational.core.mapping;
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;
import org.springframework.core.annotation.AliasFor;
/**
* Specify the sequence from which the value for the {@link org.springframework.data.annotation.Id}
* should be fetched
*
* @author Mikhail Polivakha
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@Documented
public @interface TargetSequence {
/**
* The name of the sequence from which the id should be fetched
*/
String value() default "";
/**
* Alias for {@link #value()}
*/
@AliasFor("value")
String sequence() default "";
/**
* Schema where the sequence reside.
* Technically, this attribute is not necessarily the schema. It just represents the location/namespace,
* where the sequence resides. For instance, in Oracle databases the schema and user are often used
* interchangeably, so {@link #schema() schema} attribute may represent an Oracle user as well.
* <p>
* The final name of the sequence to be queried for the next value will be constructed by the concatenation
* of schema and sequence : <pre>schema().sequence()</pre>
*/
String schema() default "";
}

View File

@@ -58,6 +58,34 @@ class BasicRelationalPersistentEntityUnitTests {
assertThat(entity.getTableName()).isEqualTo(quoted("dummy_sub_entity"));
}
@Test
void entityWithNotargetSequence() {
RelationalPersistentEntity<?> entity = mappingContext.getRequiredPersistentEntity(DummySubEntity.class);
assertThat(entity.getIdTargetSequence()).isEmpty();
}
@Test
void determineSequenceName() {
RelationalPersistentEntity<?> persistentEntity = mappingContext.getPersistentEntity(EntityWithSequence.class);
assertThat(persistentEntity.getIdTargetSequence()).isPresent().hasValue("my_seq");
}
@Test
void determineSequenceNameFromValue() {
RelationalPersistentEntity<?> persistentEntity = mappingContext.getPersistentEntity(EntityWithSequenceValueAlias.class);
assertThat(persistentEntity.getIdTargetSequence()).isPresent().hasValue("my_seq");
}
@Test
void determineSequenceNameWithSchemaSpecified() {
RelationalPersistentEntity<?> persistentEntity = mappingContext.getPersistentEntity(EntityWithSequenceAndSchema.class);
assertThat(persistentEntity.getIdTargetSequence()).isPresent().hasValue("public.my_seq");
}
@Test // DATAJDBC-294
void considerIdColumnName() {
@@ -201,6 +229,24 @@ class BasicRelationalPersistentEntityUnitTests {
@Column("renamedId") Long id;
}
@Table("entity_with_sequence")
static class EntityWithSequence {
@Id
@TargetSequence(sequence = "my_seq") Long id;
}
@Table("entity_with_sequence_value_alias")
static class EntityWithSequenceValueAlias {
@Id
@Column("myId") @TargetSequence(value = "my_seq") Long id;
}
@Table("entity_with_sequence_and_schema")
static class EntityWithSequenceAndSchema {
@Id
@Column("myId") @TargetSequence(sequence = "my_seq", schema = "public") Long id;
}
@Table()
static class DummyEntityWithEmptyAnnotation {
@Id