diff --git a/.changes/next-release/feature-DynamoDBEnhancedClient-2a501d8.json b/.changes/next-release/feature-DynamoDBEnhancedClient-2a501d8.json new file mode 100644 index 000000000000..d9d2457d5776 --- /dev/null +++ b/.changes/next-release/feature-DynamoDBEnhancedClient-2a501d8.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "DynamoDB Enhanced Client", + "contributor": "akiesler", + "description": "Support for Version Starting at 0 with Configurable Increment" +} diff --git a/pom.xml b/pom.xml index 5d4255770701..0c46743eaf38 100644 --- a/pom.xml +++ b/pom.xml @@ -680,6 +680,9 @@ 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..c521e1f5d958 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 @@ -26,6 +26,7 @@ import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.annotations.ThreadSafe; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.utils.ToString; /** * High-level representation of a DynamoDB 'expression' that can be used in various situations where the API requires @@ -311,6 +312,20 @@ public int hashCode() { return result; } + @Override + public String toString() { + // return "Expression{" + + // "expression='" + expression + '\'' + + // ", expressionValues=" + expressionValues + + // ", expressionNames=" + expressionNames + + // '}'; + return ToString.builder("Expression") + .add("expression", expression) + .add("expressionValues", expressionValues) + .add("expressionNames", expressionNames) + .build(); + } + /** * 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..9c9e48f9e739 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,18 @@ 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 (isInitialVersion(existingVersionValue, versionStartAtFromAnnotation)) { + 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 +172,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())) @@ -142,13 +194,55 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex .build(); } + private boolean isInitialVersion(Optional existingVersionValue, Optional versionStartAtFromAnnotation) { + if (!existingVersionValue.isPresent() || isNullAttributeValue(existingVersionValue.get())) { + return true; + } + + AttributeValue value = existingVersionValue.get(); + if (value.n() != null) { + long currentVersion = Long.parseLong(value.n()); + return (versionStartAtFromAnnotation.isPresent() && currentVersion == versionStartAtFromAnnotation.get()) + || currentVersion == this.startAt; + } + + return false; + } + @NotThreadSafe public static final class Builder { + private Long startAt; + private Long incrementBy; + 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..9e68de829354 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,31 @@ 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.functionaltests.models.FakeVersionedImmutableItem; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeVersionedStaticImmutableItem; 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 +59,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 +180,413 @@ 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")); + } + + @ParameterizedTest + @MethodSource("customIncrementForExistingVersionValues") + public void customIncrementForExistingVersion_withImmutableSchema_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(); + + FakeVersionedStaticImmutableItem fakeItem = FakeVersionedStaticImmutableItem + .builder() + .id(UUID.randomUUID().toString()) + .version(existingVersion) + .build(); + + Map inputMap = + new HashMap<>(FakeVersionedStaticImmutableItem.getTableSchema().itemToMap(fakeItem, true)); + + Map expectedVersionedItem = + new HashMap<>(FakeVersionedStaticImmutableItem.getTableSchema().itemToMap(fakeItem, true)); + expectedVersionedItem.put("version", AttributeValue.builder().n(expectedNextVersion).build()); + + WriteModification result = + recordExtension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(inputMap) + .tableMetadata(FakeVersionedStaticImmutableItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result.transformedItem(), is(expectedVersionedItem)); + assertThat(result.additionalConditionalExpression().expression(), + is("#AMZN_MAPPED_version = :old_version_value")); + } + + @Test + public void customStartingValueAndIncrementWithImmutableClass_worksAsExpected() { + VersionedRecordExtension recordExtension = VersionedRecordExtension.builder().build(); + + FakeVersionedImmutableItem item = FakeVersionedImmutableItem.builder() + .id(UUID.randomUUID().toString()) + .build(); + + TableSchema schema = + TableSchema.fromImmutableClass(FakeVersionedImmutableItem.class); + + Map inputMap = new HashMap<>(schema.itemToMap(item, true)); + + Map expectedInitialVersion = new HashMap<>(schema.itemToMap(item, true)); + expectedInitialVersion.put("version", AttributeValue.builder().n("9").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())); + } + + 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")); + } } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/FakeVersionedImmutableItem.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/FakeVersionedImmutableItem.java new file mode 100644 index 000000000000..ba4d606acab7 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/FakeVersionedImmutableItem.java @@ -0,0 +1,64 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests.models; + +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; + +@DynamoDbImmutable(builder = FakeVersionedImmutableItem.Builder.class) +public class FakeVersionedImmutableItem { + private final String id; + private final Long version; + + private FakeVersionedImmutableItem(Builder builder) { + this.id = builder.id; + this.version = builder.version; + } + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + @DynamoDbVersionAttribute(startAt = 4, incrementBy = 5) + public Long getVersion() { + return version; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String id; + private Long version; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder version(Long version) { + this.version = version; + return this; + } + + public FakeVersionedImmutableItem build() { + return new FakeVersionedImmutableItem(this); + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/FakeVersionedStaticImmutableItem.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/FakeVersionedStaticImmutableItem.java new file mode 100644 index 000000000000..527991276146 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/FakeVersionedStaticImmutableItem.java @@ -0,0 +1,119 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests.models; + +import static software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension.AttributeTags.versionAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; + +import java.util.Objects; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticImmutableTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; + +@DynamoDbImmutable(builder = FakeVersionedStaticImmutableItem.Builder.class) +public class FakeVersionedStaticImmutableItem { + private final String id; + private final String attribute; + private final long version; + + private FakeVersionedStaticImmutableItem(Builder b) { + this.id = b.id; + this.attribute = b.attribute; + this.version = b.version; + } + + public static Builder builder() { + return new Builder(); + } + + public String attribute() { + return attribute; + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + public long version() { + return version; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FakeVersionedStaticImmutableItem that = (FakeVersionedStaticImmutableItem) o; + return version == that.version && Objects.equals(id, that.id) && Objects.equals(attribute, that.attribute); + } + + @Override + public int hashCode() { + return Objects.hash(id, attribute, version); + } + + public static TableSchema getTableSchema() { + return StaticImmutableTableSchema.builder(FakeVersionedStaticImmutableItem.class, FakeVersionedStaticImmutableItem.Builder.class) + .newItemBuilder(FakeVersionedStaticImmutableItem::builder, FakeVersionedStaticImmutableItem.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(FakeVersionedStaticImmutableItem::id) + .setter(FakeVersionedStaticImmutableItem.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(Long.class, a -> a.name("version") + .getter(FakeVersionedStaticImmutableItem::version) + .setter(FakeVersionedStaticImmutableItem.Builder::version) + .tags(versionAttribute())) + .addAttribute(String.class, a -> a.name("attribute") + .getter(FakeVersionedStaticImmutableItem::attribute) + .setter(FakeVersionedStaticImmutableItem.Builder::attribute)) + .build(); + } + + public static TableMetadata getTableMetadata() { + return getTableSchema().tableMetadata(); + } + + public static final class Builder { + private String id; + private String attribute; + private long version; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder version(long version) { + this.version = version; + return this; + } + + public Builder attribute(String attribute) { + this.attribute = attribute; + return this; + } + + public FakeVersionedStaticImmutableItem build() { + return new FakeVersionedStaticImmutableItem(this); + } + } +}