Browse Source
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>pull/2001/head
33 changed files with 716 additions and 39 deletions
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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(); |
||||||
|
} |
||||||
@ -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())); |
||||||
|
} |
||||||
|
} |
||||||
@ -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 ""; |
||||||
|
} |
||||||
Loading…
Reference in new issue