From d500fed1e5337c41424211e344828a2dde81bf78 Mon Sep 17 00:00:00 2001 From: Mikhail2048 Date: Sat, 26 Oct 2024 13:04:22 +0300 Subject: [PATCH] Added Sequence generation support --- pom.xml | 1 - spring-data-jdbc/pom.xml | 31 +++++ .../convert/DefaultDataAccessStrategy.java | 29 +++-- .../core/convert/InsertStrategyFactory.java | 1 - .../core/convert/MappingJdbcConverter.java | 2 +- .../convert/SqlIdentifierParameterSource.java | 21 ++- .../core/convert/SqlParametersFactory.java | 38 ++++-- .../IdGeneratingBeforeSaveCallback.java | 71 ++++++++++ .../config/AbstractJdbcConfiguration.java | 17 +++ .../convert/SqlParametersFactoryTest.java | 5 +- .../IdGeneratingBeforeSaveCallbackTest.java | 121 ++++++++++++++++++ .../JdbcRepositoryIntegrationTests.java | 61 ++++++++- .../data/jdbc/testing/DisabledOnDatabase.java | 27 ++++ .../DisabledOnDatabaseExecutionCondition.java | 36 ++++++ .../data/jdbc/testing/TestConfiguration.java | 25 ++++ .../JdbcRepositoryIntegrationTests-db2.sql | 12 +- .../JdbcRepositoryIntegrationTests-h2.sql | 10 +- .../JdbcRepositoryIntegrationTests-hsql.sql | 10 +- ...JdbcRepositoryIntegrationTests-mariadb.sql | 10 +- .../JdbcRepositoryIntegrationTests-mssql.sql | 12 +- .../JdbcRepositoryIntegrationTests-mysql.sql | 2 +- .../JdbcRepositoryIntegrationTests-oracle.sql | 10 ++ ...dbcRepositoryIntegrationTests-postgres.sql | 12 +- .../SingleQuerySqlGeneratorBenchmark.java | 1 - .../core/conversion/IdValueSource.java | 15 ++- .../relational/core/dialect/Db2Dialect.java | 11 ++ .../data/relational/core/dialect/Dialect.java | 2 +- .../relational/core/dialect/H2Dialect.java | 12 ++ .../core/dialect/HsqlDbDialect.java | 19 +++ .../relational/core/dialect/IdGeneration.java | 24 +++- .../core/dialect/MariaDbDialect.java | 13 ++ .../relational/core/dialect/MySqlDialect.java | 11 ++ .../core/dialect/OracleDialect.java | 7 + .../core/dialect/PostgresDialect.java | 27 ++-- .../core/dialect/SqlServerDialect.java | 5 + .../BasicRelationalPersistentEntity.java | 40 ++++++ .../EmbeddedRelationalPersistentEntity.java | 7 + .../mapping/RelationalPersistentEntity.java | 7 + .../core/mapping/TargetSequence.java | 43 +++++++ ...icRelationalPersistentEntityUnitTests.java | 46 +++++++ 40 files changed, 803 insertions(+), 51 deletions(-) create mode 100644 spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/mapping/IdGeneratingBeforeSaveCallback.java create mode 100644 spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/mapping/IdGeneratingBeforeSaveCallbackTest.java create mode 100644 spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/DisabledOnDatabase.java create mode 100644 spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/DisabledOnDatabaseExecutionCondition.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/TargetSequence.java diff --git a/pom.xml b/pom.xml index 4ecb4c47099..3b7d9f8ae3c 100644 --- a/pom.xml +++ b/pom.xml @@ -126,7 +126,6 @@ - no-jacoco diff --git a/spring-data-jdbc/pom.xml b/spring-data-jdbc/pom.xml index 73b1d2f7a0b..503f6ea99ff 100644 --- a/spring-data-jdbc/pom.xml +++ b/spring-data-jdbc/pom.xml @@ -299,6 +299,37 @@ + + mariadb + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + mariadb-test + integration-test + + integration-test + + + + **/*IntegrationTests.java + + + + + + mariadb + + + + + + + + all-dbs diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java index 4d210d516da..3fe6e2a3135 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java @@ -19,9 +19,16 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.LongStream; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.dao.OptimisticLockingFailureException; @@ -37,6 +44,7 @@ import org.springframework.data.relational.core.query.Query; import org.springframework.data.relational.core.sql.LockMode; import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.util.Pair; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; @@ -60,6 +68,7 @@ * @author Radim Tlusty * @author Chirag Tailor * @author Diego Krupitza + * @author Mikhail Polivakha * @since 1.1 */ public class DefaultDataAccessStrategy implements DataAccessStrategy { @@ -103,12 +112,12 @@ public DefaultDataAccessStrategy(SqlGeneratorSource sqlGeneratorSource, Relation public Object insert(T instance, Class domainType, Identifier identifier, IdValueSource idValueSource) { SqlIdentifierParameterSource parameterSource = sqlParametersFactory.forInsert(instance, domainType, identifier, - idValueSource); + idValueSource); String insertSql = sql(domainType).getInsert(parameterSource.getIdentifiers()); return insertStrategyFactory.insertStrategy(idValueSource, getIdColumn(domainType)).execute(insertSql, - parameterSource); + parameterSource); } @Override @@ -116,17 +125,22 @@ public Object[] insert(List> insertSubjects, Class domai 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)) - .toArray(SqlIdentifierParameterSource[]::new); + .map(insertSubject -> sqlParametersFactory.forInsert( + insertSubject.getInstance(), + domainType, + insertSubject.getIdentifier(), + idValueSource + ) + ) + .toArray(SqlIdentifierParameterSource[]::new); String insertSql = sql(domainType).getInsert(sqlParameterSources[0].getIdentifiers()); return insertStrategyFactory.batchInsertStrategy(idValueSource, getIdColumn(domainType)).execute(insertSql, - sqlParameterSources); + sqlParameterSources); } - @Override + @Override public boolean update(S instance, Class domainType) { SqlIdentifierParameterSource parameterSource = sqlParametersFactory.forUpdate(instance, domainType); @@ -445,5 +459,4 @@ private Class getBaseType(PersistentPropertyPath, Map> ITERABLE_OF_ENTRY_TO_MAP_CONVERTER = new IterableOfEntryToMapConverter(); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlIdentifierParameterSource.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlIdentifierParameterSource.java index 2b131ac7a9b..7dd90902702 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlIdentifierParameterSource.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlIdentifierParameterSource.java @@ -22,6 +22,7 @@ import java.util.Set; import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.util.Pair; import org.springframework.jdbc.core.namedparam.AbstractSqlParameterSource; /** @@ -35,9 +36,11 @@ */ class SqlIdentifierParameterSource extends AbstractSqlParameterSource { - private final Set identifiers = new HashSet<>(); + private final Set sqlIdentifiers = new HashSet<>(); private final Map namesToValues = new HashMap<>(); + private Pair idToValue; + @Override public boolean hasValue(String paramName) { return namesToValues.containsKey(paramName); @@ -54,30 +57,34 @@ public String[] getParameterNames() { } Set getIdentifiers() { - return Collections.unmodifiableSet(identifiers); + return Collections.unmodifiableSet(sqlIdentifiers); } void addValue(SqlIdentifier name, Object value) { addValue(name, value, Integer.MIN_VALUE); } - void addValue(SqlIdentifier identifier, Object value, int sqlType) { + void addValue(SqlIdentifier sqlIdentifier, Object value, int sqlType) { - identifiers.add(identifier); - String name = BindParameterNameSanitizer.sanitize(identifier.getReference()); + sqlIdentifiers.add(sqlIdentifier); + String name = prepareSqlIdentifierName(sqlIdentifier); namesToValues.put(name, value); registerSqlType(name, sqlType); } - void addAll(SqlIdentifierParameterSource others) { + void addAll(SqlIdentifierParameterSource others) { for (SqlIdentifier identifier : others.getIdentifiers()) { - String name = BindParameterNameSanitizer.sanitize( identifier.getReference()); + String name = prepareSqlIdentifierName(identifier); addValue(identifier, others.getValue(name), others.getSqlType(name)); } } + private static String prepareSqlIdentifierName(SqlIdentifier sqlIdentifier) { + return BindParameterNameSanitizer.sanitize(sqlIdentifier.getReference()); + } + int size() { return namesToValues.size(); } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java index 94f90de501f..ab6395c61fb 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlParametersFactory.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Predicate; import org.springframework.data.jdbc.core.mapping.JdbcValue; @@ -30,6 +31,7 @@ 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.jdbc.core.namedparam.SqlParameterSource; import org.springframework.jdbc.support.JdbcUtils; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -47,9 +49,6 @@ public class SqlParametersFactory { private final RelationalMappingContext context; private final JdbcConverter converter; - /** - * @since 3.1 - */ public SqlParametersFactory(RelationalMappingContext context, JdbcConverter converter) { this.context = context; this.converter = converter; @@ -70,18 +69,39 @@ public SqlParametersFactory(RelationalMappingContext context, JdbcConverter conv SqlIdentifierParameterSource forInsert(T instance, Class domainType, Identifier identifier, IdValueSource idValueSource) { + RelationalPersistentEntity persistentEntity = getRequiredPersistentEntity(domainType); + + Object idValue = null; + + if (IdValueSource.PROVIDED.equals(idValueSource) || IdValueSource.SEQUENCE.equals(idValueSource)) { + idValue = persistentEntity.getIdentifierAccessor(instance).getRequiredIdentifier(); + } + + return forInsert(instance, domainType, identifier, idValue); + } + + /** + * Creates the parameters for a SQL insert operation. That method is different from its sibling + * {@link #forInsert(Object, Class, Identifier, IdValueSource) forInsert method} in the sense, that + * this method is invoked when we actually know the id to be added to the {@link SqlParameterSource paarameter source}. + * It might be null, meaning, that we know for sure the id should be coming from the database, or + * it could be not null, meaning, that we've got the id from some source (user provided by himself, + * or we have queried the sequence for instance) + */ + SqlIdentifierParameterSource forInsert(T instance, Class domainType, Identifier identifier, + @Nullable Object id) { + RelationalPersistentEntity persistentEntity = getRequiredPersistentEntity(domainType); SqlIdentifierParameterSource parameterSource = getParameterSource(instance, persistentEntity, "", PersistentProperty::isIdProperty); identifier.forEach((name, value, type) -> addConvertedPropertyValue(parameterSource, name, value, type)); - if (IdValueSource.PROVIDED.equals(idValueSource)) { - - RelationalPersistentProperty idProperty = persistentEntity.getRequiredIdProperty(); - Object idValue = persistentEntity.getIdentifierAccessor(instance).getRequiredIdentifier(); - addConvertedPropertyValue(parameterSource, idProperty, idValue, idProperty.getColumnName()); - } + RelationalPersistentProperty idProperty = persistentEntity.getIdProperty(); + Optional + .ofNullable(id) + .filter(it -> idProperty != null) + .ifPresent(it -> addConvertedPropertyValue(parameterSource, idProperty, it, idProperty.getColumnName())); return parameterSource; } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/mapping/IdGeneratingBeforeSaveCallback.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/mapping/IdGeneratingBeforeSaveCallback.java new file mode 100644 index 00000000000..0f8de428b74 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/mapping/IdGeneratingBeforeSaveCallback.java @@ -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 { + + 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 aggregateChange) { + Assert.notNull(aggregate, "The aggregate cannot be null at this point"); + RelationalPersistentEntity persistentEntity = relationalMappingContext.getPersistentEntity(aggregate.getClass()); + Optional 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 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; + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/config/AbstractJdbcConfiguration.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/config/AbstractJdbcConfiguration.java index bd725b98d33..fc6b7fda355 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/config/AbstractJdbcConfiguration.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/config/AbstractJdbcConfiguration.java @@ -38,6 +38,7 @@ 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 JdbcMappingContext jdbcMappingContext(Optional namingStra 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)}. diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryTest.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryTest.java index 7fd0f6e9a8b..7d24f40509b 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryTest.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlParametersFactoryTest.java @@ -27,6 +27,7 @@ import java.util.Objects; import org.junit.jupiter.api.Test; +import org.mockito.Mock; import org.springframework.core.convert.converter.Converter; import org.springframework.data.annotation.Id; import org.springframework.data.convert.ReadingConverter; @@ -38,18 +39,20 @@ import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; /** * Unit tests for {@link SqlParametersFactory}. * * @author Chirag Tailor + * @author Mikhail Polivakha */ 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 diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/mapping/IdGeneratingBeforeSaveCallbackTest.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/mapping/IdGeneratingBeforeSaveCallbackTest.java new file mode 100644 index 00000000000..65b2222bab2 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/mapping/IdGeneratingBeforeSaveCallbackTest.java @@ -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; + } + } +} \ No newline at end of file diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryIntegrationTests.java index 0704a16ca0f..c6f605a4973 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryIntegrationTests.java @@ -68,6 +68,8 @@ import org.springframework.data.jdbc.repository.support.JdbcRepositoryFactory; import org.springframework.data.jdbc.testing.ConditionalOnDatabase; import org.springframework.data.jdbc.testing.DatabaseType; +import org.springframework.data.jdbc.testing.DisabledOnDatabase; +import org.springframework.data.jdbc.testing.EnabledOnDatabase; import org.springframework.data.jdbc.testing.EnabledOnFeature; import org.springframework.data.jdbc.testing.IntegrationTest; import org.springframework.data.jdbc.testing.TestConfiguration; @@ -75,6 +77,7 @@ 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 +118,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 +138,29 @@ public void savesAnEntity() { "id_Prop = " + entity.getIdProp())).isEqualTo(1); } + @Test + @DisabledOnDatabase(database = DatabaseType.MYSQL) + 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 + @DisabledOnDatabase(database = DatabaseType.MYSQL) + public void batchInsertEntityWithTargetSequenceSpecified() { + Iterable 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 +1541,8 @@ interface RootRepository extends ListCrudRepository { interface WithDelimitedColumnRepository extends CrudRepository {} + interface EntityWithSequenceRepository extends CrudRepository {} + @Configuration @Import(TestConfiguration.class) static class Config { @@ -1536,6 +1564,11 @@ WithDelimitedColumnRepository withDelimitedColumnRepository() { return factory.getRepository(WithDelimitedColumnRepository.class); } + @Bean + EntityWithSequenceRepository entityWithSequenceRepository() { + return factory.getRepository(EntityWithSequenceRepository.class); + } + @Bean NamedQueries namedQueries() throws IOException { @@ -1839,6 +1872,32 @@ private static DummyEntity createEntity(String entityName, Consumer 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; diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/DisabledOnDatabase.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/DisabledOnDatabase.java new file mode 100644 index 00000000000..c83ec900f68 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/DisabledOnDatabase.java @@ -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(); +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/DisabledOnDatabaseExecutionCondition.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/DisabledOnDatabaseExecutionCondition.java new file mode 100644 index 00000000000..17f9bfdf206 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/DisabledOnDatabaseExecutionCondition.java @@ -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 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())); + } +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/TestConfiguration.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/TestConfiguration.java index b84d93fe6b0..85d74672b20 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/TestConfiguration.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/TestConfiguration.java @@ -36,11 +36,15 @@ 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> namedQueries, + List> callbacks, List 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 @@ JdbcConverter relationalConverter(RelationalMappingContext mappingContext, @Lazy 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()); diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-db2.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-db2.sql index 2c66f226e1a..1c00e779a6e 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-db2.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-db2.sql @@ -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) -); \ No newline at end of file +); + +CREATE TABLE ENTITY_WITH_SEQUENCE +( + ID BIGINT, + NAME VARCHAR(100) +); + +CREATE SEQUENCE ENTITY_SEQUENCE START WITH 1 INCREMENT BY 1 NO MAXVALUE; \ No newline at end of file diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-h2.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-h2.sql index b72f6645357..6f9087b69df 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-h2.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-h2.sql @@ -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) -); \ No newline at end of file +); + +CREATE TABLE ENTITY_WITH_SEQUENCE +( + ID BIGINT, + NAME VARCHAR(100) +); + +CREATE SEQUENCE ENTITY_SEQUENCE START WITH 1 INCREMENT BY 1 NO MAXVALUE; \ No newline at end of file diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-hsql.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-hsql.sql index b72f6645357..6f9087b69df 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-hsql.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-hsql.sql @@ -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) -); \ No newline at end of file +); + +CREATE TABLE ENTITY_WITH_SEQUENCE +( + ID BIGINT, + NAME VARCHAR(100) +); + +CREATE SEQUENCE ENTITY_SEQUENCE START WITH 1 INCREMENT BY 1 NO MAXVALUE; \ No newline at end of file diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-mariadb.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-mariadb.sql index 75b46639892..19ebad8bc38 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-mariadb.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-mariadb.sql @@ -39,4 +39,12 @@ CREATE TABLE WITH_DELIMITED_COLUMN ID BIGINT AUTO_INCREMENT PRIMARY KEY, `ORG.XTUNIT.IDENTIFIER` VARCHAR(100), STYPE VARCHAR(100) -); \ No newline at end of file +); + +CREATE TABLE ENTITY_WITH_SEQUENCE +( + ID BIGINT, + NAME VARCHAR(100) +); + +CREATE SEQUENCE ENTITY_SEQUENCE START WITH 1 INCREMENT BY 1 NO MAXVALUE; \ No newline at end of file diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-mssql.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-mssql.sql index 9959dea4a81..69f191f65df 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-mssql.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-mssql.sql @@ -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) -); \ No newline at end of file +); + +CREATE TABLE ENTITY_WITH_SEQUENCE +( + ID BIGINT, + NAME VARCHAR(100) +); + +CREATE SEQUENCE ENTITY_SEQUENCE START WITH 1 INCREMENT BY 1 NO MAXVALUE; \ No newline at end of file diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-mysql.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-mysql.sql index 0d3e16587ff..43c33bc4404 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-mysql.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-mysql.sql @@ -42,4 +42,4 @@ CREATE TABLE WITH_DELIMITED_COLUMN ID BIGINT AUTO_INCREMENT PRIMARY KEY, `ORG.XTUNIT.IDENTIFIER` VARCHAR(100), STYPE VARCHAR(100) -); \ No newline at end of file +); diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-oracle.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-oracle.sql index 0a08dfbf9ed..179ac5abb99 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-oracle.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-oracle.sql @@ -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; \ No newline at end of file diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-postgres.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-postgres.sql index 37ad6914dee..14dff05925f 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-postgres.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository/JdbcRepositoryIntegrationTests-postgres.sql @@ -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) -); \ No newline at end of file +); + +CREATE TABLE ENTITY_WITH_SEQUENCE +( + ID BIGINT, + NAME VARCHAR(100) +); + +CREATE SEQUENCE ENTITY_SEQUENCE START WITH 1 INCREMENT BY 1 NO MAXVALUE; \ No newline at end of file diff --git a/spring-data-relational/src/jmh/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGeneratorBenchmark.java b/spring-data-relational/src/jmh/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGeneratorBenchmark.java index 3d4278221a8..fa2d723ca0a 100644 --- a/spring-data-relational/src/jmh/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGeneratorBenchmark.java +++ b/spring-data-relational/src/jmh/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGeneratorBenchmark.java @@ -15,7 +15,6 @@ */ package org.springframework.data.relational.core.sqlgeneration; - import java.util.List; import org.junit.platform.commons.annotation.Testable; diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/IdValueSource.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/IdValueSource.java index 0c7961ae619..d16011e0ea7 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/IdValueSource.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/IdValueSource.java @@ -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 @@ * 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 IdValueSource forInstance(Object instance, RelationalPersistentEntity persistentEntity) { + Optional idTargetSequence = persistentEntity.getIdTargetSequence(); + if (idTargetSequence.isPresent()) { + return IdValueSource.SEQUENCE; + } + Object idValue = persistentEntity.getIdentifierAccessor(instance).getIdentifier(); RelationalPersistentProperty idProperty = persistentEntity.getIdProperty(); if (idProperty == null) { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Db2Dialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Db2Dialect.java index d2f2fa3e7c6..b39d807a2f3 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Db2Dialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Db2Dialect.java @@ -25,6 +25,7 @@ * 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() {} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java index 492b84f11fe..de8b16b1491 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java @@ -146,5 +146,5 @@ default SimpleFunction getExistsFunction() { default boolean supportsSingleQueryLoading() { return true; - }; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/H2Dialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/H2Dialect.java index a13212971a2..74f4e955c6e 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/H2Dialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/H2Dialect.java @@ -31,6 +31,7 @@ * @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 Set> simpleTypes() { 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); + } + }; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/HsqlDbDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/HsqlDbDialect.java index 268f59cc528..619b66a556c 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/HsqlDbDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/HsqlDbDialect.java @@ -20,6 +20,7 @@ * * @author Jens Schauder * @author Myeonghyeon Lee + * @author Mikhail Polivakha */ public class HsqlDbDialect extends AbstractDialect { @@ -64,4 +65,22 @@ public Position getClausePosition() { 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 The way JOOQ solves this problem + */ + @Override + public String nextValueFromSequenceSelect(String sequenceName) { + return "SELECT NEXT VALUE FOR %s AS msq FROM INFORMATION_SCHEMA.SEQUENCES LIMIT 1".formatted(sequenceName); + } + }; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/IdGeneration.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/IdGeneration.java index f29a877b350..472d1f7f7d6 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/IdGeneration.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/IdGeneration.java @@ -21,10 +21,13 @@ 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 @@ default String getKeyColumnName(SqlIdentifier id) { 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. *

@@ -71,4 +81,16 @@ default String getKeyColumnName(SqlIdentifier id) { 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()) + ); + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MariaDbDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MariaDbDialect.java index 4387724134c..51e48f07b11 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MariaDbDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MariaDbDialect.java @@ -18,12 +18,14 @@ 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 Collection getConverters() { TimestampAtUtcToOffsetDateTimeConverter.INSTANCE, NumberToBooleanConverter.INSTANCE); } + + @Override + public IdGeneration getIdGeneration() { + return new IdGeneration() { + + @Override + public String nextValueFromSequenceSelect(String sequenceName) { + return "SELECT NEXTVAL(%s)".formatted(sequenceName); + } + }; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MySqlDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MySqlDialect.java index 6fe76b6df94..f9238acdf0e 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MySqlDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MySqlDialect.java @@ -141,4 +141,15 @@ public Collection getConverters() { public OrderByNullPrecedence orderByNullHandling() { return OrderByNullPrecedence.NONE; } + + @Override + public IdGeneration getIdGeneration() { + return new IdGeneration() { + + @Override + public boolean sequencesSupported() { + return false; + } + }; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java index 4970d507591..93ba723d108 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java @@ -27,6 +27,7 @@ * 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 boolean driverRequiresKeyColumnNames() { 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() {} 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 ca0d52c2eab..c5f58e0cb47 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 @@ -42,6 +42,7 @@ * @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 String getLock(LockOptions lockOptions) { // 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 Set> simpleTypes() { 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); + } + }; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/SqlServerDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/SqlServerDialect.java index 2eb2a1ee9a3..dd45d6dce07 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/SqlServerDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/SqlServerDialect.java @@ -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 diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntity.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntity.java index c8d67cb1b25..8bd6b5a2cd2 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntity.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntity.java @@ -17,6 +17,7 @@ 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 extends BasicPersistentEntity tableName; private final @Nullable Expression tableNameExpression; + private final Lazy idTargetSequenceName; + private final Lazy> schemaName; private final @Nullable Expression schemaNameExpression; private final ExpressionEvaluator expressionEvaluator; @@ -87,6 +90,8 @@ class BasicRelationalPersistentEntity extends BasicPersistentEntity 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()); + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java index e5432499a79..2c915dd21c0 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java @@ -17,6 +17,7 @@ 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 @@ * Embedded entity extension for a {@link Embedded entity}. * * @author Mark Paluch + * @author Mikhail Polivakha * @since 3.2 */ class EmbeddedRelationalPersistentEntity implements RelationalPersistentEntity { @@ -54,6 +56,11 @@ public SqlIdentifier getIdColumn() { throw new MappingException("Embedded entity does not have an id column"); } + @Override + public Optional getIdTargetSequence() { + return Optional.empty(); + } + @Override public void addPersistentProperty(RelationalPersistentProperty property) { throw new UnsupportedOperationException(); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPersistentEntity.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPersistentEntity.java index f54587a19d5..fea5f9c86c0 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPersistentEntity.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalPersistentEntity.java @@ -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 @@ * @author Jens Schauder * @author Oliver Gierke * @author Mark Paluch + * @author Mikhail Polivakha */ public interface RelationalPersistentEntity extends MutablePersistentEntity { @@ -52,4 +55,8 @@ default SqlIdentifier getQualifiedTableName() { */ SqlIdentifier getIdColumn(); + /** + * @return the target sequence that should be used for id generation + */ + Optional getIdTargetSequence(); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/TargetSequence.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/TargetSequence.java new file mode 100644 index 00000000000..be16bcfc7fe --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/TargetSequence.java @@ -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. + *

+ * The final name of the sequence to be queried for the next value will be constructed by the concatenation + * of schema and sequence :

schema().sequence()
+ */ + String schema() default ""; +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntityUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntityUnitTests.java index 83f56e80121..8fc0b98033b 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntityUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/BasicRelationalPersistentEntityUnitTests.java @@ -58,6 +58,34 @@ void discoversAnnotatedTableName() { 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 @@ static class DummySubEntity { @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