diff --git a/.changes/next-release/feature-DynamoDBEnhancedClient-2a501d8.json b/.changes/next-release/feature-DynamoDBEnhancedClient-2a501d8.json new file mode 100644 index 000000000000..cfb75bbcf67e --- /dev/null +++ b/.changes/next-release/feature-DynamoDBEnhancedClient-2a501d8.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "DynamoDB Enhanced Client", + "contributor": "akiesler", + "description": "DynamoDB Enhanced Client: Support for Version Starting at 0 with Configurable Increment" +} diff --git a/pom.xml b/pom.xml index 92cc17a8ab3f..70cba84d3421 100644 --- a/pom.xml +++ b/pom.xml @@ -679,6 +679,8 @@ polly + software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute#incrementBy() + software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute#startAt() *.internal.* software.amazon.awssdk.thirdparty.* software.amazon.awssdk.regions.* diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/Expression.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/Expression.java index fa0f69ad9ed3..5a4d9e454e47 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/Expression.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/Expression.java @@ -311,6 +311,15 @@ public int hashCode() { return result; } + @Override + public String toString() { + return "Expression{" + + "expression='" + expression + '\'' + + ", expressionValues=" + expressionValues + + ", expressionNames=" + expressionNames + + '}'; + } + /** * A builder for {@link Expression} */ diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java index 34a6396c5109..89bb11aa31df 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java @@ -34,6 +34,7 @@ import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.utils.Validate; /** * This extension implements optimistic locking on record writes by means of a 'record version number' that is used @@ -61,7 +62,18 @@ public final class VersionedRecordExtension implements DynamoDbEnhancedClientExt private static final String CUSTOM_METADATA_KEY = "VersionedRecordExtension:VersionAttribute"; private static final VersionAttribute VERSION_ATTRIBUTE = new VersionAttribute(); - private VersionedRecordExtension() { + private final long startAt; + private final long incrementBy; + + private VersionedRecordExtension(Long startAt, Long incrementBy) { + Validate.isNotNegativeOrNull(startAt, "startAt"); + + if (incrementBy != null && incrementBy < 1) { + throw new IllegalArgumentException("IncrementBy must be greater than 0."); + } + + this.startAt = startAt != null ? startAt : 0L; + this.incrementBy = incrementBy != null ? incrementBy : 1L; } public static Builder builder() { @@ -75,19 +87,47 @@ private AttributeTags() { public static StaticAttributeTag versionAttribute() { return VERSION_ATTRIBUTE; } + + public static StaticAttributeTag versionAttribute(Long startAt, Long incrementBy) { + return new VersionAttribute(startAt, incrementBy); + } } - private static class VersionAttribute implements StaticAttributeTag { + private static final class VersionAttribute implements StaticAttributeTag { + private static final String START_AT_METADATA_KEY = "VersionedRecordExtension:StartAt"; + private static final String INCREMENT_BY_METADATA_KEY = "VersionedRecordExtension:IncrementBy"; + + private final Long startAt; + private final Long incrementBy; + + private VersionAttribute() { + this.startAt = null; + this.incrementBy = null; + } + + private VersionAttribute(Long startAt, Long incrementBy) { + this.startAt = startAt; + this.incrementBy = incrementBy; + } + @Override public Consumer modifyMetadata(String attributeName, AttributeValueType attributeValueType) { if (attributeValueType != AttributeValueType.N) { throw new IllegalArgumentException(String.format( "Attribute '%s' of type %s is not a suitable type to be used as a version attribute. Only type 'N' " + - "is supported.", attributeName, attributeValueType.name())); + "is supported.", attributeName, attributeValueType.name())); + } + + Validate.isNotNegativeOrNull(startAt, "startAt"); + + if (incrementBy != null && incrementBy < 1) { + throw new IllegalArgumentException("IncrementBy must be greater than 0."); } return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, attributeName) + .addCustomMetadataObject(START_AT_METADATA_KEY, startAt) + .addCustomMetadataObject(INCREMENT_BY_METADATA_KEY, incrementBy) .markAttributeAsKey(attributeName, attributeValueType); } } @@ -109,9 +149,24 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex Optional existingVersionValue = Optional.ofNullable(itemToTransform.get(versionAttributeKey.get())); - if (!existingVersionValue.isPresent() || isNullAttributeValue(existingVersionValue.get())) { - // First version of the record - newVersionValue = AttributeValue.builder().n("1").build(); + Optional versionStartAtFromAnnotation = context.tableMetadata() + .customMetadataObject(VersionAttribute.START_AT_METADATA_KEY, + Long.class); + + Optional versionIncrementByFromAnnotation = context.tableMetadata() + .customMetadataObject(VersionAttribute.INCREMENT_BY_METADATA_KEY, + Long.class); + + if (!existingVersionValue.isPresent() || isNullAttributeValue(existingVersionValue.get()) || + (existingVersionValue.get().n() != null && + ((versionStartAtFromAnnotation.isPresent() && + Long.parseLong(existingVersionValue.get().n()) == versionStartAtFromAnnotation.get()) || + Long.parseLong(existingVersionValue.get().n()) == this.startAt))) { + + long startValue = versionStartAtFromAnnotation.orElse(this.startAt); + long increment = versionIncrementByFromAnnotation.orElse(this.incrementBy); + + newVersionValue = AttributeValue.builder().n(Long.toString(startValue + increment)).build(); condition = Expression.builder() .expression(String.format("attribute_not_exists(%s)", attributeKeyRef)) .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get())) @@ -123,9 +178,12 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex throw new IllegalArgumentException("Version attribute appears to be the wrong type. N is required."); } - int existingVersion = Integer.parseInt(existingVersionValue.get().n()); + long existingVersion = Long.parseLong(existingVersionValue.get().n()); String existingVersionValueKey = VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER.apply(versionAttributeKey.get()); - newVersionValue = AttributeValue.builder().n(Integer.toString(existingVersion + 1)).build(); + + long increment = versionIncrementByFromAnnotation.orElse(this.incrementBy); + newVersionValue = AttributeValue.builder().n(Long.toString(existingVersion + increment)).build(); + condition = Expression.builder() .expression(String.format("%s = %s", attributeKeyRef, existingVersionValueKey)) .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get())) @@ -144,11 +202,38 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex @NotThreadSafe public static final class Builder { + private Long startAt = 0L; + private Long incrementBy = 1L; + private Builder() { } + /** + * Sets the startAt used to compare if a record is the initial version of a record. + * Default value - {@code 0}. + * + * @param startAt + * @return the builder instance + */ + public Builder startAt(Long startAt) { + this.startAt = startAt; + return this; + } + + /** + * Sets the amount to increment the version by with each subsequent update. + * Default value - {@code 1}. + * + * @param incrementBy + * @return the builder instance + */ + public Builder incrementBy(Long incrementBy) { + this.incrementBy = incrementBy; + return this; + } + public VersionedRecordExtension build() { - return new VersionedRecordExtension(); + return new VersionedRecordExtension(this.startAt, this.incrementBy); } } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbVersionAttribute.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbVersionAttribute.java index 21f3beeeb446..09ab6eb00159 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbVersionAttribute.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbVersionAttribute.java @@ -33,4 +33,20 @@ @Retention(RetentionPolicy.RUNTIME) @BeanTableSchemaAttributeTag(VersionRecordAttributeTags.class) public @interface DynamoDbVersionAttribute { + /** + * The starting value for the version attribute. + * Default value - {@code 0}. + * + * @return the starting value + */ + long startAt() default 0; + + /** + * The amount to increment the version by with each update. + * Default value - {@code 1}. + * + * @return the increment value + */ + long incrementBy() default 1; + } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/VersionRecordAttributeTags.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/VersionRecordAttributeTags.java index e1c2d527866b..d81cf268afff 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/VersionRecordAttributeTags.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/VersionRecordAttributeTags.java @@ -26,6 +26,6 @@ private VersionRecordAttributeTags() { } public static StaticAttributeTag attributeTagFor(DynamoDbVersionAttribute annotation) { - return VersionedRecordExtension.AttributeTags.versionAttribute(); + return VersionedRecordExtension.AttributeTags.versionAttribute(annotation.startAt(), annotation.incrementBy()); } } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtensionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtensionTest.java index 4f61db7487e9..2dac494b943c 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtensionTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtensionTest.java @@ -18,19 +18,29 @@ import static java.util.Collections.singletonMap; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem.createUniqueFakeItem; import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItemWithSort.createUniqueFakeItemWithSort; import java.util.HashMap; import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; import org.junit.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.enhanced.dynamodb.OperationContext; import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItemWithSort; import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DefaultOperationContext; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; public class VersionedRecordExtensionTest { @@ -47,10 +57,10 @@ public void beforeRead_doesNotTransformObject() { ReadModification result = versionedRecordExtension.afterRead(DefaultDynamoDbExtensionContext - .builder() - .items(fakeItemMap) - .tableMetadata(FakeItem.getTableMetadata()) - .operationContext(PRIMARY_CONTEXT).build()); + .builder() + .items(fakeItemMap) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); assertThat(result, is(ReadModification.builder().build())); } @@ -168,15 +178,344 @@ public void beforeWrite_returnsNoOpModification_ifVersionAttributeNotDefined() { @Test(expected = IllegalArgumentException.class) public void beforeWrite_throwsIllegalArgumentException_ifVersionAttributeIsWrongType() { FakeItem fakeItem = createUniqueFakeItem(); - Map fakeItemWIthBadVersion = + Map fakeItemWithBadVersion = new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); - fakeItemWIthBadVersion.put("version", AttributeValue.builder().s("14").build()); + fakeItemWithBadVersion.put("version", AttributeValue.builder().s("14").build()); versionedRecordExtension.beforeWrite( DefaultDynamoDbExtensionContext.builder() - .items(fakeItemWIthBadVersion) + .items(fakeItemWithBadVersion) .operationContext(PRIMARY_CONTEXT) .tableMetadata(FakeItem.getTableMetadata()) .build()); } + + @Test + public void beforeWrite_versionEqualsStartAt_treatedAsInitialVersion() { + VersionedRecordExtension recordExtension = VersionedRecordExtension.builder() + .startAt(5L) + .build(); + + FakeItem fakeItem = createUniqueFakeItem(); + fakeItem.setVersion(5); + + Map inputMap = + new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); + + WriteModification result = + recordExtension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(inputMap) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result.additionalConditionalExpression().expression(), + is("attribute_not_exists(#AMZN_MAPPED_version)")); + } + + @ParameterizedTest + @MethodSource("customStartAtAndIncrementValues") + public void customStartingValueAndIncrement_worksAsExpected(Long startAt, Long incrementBy, String expectedVersion) { + VersionedRecordExtension.Builder recordExtensionBuilder = VersionedRecordExtension.builder(); + if (startAt != null) { + recordExtensionBuilder.startAt(startAt); + } + if (incrementBy != null) { + recordExtensionBuilder.incrementBy(incrementBy); + } + + VersionedRecordExtension recordExtension = recordExtensionBuilder.build(); + + FakeItem fakeItem = createUniqueFakeItem(); + + Map inputMap = + new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); + + Map expectedInitialVersion = + new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); + + expectedInitialVersion.put("version", AttributeValue.builder().n(expectedVersion).build()); + + WriteModification result = + recordExtension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(inputMap) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result.transformedItem(), is(expectedInitialVersion)); + assertThat(result.additionalConditionalExpression(), + is(Expression.builder() + .expression("attribute_not_exists(#AMZN_MAPPED_version)") + .expressionNames(singletonMap("#AMZN_MAPPED_version", "version")) + .build())); + } + + public static Stream customStartAtAndIncrementValues() { + return Stream.of( + Arguments.of(0L,1L,"1"), + Arguments.of(3L,2L,"5"), + Arguments.of(3L,null,"4"), + Arguments.of(null,3L,"3")); + } + + @ParameterizedTest + @MethodSource("customFailingStartAtAndIncrementValues") + public void customStartingValueAndIncrement_shouldThrow(Long startAt, Long incrementBy) { + assertThrows(IllegalArgumentException.class, () -> VersionedRecordExtension.builder() + .startAt(startAt) + .incrementBy(incrementBy) + .build()); + } + + public static Stream customFailingStartAtAndIncrementValues() { + return Stream.of( + Arguments.of(-2L, 1L), + Arguments.of(3L, 0L)); + } + + @Test + public void beforeWrite_versionNotEqualsAnnotationStartAt_notTreatedAsInitialVersion() { + FakeVersionedThroughAnnotationItem item = new FakeVersionedThroughAnnotationItem(); + item.setId(UUID.randomUUID().toString()); + item.setVersion(10L); + + TableSchema schema = + TableSchema.fromBean(FakeVersionedThroughAnnotationItem.class); + + Map inputMap = new HashMap<>(schema.itemToMap(item, true)); + + VersionedRecordExtension recordExtension = VersionedRecordExtension.builder().build(); + + WriteModification result = + recordExtension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(inputMap) + .tableMetadata(schema.tableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result.additionalConditionalExpression().expression(), + is("#AMZN_MAPPED_version = :old_version_value")); + } + + @Test + public void beforeWrite_versionEqualsAnnotationStartAt_isTreatedAsInitialVersion() { + FakeVersionedThroughAnnotationItem item = new FakeVersionedThroughAnnotationItem(); + item.setId(UUID.randomUUID().toString()); + item.setVersion(3L); + + TableSchema schema = + TableSchema.fromBean(FakeVersionedThroughAnnotationItem.class); + + Map inputMap = new HashMap<>(schema.itemToMap(item, true)); + + VersionedRecordExtension recordExtension = VersionedRecordExtension.builder().build(); + + WriteModification result = + recordExtension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(inputMap) + .tableMetadata(schema.tableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result.additionalConditionalExpression().expression(), + is("attribute_not_exists(#AMZN_MAPPED_version)")); + } + + + @DynamoDbBean + public static class FakeVersionedThroughAnnotationItem { + private String id; + private Long version; + + public FakeVersionedThroughAnnotationItem() { + } + + @DynamoDbPartitionKey + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + @DynamoDbVersionAttribute(startAt = 3, incrementBy = 2) + public Long getVersion() { return version; } + public void setVersion(Long version) { this.version = version; } + } + + + @Test + public void customStartingValueAndIncrementWithAnnotation_worksAsExpected() { + VersionedRecordExtension recordExtension = VersionedRecordExtension.builder().build(); + + FakeVersionedThroughAnnotationItem item = new FakeVersionedThroughAnnotationItem(); + item.setId(UUID.randomUUID().toString()); + + TableSchema schema = TableSchema.fromBean(FakeVersionedThroughAnnotationItem.class); + + Map inputMap = new HashMap<>(schema.itemToMap(item, true)); + + Map expectedInitialVersion = new HashMap<>(schema.itemToMap(item, true)); + expectedInitialVersion.put("version", AttributeValue.builder().n("5").build()); + + WriteModification result = + recordExtension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(inputMap) + .tableMetadata(schema.tableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result.transformedItem(), is(expectedInitialVersion)); + assertThat(result.additionalConditionalExpression(), + is(Expression.builder() + .expression("attribute_not_exists(#AMZN_MAPPED_version)") + .expressionNames(singletonMap("#AMZN_MAPPED_version", "version")) + .build())); + } + + @Test + public void customAnnotationValuesAndBuilderValues_annotationShouldTakePrecedence() { + VersionedRecordExtension recordExtension = VersionedRecordExtension.builder() + .startAt(5L) + .incrementBy(2L) + .build(); + + FakeVersionedThroughAnnotationItem item = new FakeVersionedThroughAnnotationItem(); + item.setId(UUID.randomUUID().toString()); + + TableSchema schema = TableSchema.fromBean(FakeVersionedThroughAnnotationItem.class); + + Map inputMap = new HashMap<>(schema.itemToMap(item, true)); + + Map expectedInitialVersion = new HashMap<>(schema.itemToMap(item, true)); + expectedInitialVersion.put("version", AttributeValue.builder().n("5").build()); + + WriteModification result = + recordExtension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(inputMap) + .tableMetadata(schema.tableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result.transformedItem(), is(expectedInitialVersion)); + assertThat(result.additionalConditionalExpression(), + is(Expression.builder() + .expression("attribute_not_exists(#AMZN_MAPPED_version)") + .expressionNames(singletonMap("#AMZN_MAPPED_version", "version")) + .build())); + } + + @DynamoDbBean + public static class FakeVersionedThroughAnnotationItemWithExplicitDefaultValues { + private String id; + private Long version; + + public FakeVersionedThroughAnnotationItemWithExplicitDefaultValues() { + } + + @DynamoDbPartitionKey + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + @DynamoDbVersionAttribute(startAt = 0, incrementBy = 1) + public Long getVersion() { return version; } + public void setVersion(Long version) { this.version = version; } + } + + @Test + public void customAnnotationDefaultValuesAndBuilderValues_annotationShouldTakePrecedence() { + VersionedRecordExtension recordExtension = VersionedRecordExtension.builder() + .startAt(5L) + .incrementBy(2L) + .build(); + + FakeVersionedThroughAnnotationItemWithExplicitDefaultValues item = new FakeVersionedThroughAnnotationItemWithExplicitDefaultValues(); + item.setId(UUID.randomUUID().toString()); + + TableSchema schema = TableSchema.fromBean(FakeVersionedThroughAnnotationItemWithExplicitDefaultValues.class); + + Map inputMap = new HashMap<>(schema.itemToMap(item, true)); + + Map expectedInitialVersion = new HashMap<>(schema.itemToMap(item, true)); + expectedInitialVersion.put("version", AttributeValue.builder().n("1").build()); + + WriteModification result = + recordExtension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(inputMap) + .tableMetadata(schema.tableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result.transformedItem(), is(expectedInitialVersion)); + assertThat(result.additionalConditionalExpression(), + is(Expression.builder() + .expression("attribute_not_exists(#AMZN_MAPPED_version)") + .expressionNames(singletonMap("#AMZN_MAPPED_version", "version")) + .build())); + } + + @DynamoDbBean + public static class FakeVersionedThroughAnnotationItemWithInvalidValues { + private String id; + private Long version; + + public FakeVersionedThroughAnnotationItemWithInvalidValues() { + } + + @DynamoDbPartitionKey + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + @DynamoDbVersionAttribute(startAt = -1, incrementBy = -1) + public Long getVersion() { return version; } + public void setVersion(Long version) { this.version = version; } + } + + @Test + public void invalidAnnotationValues_shouldThrowException() { + FakeVersionedThroughAnnotationItemWithInvalidValues item = new FakeVersionedThroughAnnotationItemWithInvalidValues(); + item.setId(UUID.randomUUID().toString()); + + assertThrows(IllegalArgumentException.class, () -> TableSchema.fromBean(FakeVersionedThroughAnnotationItemWithInvalidValues.class)); + } + + @ParameterizedTest + @MethodSource("customIncrementForExistingVersionValues") + public void customIncrementForExistingVersion_worksAsExpected(Long startAt, Long incrementBy, + Long existingVersion, String expectedNextVersion) { + VersionedRecordExtension.Builder recordExtensionBuilder = VersionedRecordExtension.builder(); + if (startAt != null) { + recordExtensionBuilder.startAt(startAt); + } + if (incrementBy != null) { + recordExtensionBuilder.incrementBy(incrementBy); + } + VersionedRecordExtension recordExtension = recordExtensionBuilder.build(); + + FakeItem fakeItem = createUniqueFakeItem(); + fakeItem.setVersion(existingVersion.intValue()); + + Map inputMap = + new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); + + Map expectedVersionedItem = + new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); + expectedVersionedItem.put("version", AttributeValue.builder().n(expectedNextVersion).build()); + + WriteModification result = + recordExtension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(inputMap) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result.transformedItem(), is(expectedVersionedItem)); + assertThat(result.additionalConditionalExpression().expression(), + is("#AMZN_MAPPED_version = :old_version_value")); + } + + public static Stream customIncrementForExistingVersionValues() { + return Stream.of( + Arguments.of(0L, 1L, 5L, "6"), + Arguments.of(3L, 2L, 7L, "9"), + Arguments.of(3L, null, 10L, "11"), + Arguments.of(null, 3L, 4L, "7")); + } }