From 099236ced0ad4f595544f323b41da62399374de0 Mon Sep 17 00:00:00 2001 From: Ran Vaknin Date: Tue, 8 Apr 2025 10:28:41 -0700 Subject: [PATCH 01/11] Adding annotation support --- .../awssdk/enhanced/dynamodb/Expression.java | 11 +- .../extensions/VersionedRecordExtension.java | 201 +++++++++++++++--- .../annotations/DynamoDbVersionAttribute.java | 16 ++ .../VersionRecordAttributeTags.java | 2 +- .../VersionedRecordExtensionTest.java | 122 ++++++++++- 5 files changed, 311 insertions(+), 41 deletions(-) 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..e14cc33401fe 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,8 +311,17 @@ public int hashCode() { return result; } + @Override + public String toString() { + return "Expression{" + + "expression='" + expression + '\'' + + ", expressionValues=" + expressionValues + + ", expressionNames=" + expressionNames + + '}'; + } + /** - * A builder for {@link Expression} + * A builder for {@link Expression}v */ @NotThreadSafe public static final class Builder { 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..a13d683c7562 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.Pair; /** * This extension implements optimistic locking on record writes by means of a 'record version number' that is used @@ -60,8 +61,20 @@ public final class VersionedRecordExtension implements DynamoDbEnhancedClientExt private static final Function VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER = key -> ":old_" + key + "_value"; private static final String CUSTOM_METADATA_KEY = "VersionedRecordExtension:VersionAttribute"; private static final VersionAttribute VERSION_ATTRIBUTE = new VersionAttribute(); + private static final AttributeValue DEFAULT_VALUE = AttributeValue.fromNul(Boolean.TRUE); - private VersionedRecordExtension() { + private final int startAt; + private final int incrementBy; + + /** + * Creates a new {@link VersionedRecordExtension} using the supplied starting and incrementing value. + * + * @param startAt the value used to compare if a record is the initial version of a record. + * @param incrementBy the amount to increment the version by with each subsequent update. + */ + private VersionedRecordExtension(int startAt, int incrementBy) { + this.startAt = startAt; + this.incrementBy = incrementBy; } public static Builder builder() { @@ -75,19 +88,42 @@ private AttributeTags() { public static StaticAttributeTag versionAttribute() { return VERSION_ATTRIBUTE; } + + public static StaticAttributeTag versionAttribute(int startAt, int 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 int startAt; + private final int incrementBy; + + private VersionAttribute() { + this.startAt = 0; + this.incrementBy = 1; + } + + private VersionAttribute(int startAt, int 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())); } - return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, attributeName) + return metadata -> metadata + .addCustomMetadataObject(CUSTOM_METADATA_KEY, attributeName) + .addCustomMetadataObject(START_AT_METADATA_KEY, startAt) + .addCustomMetadataObject(INCREMENT_BY_METADATA_KEY, incrementBy) .markAttributeAsKey(attributeName, attributeValueType); } } @@ -101,39 +137,14 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex return WriteModification.builder().build(); } - Map itemToTransform = new HashMap<>(context.items()); - String attributeKeyRef = keyRef(versionAttributeKey.get()); - AttributeValue newVersionValue; - Expression condition; - 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(); - condition = Expression.builder() - .expression(String.format("attribute_not_exists(%s)", attributeKeyRef)) - .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get())) - .build(); - } else { - // Existing record, increment version - if (existingVersionValue.get().n() == null) { - // In this case a non-null version attribute is present, but it's not an N - throw new IllegalArgumentException("Version attribute appears to be the wrong type. N is required."); - } + Pair updates = getRecordUpdates(versionAttributeKey.get(), context); - int existingVersion = Integer.parseInt(existingVersionValue.get().n()); - String existingVersionValueKey = VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER.apply(versionAttributeKey.get()); - newVersionValue = AttributeValue.builder().n(Integer.toString(existingVersion + 1)).build(); - condition = Expression.builder() - .expression(String.format("%s = %s", attributeKeyRef, existingVersionValueKey)) - .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get())) - .expressionValues(Collections.singletonMap(existingVersionValueKey, - existingVersionValue.get())) - .build(); - } + // Unpack values from Pair + AttributeValue newVersionValue = updates.left(); + Expression condition = updates.right(); + Map itemToTransform = new HashMap<>(context.items()); itemToTransform.put(versionAttributeKey.get(), newVersionValue); return WriteModification.builder() @@ -142,13 +153,133 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex .build(); } + private Pair getRecordUpdates(String versionAttributeKey, + DynamoDbExtensionContext.BeforeWrite context) { + Map itemToTransform = context.items(); + + // Default to NUL if not present to reduce additional checks further along + AttributeValue existingVersionValue = itemToTransform.getOrDefault(versionAttributeKey, DEFAULT_VALUE); + + if (isInitialVersion(existingVersionValue, context)) { + // First version of the record ensure it does not exist + return createInitialRecord(versionAttributeKey, context); + } + // Existing record, increment version + return updateExistingRecord(versionAttributeKey, existingVersionValue, context); + } + + private boolean isInitialVersion(AttributeValue existingVersionValue, DynamoDbExtensionContext.BeforeWrite context) { + if (isNullAttributeValue(existingVersionValue)) { + return true; + } + + + Optional versionStartAtFromAnnotation = context.tableMetadata() + .customMetadataObject(VersionAttribute.START_AT_METADATA_KEY, + Integer.class); + + return isNullAttributeValue(existingVersionValue) + || (versionStartAtFromAnnotation.isPresent() && + getExistingVersion(existingVersionValue) == versionStartAtFromAnnotation.get()) + || (!versionStartAtFromAnnotation.isPresent() && + getExistingVersion(existingVersionValue) == this.startAt); + } + + private Pair createInitialRecord(String versionAttributeKey, + DynamoDbExtensionContext.BeforeWrite context) { + Optional versionStartAtFromAnnotation = context.tableMetadata() + .customMetadataObject(VersionAttribute.START_AT_METADATA_KEY, + Integer.class); + + AttributeValue newVersionValue = versionStartAtFromAnnotation.isPresent() ? + incrementVersion(versionStartAtFromAnnotation.get(), context) : + incrementVersion(this.startAt, context); + + + String attributeKeyRef = keyRef(versionAttributeKey); + + Expression condition = Expression.builder() + // Check that the version does not exist before setting the initial value. + .expression(String.format("attribute_not_exists(%s)", attributeKeyRef)) + .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey)) + .build(); + + return Pair.of(newVersionValue, condition); + } + + private Pair updateExistingRecord(String versionAttributeKey, + AttributeValue existingVersionValue, + DynamoDbExtensionContext.BeforeWrite context) { + int existingVersion = getExistingVersion(existingVersionValue); + AttributeValue newVersionValue = incrementVersion(existingVersion, context); + + String attributeKeyRef = keyRef(versionAttributeKey); + String existingVersionValueKey = VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER.apply(versionAttributeKey); + + Expression condition = Expression.builder() + // Check that the version matches the existing value before setting the updated value. + .expression(String.format("%s = %s", attributeKeyRef, existingVersionValueKey)) + .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey)) + .expressionValues(Collections.singletonMap(existingVersionValueKey, + existingVersionValue)) + .build(); + + return Pair.of(newVersionValue, condition); + } + + private int getExistingVersion(AttributeValue existingVersionValue) { + if (existingVersionValue.n() == null) { + // In this case a non-null version attribute is present, but it's not an N + throw new IllegalArgumentException("Version attribute appears to be the wrong type. N is required."); + } + + return Integer.parseInt(existingVersionValue.n()); + } + + private AttributeValue incrementVersion(int version, DynamoDbExtensionContext.BeforeWrite context) { + Optional versionIncrementByFromAnnotation = context.tableMetadata() + .customMetadataObject(VersionAttribute.INCREMENT_BY_METADATA_KEY, + Integer.class); + if (versionIncrementByFromAnnotation.isPresent()) { + return AttributeValue.fromN(Integer.toString(version + versionIncrementByFromAnnotation.get())); + } + return AttributeValue.fromN(Integer.toString(version + this.incrementBy)); + } + @NotThreadSafe public static final class Builder { + private int startAt = 0; + private int incrementBy = 1; + 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(int 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(int 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..c664340db03e 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 + */ + int startAt() default 0; + + /** + * The amount to increment the version by with each update. + * Default value - {@code 1}. + * + * @return the increment value + */ + int 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..072407349832 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 @@ -23,14 +23,22 @@ import java.util.HashMap; import java.util.Map; +import java.util.UUID; import org.junit.Test; 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.StaticTableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; public class VersionedRecordExtensionTest { @@ -47,10 +55,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())); } @@ -112,6 +120,65 @@ public void beforeWrite_initialVersionDueToExplicitNull_transformedItemIsCorrect assertThat(result.transformedItem(), is(fakeItemWithInitialVersion)); } + @Test + public void customStartingValueAndIncrement_worksAsExpected() { + VersionedRecordExtension recordExtension = VersionedRecordExtension.builder() + .startAt(5) + .incrementBy(2) + .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("7").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())); + } + + @Test + public void beforeWrite_initialVersionDueToExplicitZero_expressionAndTransformedItemIsCorrect() { + FakeItem fakeItem = createUniqueFakeItem(); + + Map inputMap = + new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); + inputMap.put("version", AttributeValue.builder().n("0").build()); + + Map fakeItemWithInitialVersion = + new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); + fakeItemWithInitialVersion.put("version", AttributeValue.builder().n("1").build()); + + WriteModification result = + versionedRecordExtension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(inputMap) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result.transformedItem(), is(fakeItemWithInitialVersion)); + assertThat(result.additionalConditionalExpression(), + is(Expression.builder() + .expression("attribute_not_exists(#AMZN_MAPPED_version)") + .expressionNames(singletonMap("#AMZN_MAPPED_version", "version")) + .build())); + } + @Test public void beforeWrite_existingVersion_expressionIsCorrect() { FakeItem fakeItem = createUniqueFakeItem(); @@ -179,4 +246,51 @@ public void beforeWrite_throwsIllegalArgumentException_ifVersionAttributeIsWrong .tableMetadata(FakeItem.getTableMetadata()) .build()); } + + @DynamoDbBean + public static class CustomVersionedItem { + private String id; + private Integer version; + + public CustomVersionedItem() { + } + + @DynamoDbPartitionKey + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + @DynamoDbVersionAttribute(startAt = 3, incrementBy = 2) + public Integer getVersion() { return version; } + public void setVersion(Integer version) { this.version = version; } + } + + + @Test + public void customStartingValueAndIncrementWithAnnotation_worksAsExpected() { + VersionedRecordExtension recordExtension = VersionedRecordExtension.builder().build(); + + CustomVersionedItem item = new CustomVersionedItem(); + item.setId(UUID.randomUUID().toString()); + + TableSchema schema = TableSchema.fromBean(CustomVersionedItem.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())); + } } From f0ccb3efea198b77a2f986e9c077458215aaa85c Mon Sep 17 00:00:00 2001 From: Ran Vaknin Date: Wed, 9 Apr 2025 15:03:48 -0700 Subject: [PATCH 02/11] Making annotation values nullable to know if initial value is explicitly set --- .../extensions/VersionedRecordExtension.java | 38 +- .../VersionedRecordExtensionTest.java | 340 ++++++++++++++---- 2 files changed, 293 insertions(+), 85 deletions(-) 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 a13d683c7562..08cef7a07a38 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 @@ -89,7 +89,7 @@ public static StaticAttributeTag versionAttribute() { return VERSION_ATTRIBUTE; } - public static StaticAttributeTag versionAttribute(int startAt, int incrementBy) { + public static StaticAttributeTag versionAttribute(Integer startAt, Integer incrementBy) { return new VersionAttribute(startAt, incrementBy); } } @@ -98,15 +98,15 @@ 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 int startAt; - private final int incrementBy; + private final Integer startAt; + private final Integer incrementBy; private VersionAttribute() { - this.startAt = 0; - this.incrementBy = 1; + this.startAt = null; + this.incrementBy = null; } - private VersionAttribute(int startAt, int incrementBy) { + private VersionAttribute(Integer startAt, Integer incrementBy) { this.startAt = startAt; this.incrementBy = incrementBy; } @@ -120,6 +120,14 @@ public Consumer modifyMetadata(String attributeName "is supported.", attributeName, attributeValueType.name())); } + if (startAt != null && startAt < 0) { + throw new IllegalArgumentException("StartAt cannot be negative."); + } + + 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) @@ -137,7 +145,6 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex return WriteModification.builder().build(); } - Pair updates = getRecordUpdates(versionAttributeKey.get(), context); // Unpack values from Pair @@ -169,20 +176,13 @@ private Pair getRecordUpdates(String versionAttribut } private boolean isInitialVersion(AttributeValue existingVersionValue, DynamoDbExtensionContext.BeforeWrite context) { - if (isNullAttributeValue(existingVersionValue)) { - return true; - } - - Optional versionStartAtFromAnnotation = context.tableMetadata() .customMetadataObject(VersionAttribute.START_AT_METADATA_KEY, Integer.class); return isNullAttributeValue(existingVersionValue) - || (versionStartAtFromAnnotation.isPresent() && - getExistingVersion(existingVersionValue) == versionStartAtFromAnnotation.get()) - || (!versionStartAtFromAnnotation.isPresent() && - getExistingVersion(existingVersionValue) == this.startAt); + || (versionStartAtFromAnnotation.isPresent() && getExistingVersion(existingVersionValue) == versionStartAtFromAnnotation.get()) + || getExistingVersion(existingVersionValue) == this.startAt; } private Pair createInitialRecord(String versionAttributeKey, @@ -262,6 +262,9 @@ private Builder() { * @return the builder instance */ public Builder startAt(int startAt) { + if (startAt < 0) { + throw new IllegalArgumentException("StartAt cannot be negative."); + } this.startAt = startAt; return this; } @@ -274,6 +277,9 @@ public Builder startAt(int startAt) { * @return the builder instance */ public Builder incrementBy(int incrementBy) { + if (incrementBy < 1) { + throw new IllegalArgumentException("IncrementBy must be greater than 0."); + } this.incrementBy = incrementBy; return this; } 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 072407349832..ed4ecafc2544 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,13 +18,18 @@ 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; @@ -34,11 +39,8 @@ 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.StaticTableMetadata; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; public class VersionedRecordExtensionTest { @@ -120,65 +122,6 @@ public void beforeWrite_initialVersionDueToExplicitNull_transformedItemIsCorrect assertThat(result.transformedItem(), is(fakeItemWithInitialVersion)); } - @Test - public void customStartingValueAndIncrement_worksAsExpected() { - VersionedRecordExtension recordExtension = VersionedRecordExtension.builder() - .startAt(5) - .incrementBy(2) - .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("7").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())); - } - - @Test - public void beforeWrite_initialVersionDueToExplicitZero_expressionAndTransformedItemIsCorrect() { - FakeItem fakeItem = createUniqueFakeItem(); - - Map inputMap = - new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); - inputMap.put("version", AttributeValue.builder().n("0").build()); - - Map fakeItemWithInitialVersion = - new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); - fakeItemWithInitialVersion.put("version", AttributeValue.builder().n("1").build()); - - WriteModification result = - versionedRecordExtension.beforeWrite(DefaultDynamoDbExtensionContext - .builder() - .items(inputMap) - .tableMetadata(FakeItem.getTableMetadata()) - .operationContext(PRIMARY_CONTEXT).build()); - - assertThat(result.transformedItem(), is(fakeItemWithInitialVersion)); - assertThat(result.additionalConditionalExpression(), - is(Expression.builder() - .expression("attribute_not_exists(#AMZN_MAPPED_version)") - .expressionNames(singletonMap("#AMZN_MAPPED_version", "version")) - .build())); - } - @Test public void beforeWrite_existingVersion_expressionIsCorrect() { FakeItem fakeItem = createUniqueFakeItem(); @@ -235,24 +178,134 @@ 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(5) + .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(Integer startAt, Integer 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(0,1,"1"), + Arguments.of(3,2,"5"), + Arguments.of(3,null,"4"), + Arguments.of(null,3,"3")); + } + + @ParameterizedTest + @MethodSource("customFailingStartAtAndIncrementValues") + public void customStartingValueAndIncrement_shouldThrow(Integer startAt, Integer incrementBy) { + assertThrows(IllegalArgumentException.class, () -> VersionedRecordExtension.builder() + .startAt(startAt) + .incrementBy(incrementBy) + .build()); + } + + public static Stream customFailingStartAtAndIncrementValues() { + return Stream.of( + Arguments.of(-2, 1), + Arguments.of(3, 0)); + } + + @Test + public void beforeWrite_versionNotEqualsAnnotationStartAt_notTreatedAsInitialVersion() { + FakeVersionedThroughAnnotationItem item = new FakeVersionedThroughAnnotationItem(); + item.setId(UUID.randomUUID().toString()); + item.setVersion(10); + + 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()); + + // Should be treated as existing version + assertThat(result.additionalConditionalExpression().expression(), + is("#AMZN_MAPPED_version = :old_version_value")); + } + + @DynamoDbBean - public static class CustomVersionedItem { + public static class FakeVersionedThroughAnnotationItem { private String id; private Integer version; - public CustomVersionedItem() { + public FakeVersionedThroughAnnotationItem() { } @DynamoDbPartitionKey @@ -269,10 +322,42 @@ public CustomVersionedItem() { public void customStartingValueAndIncrementWithAnnotation_worksAsExpected() { VersionedRecordExtension recordExtension = VersionedRecordExtension.builder().build(); - CustomVersionedItem item = new CustomVersionedItem(); + 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(5) + .incrementBy(2) + .build(); + + FakeVersionedThroughAnnotationItem item = new FakeVersionedThroughAnnotationItem(); item.setId(UUID.randomUUID().toString()); - TableSchema schema = TableSchema.fromBean(CustomVersionedItem.class); + TableSchema schema = TableSchema.fromBean(FakeVersionedThroughAnnotationItem.class); Map inputMap = new HashMap<>(schema.itemToMap(item, true)); @@ -293,4 +378,121 @@ public void customStartingValueAndIncrementWithAnnotation_worksAsExpected() { .expressionNames(singletonMap("#AMZN_MAPPED_version", "version")) .build())); } + + @DynamoDbBean + public static class FakeVersionedThroughAnnotationItemWithExplicitDefaultValues { + private String id; + private Integer version; + + public FakeVersionedThroughAnnotationItemWithExplicitDefaultValues() { + } + + @DynamoDbPartitionKey + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + @DynamoDbVersionAttribute(startAt = 0, incrementBy = 1) + public Integer getVersion() { return version; } + public void setVersion(Integer version) { this.version = version; } + } + + @Test + public void customAnnotationDefaultValuesAndBuilderValues_annotationShouldTakePrecedence() { + VersionedRecordExtension recordExtension = VersionedRecordExtension.builder() + .startAt(5) + .incrementBy(2) + .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 Integer version; + + public FakeVersionedThroughAnnotationItemWithInvalidValues() { + } + + @DynamoDbPartitionKey + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + @DynamoDbVersionAttribute(startAt = -1, incrementBy = -1) + public Integer getVersion() { return version; } + public void setVersion(Integer 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(Integer startAt, Integer incrementBy, + Integer 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); + + 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(0, 1, 5, "6"), + Arguments.of(3, 2, 7, "9"), + Arguments.of(3, null, 10, "11"), + Arguments.of(null, 3, 4, "7")); + } } From 086b7696f498d0a7cf101258af15c5c6f7ec5e49 Mon Sep 17 00:00:00 2001 From: Ran Vaknin Date: Wed, 9 Apr 2025 15:23:14 -0700 Subject: [PATCH 03/11] Added changelog with attribution --- .../feature-DynamoDBEnhancedClient-2a501d8.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changes/next-release/feature-DynamoDBEnhancedClient-2a501d8.json 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" +} From de4a4f57741d13c915176009b96874021eea9c58 Mon Sep 17 00:00:00 2001 From: Ran Vaknin Date: Thu, 10 Apr 2025 10:06:45 -0700 Subject: [PATCH 04/11] Fix checkstyle --- .../dynamodb/extensions/VersionedRecordExtension.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 08cef7a07a38..abb79ad19b44 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 @@ -181,7 +181,8 @@ private boolean isInitialVersion(AttributeValue existingVersionValue, DynamoDbEx Integer.class); return isNullAttributeValue(existingVersionValue) - || (versionStartAtFromAnnotation.isPresent() && getExistingVersion(existingVersionValue) == versionStartAtFromAnnotation.get()) + || (versionStartAtFromAnnotation.isPresent() + && getExistingVersion(existingVersionValue) == versionStartAtFromAnnotation.get()) || getExistingVersion(existingVersionValue) == this.startAt; } @@ -238,8 +239,8 @@ private int getExistingVersion(AttributeValue existingVersionValue) { private AttributeValue incrementVersion(int version, DynamoDbExtensionContext.BeforeWrite context) { Optional versionIncrementByFromAnnotation = context.tableMetadata() - .customMetadataObject(VersionAttribute.INCREMENT_BY_METADATA_KEY, - Integer.class); + .customMetadataObject(VersionAttribute.INCREMENT_BY_METADATA_KEY, + Integer.class); if (versionIncrementByFromAnnotation.isPresent()) { return AttributeValue.fromN(Integer.toString(version + versionIncrementByFromAnnotation.get())); } From 9a66e86f585d89351c3987b1908d09748c6dc3b3 Mon Sep 17 00:00:00 2001 From: Ran Vaknin Date: Thu, 10 Apr 2025 15:54:23 -0700 Subject: [PATCH 05/11] Adding japicmp excludes block --- services-custom/dynamodb-enhanced/pom.xml | 14 ++++++++++++++ .../src/main/resources/log4j2.xml | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 test/http-client-tests/src/main/resources/log4j2.xml diff --git a/services-custom/dynamodb-enhanced/pom.xml b/services-custom/dynamodb-enhanced/pom.xml index 66143ddb4c70..7ecb872c5856 100644 --- a/services-custom/dynamodb-enhanced/pom.xml +++ b/services-custom/dynamodb-enhanced/pom.xml @@ -76,6 +76,20 @@ + + com.github.siom79.japicmp + japicmp-maven-plugin + ${japicmp-maven-plugin.version} + + + + + software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute#incrementBy() + software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute#startAt() + + + + diff --git a/test/http-client-tests/src/main/resources/log4j2.xml b/test/http-client-tests/src/main/resources/log4j2.xml new file mode 100644 index 000000000000..d14a91c10db4 --- /dev/null +++ b/test/http-client-tests/src/main/resources/log4j2.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file From 6195af7098c9181414428393f3ea003ece69806d Mon Sep 17 00:00:00 2001 From: Ran Vaknin Date: Fri, 11 Apr 2025 11:39:14 -0700 Subject: [PATCH 06/11] Adding additional test coverage to account for all isInitialVersion codepaths --- .../extensions/VersionedRecordExtension.java | 3 --- .../VersionedRecordExtensionTest.java | 25 ++++++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) 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 abb79ad19b44..60e9aac2d0e3 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 @@ -147,7 +147,6 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex Pair updates = getRecordUpdates(versionAttributeKey.get(), context); - // Unpack values from Pair AttributeValue newVersionValue = updates.left(); Expression condition = updates.right(); @@ -168,7 +167,6 @@ private Pair getRecordUpdates(String versionAttribut AttributeValue existingVersionValue = itemToTransform.getOrDefault(versionAttributeKey, DEFAULT_VALUE); if (isInitialVersion(existingVersionValue, context)) { - // First version of the record ensure it does not exist return createInitialRecord(versionAttributeKey, context); } // Existing record, increment version @@ -230,7 +228,6 @@ private Pair updateExistingRecord(String versionAttr private int getExistingVersion(AttributeValue existingVersionValue) { if (existingVersionValue.n() == null) { - // In this case a non-null version attribute is present, but it's not an N throw new IllegalArgumentException("Version attribute appears to be the wrong type. N is required."); } 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 ed4ecafc2544..20e9671b86fe 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 @@ -294,11 +294,34 @@ public void beforeWrite_versionNotEqualsAnnotationStartAt_notTreatedAsInitialVer .tableMetadata(schema.tableMetadata()) .operationContext(PRIMARY_CONTEXT).build()); - // Should be treated as existing version 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(3); + + 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 { From 7cbf420572930b987406abd09e6b4e7c9e8f006f Mon Sep 17 00:00:00 2001 From: Ran Vaknin Date: Fri, 11 Apr 2025 11:43:58 -0700 Subject: [PATCH 07/11] Removing logger config file --- .../src/main/resources/log4j2.xml | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 test/http-client-tests/src/main/resources/log4j2.xml diff --git a/test/http-client-tests/src/main/resources/log4j2.xml b/test/http-client-tests/src/main/resources/log4j2.xml deleted file mode 100644 index d14a91c10db4..000000000000 --- a/test/http-client-tests/src/main/resources/log4j2.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file From b78740699c196a9f3fd995cd67b3bdc32c84b0c9 Mon Sep 17 00:00:00 2001 From: Ran Vaknin Date: Mon, 21 Apr 2025 16:41:56 -0700 Subject: [PATCH 08/11] porting version from Integer to Long, removing refactoring --- pom.xml | 2 + services-custom/dynamodb-enhanced/pom.xml | 14 -- .../awssdk/enhanced/dynamodb/Expression.java | 2 +- .../extensions/VersionedRecordExtension.java | 196 +++++++----------- .../annotations/DynamoDbVersionAttribute.java | 4 +- .../VersionedRecordExtensionTest.java | 62 +++--- 6 files changed, 109 insertions(+), 171 deletions(-) diff --git a/pom.xml b/pom.xml index 5d4255770701..7d9fde9f2682 100644 --- a/pom.xml +++ b/pom.xml @@ -680,6 +680,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/pom.xml b/services-custom/dynamodb-enhanced/pom.xml index 7ecb872c5856..66143ddb4c70 100644 --- a/services-custom/dynamodb-enhanced/pom.xml +++ b/services-custom/dynamodb-enhanced/pom.xml @@ -76,20 +76,6 @@ - - com.github.siom79.japicmp - japicmp-maven-plugin - ${japicmp-maven-plugin.version} - - - - - software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute#incrementBy() - software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute#startAt() - - - - 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 e14cc33401fe..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 @@ -321,7 +321,7 @@ public String toString() { } /** - * A builder for {@link Expression}v + * A builder for {@link Expression} */ @NotThreadSafe public static final class Builder { 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 60e9aac2d0e3..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,7 +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.Pair; +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,20 +61,19 @@ public final class VersionedRecordExtension implements DynamoDbEnhancedClientExt private static final Function VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER = key -> ":old_" + key + "_value"; private static final String CUSTOM_METADATA_KEY = "VersionedRecordExtension:VersionAttribute"; private static final VersionAttribute VERSION_ATTRIBUTE = new VersionAttribute(); - private static final AttributeValue DEFAULT_VALUE = AttributeValue.fromNul(Boolean.TRUE); - - private final int startAt; - private final int incrementBy; - - /** - * Creates a new {@link VersionedRecordExtension} using the supplied starting and incrementing value. - * - * @param startAt the value used to compare if a record is the initial version of a record. - * @param incrementBy the amount to increment the version by with each subsequent update. - */ - private VersionedRecordExtension(int startAt, int incrementBy) { - this.startAt = startAt; - this.incrementBy = incrementBy; + + 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() { @@ -89,7 +88,7 @@ public static StaticAttributeTag versionAttribute() { return VERSION_ATTRIBUTE; } - public static StaticAttributeTag versionAttribute(Integer startAt, Integer incrementBy) { + public static StaticAttributeTag versionAttribute(Long startAt, Long incrementBy) { return new VersionAttribute(startAt, incrementBy); } } @@ -98,15 +97,15 @@ 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 Integer startAt; - private final Integer incrementBy; + private final Long startAt; + private final Long incrementBy; private VersionAttribute() { this.startAt = null; this.incrementBy = null; } - private VersionAttribute(Integer startAt, Integer incrementBy) { + private VersionAttribute(Long startAt, Long incrementBy) { this.startAt = startAt; this.incrementBy = incrementBy; } @@ -120,16 +119,13 @@ public Consumer modifyMetadata(String attributeName "is supported.", attributeName, attributeValueType.name())); } - if (startAt != null && startAt < 0) { - throw new IllegalArgumentException("StartAt cannot be negative."); - } + 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) + return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, attributeName) .addCustomMetadataObject(START_AT_METADATA_KEY, startAt) .addCustomMetadataObject(INCREMENT_BY_METADATA_KEY, incrementBy) .markAttributeAsKey(attributeName, attributeValueType); @@ -145,109 +141,69 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex return WriteModification.builder().build(); } - Pair updates = getRecordUpdates(versionAttributeKey.get(), context); - - AttributeValue newVersionValue = updates.left(); - Expression condition = updates.right(); - Map itemToTransform = new HashMap<>(context.items()); - itemToTransform.put(versionAttributeKey.get(), newVersionValue); - - return WriteModification.builder() - .transformedItem(Collections.unmodifiableMap(itemToTransform)) - .additionalConditionalExpression(condition) - .build(); - } - - private Pair getRecordUpdates(String versionAttributeKey, - DynamoDbExtensionContext.BeforeWrite context) { - Map itemToTransform = context.items(); - - // Default to NUL if not present to reduce additional checks further along - AttributeValue existingVersionValue = itemToTransform.getOrDefault(versionAttributeKey, DEFAULT_VALUE); - if (isInitialVersion(existingVersionValue, context)) { - return createInitialRecord(versionAttributeKey, context); - } - // Existing record, increment version - return updateExistingRecord(versionAttributeKey, existingVersionValue, context); - } - - private boolean isInitialVersion(AttributeValue existingVersionValue, DynamoDbExtensionContext.BeforeWrite context) { - Optional versionStartAtFromAnnotation = context.tableMetadata() - .customMetadataObject(VersionAttribute.START_AT_METADATA_KEY, - Integer.class); - - return isNullAttributeValue(existingVersionValue) - || (versionStartAtFromAnnotation.isPresent() - && getExistingVersion(existingVersionValue) == versionStartAtFromAnnotation.get()) - || getExistingVersion(existingVersionValue) == this.startAt; - } - - private Pair createInitialRecord(String versionAttributeKey, - DynamoDbExtensionContext.BeforeWrite context) { - Optional versionStartAtFromAnnotation = context.tableMetadata() - .customMetadataObject(VersionAttribute.START_AT_METADATA_KEY, - Integer.class); - - AttributeValue newVersionValue = versionStartAtFromAnnotation.isPresent() ? - incrementVersion(versionStartAtFromAnnotation.get(), context) : - incrementVersion(this.startAt, context); - - - String attributeKeyRef = keyRef(versionAttributeKey); - - Expression condition = Expression.builder() - // Check that the version does not exist before setting the initial value. - .expression(String.format("attribute_not_exists(%s)", attributeKeyRef)) - .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey)) - .build(); - - return Pair.of(newVersionValue, condition); - } - - private Pair updateExistingRecord(String versionAttributeKey, - AttributeValue existingVersionValue, - DynamoDbExtensionContext.BeforeWrite context) { - int existingVersion = getExistingVersion(existingVersionValue); - AttributeValue newVersionValue = incrementVersion(existingVersion, context); - - String attributeKeyRef = keyRef(versionAttributeKey); - String existingVersionValueKey = VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER.apply(versionAttributeKey); + String attributeKeyRef = keyRef(versionAttributeKey.get()); + AttributeValue newVersionValue; + Expression condition; + Optional existingVersionValue = + Optional.ofNullable(itemToTransform.get(versionAttributeKey.get())); + + 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())) + .build(); + } else { + // Existing record, increment version + if (existingVersionValue.get().n() == null) { + // In this case a non-null version attribute is present, but it's not an N + throw new IllegalArgumentException("Version attribute appears to be the wrong type. N is required."); + } - Expression condition = Expression.builder() - // Check that the version matches the existing value before setting the updated value. - .expression(String.format("%s = %s", attributeKeyRef, existingVersionValueKey)) - .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey)) - .expressionValues(Collections.singletonMap(existingVersionValueKey, - existingVersionValue)) - .build(); + long existingVersion = Long.parseLong(existingVersionValue.get().n()); + String existingVersionValueKey = VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER.apply(versionAttributeKey.get()); - return Pair.of(newVersionValue, condition); - } + long increment = versionIncrementByFromAnnotation.orElse(this.incrementBy); + newVersionValue = AttributeValue.builder().n(Long.toString(existingVersion + increment)).build(); - private int getExistingVersion(AttributeValue existingVersionValue) { - if (existingVersionValue.n() == null) { - throw new IllegalArgumentException("Version attribute appears to be the wrong type. N is required."); + condition = Expression.builder() + .expression(String.format("%s = %s", attributeKeyRef, existingVersionValueKey)) + .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get())) + .expressionValues(Collections.singletonMap(existingVersionValueKey, + existingVersionValue.get())) + .build(); } - return Integer.parseInt(existingVersionValue.n()); - } + itemToTransform.put(versionAttributeKey.get(), newVersionValue); - private AttributeValue incrementVersion(int version, DynamoDbExtensionContext.BeforeWrite context) { - Optional versionIncrementByFromAnnotation = context.tableMetadata() - .customMetadataObject(VersionAttribute.INCREMENT_BY_METADATA_KEY, - Integer.class); - if (versionIncrementByFromAnnotation.isPresent()) { - return AttributeValue.fromN(Integer.toString(version + versionIncrementByFromAnnotation.get())); - } - return AttributeValue.fromN(Integer.toString(version + this.incrementBy)); + return WriteModification.builder() + .transformedItem(Collections.unmodifiableMap(itemToTransform)) + .additionalConditionalExpression(condition) + .build(); } @NotThreadSafe public static final class Builder { - private int startAt = 0; - private int incrementBy = 1; + private Long startAt = 0L; + private Long incrementBy = 1L; private Builder() { } @@ -259,10 +215,7 @@ private Builder() { * @param startAt * @return the builder instance */ - public Builder startAt(int startAt) { - if (startAt < 0) { - throw new IllegalArgumentException("StartAt cannot be negative."); - } + public Builder startAt(Long startAt) { this.startAt = startAt; return this; } @@ -274,10 +227,7 @@ public Builder startAt(int startAt) { * @param incrementBy * @return the builder instance */ - public Builder incrementBy(int incrementBy) { - if (incrementBy < 1) { - throw new IllegalArgumentException("IncrementBy must be greater than 0."); - } + public Builder incrementBy(Long incrementBy) { this.incrementBy = incrementBy; return this; } 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 c664340db03e..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 @@ -39,7 +39,7 @@ * * @return the starting value */ - int startAt() default 0; + long startAt() default 0; /** * The amount to increment the version by with each update. @@ -47,6 +47,6 @@ * * @return the increment value */ - int incrementBy() default 1; + long incrementBy() default 1; } 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 20e9671b86fe..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 @@ -193,7 +193,7 @@ public void beforeWrite_throwsIllegalArgumentException_ifVersionAttributeIsWrong @Test public void beforeWrite_versionEqualsStartAt_treatedAsInitialVersion() { VersionedRecordExtension recordExtension = VersionedRecordExtension.builder() - .startAt(5) + .startAt(5L) .build(); FakeItem fakeItem = createUniqueFakeItem(); @@ -215,7 +215,7 @@ public void beforeWrite_versionEqualsStartAt_treatedAsInitialVersion() { @ParameterizedTest @MethodSource("customStartAtAndIncrementValues") - public void customStartingValueAndIncrement_worksAsExpected(Integer startAt, Integer incrementBy, String expectedVersion) { + public void customStartingValueAndIncrement_worksAsExpected(Long startAt, Long incrementBy, String expectedVersion) { VersionedRecordExtension.Builder recordExtensionBuilder = VersionedRecordExtension.builder(); if (startAt != null) { recordExtensionBuilder.startAt(startAt); @@ -253,15 +253,15 @@ public void customStartingValueAndIncrement_worksAsExpected(Integer startAt, Int public static Stream customStartAtAndIncrementValues() { return Stream.of( - Arguments.of(0,1,"1"), - Arguments.of(3,2,"5"), - Arguments.of(3,null,"4"), - Arguments.of(null,3,"3")); + 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(Integer startAt, Integer incrementBy) { + public void customStartingValueAndIncrement_shouldThrow(Long startAt, Long incrementBy) { assertThrows(IllegalArgumentException.class, () -> VersionedRecordExtension.builder() .startAt(startAt) .incrementBy(incrementBy) @@ -270,15 +270,15 @@ public void customStartingValueAndIncrement_shouldThrow(Integer startAt, Integer public static Stream customFailingStartAtAndIncrementValues() { return Stream.of( - Arguments.of(-2, 1), - Arguments.of(3, 0)); + 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(10); + item.setVersion(10L); TableSchema schema = TableSchema.fromBean(FakeVersionedThroughAnnotationItem.class); @@ -302,7 +302,7 @@ public void beforeWrite_versionNotEqualsAnnotationStartAt_notTreatedAsInitialVer public void beforeWrite_versionEqualsAnnotationStartAt_isTreatedAsInitialVersion() { FakeVersionedThroughAnnotationItem item = new FakeVersionedThroughAnnotationItem(); item.setId(UUID.randomUUID().toString()); - item.setVersion(3); + item.setVersion(3L); TableSchema schema = TableSchema.fromBean(FakeVersionedThroughAnnotationItem.class); @@ -326,7 +326,7 @@ public void beforeWrite_versionEqualsAnnotationStartAt_isTreatedAsInitialVersion @DynamoDbBean public static class FakeVersionedThroughAnnotationItem { private String id; - private Integer version; + private Long version; public FakeVersionedThroughAnnotationItem() { } @@ -336,8 +336,8 @@ public FakeVersionedThroughAnnotationItem() { public void setId(String id) { this.id = id; } @DynamoDbVersionAttribute(startAt = 3, incrementBy = 2) - public Integer getVersion() { return version; } - public void setVersion(Integer version) { this.version = version; } + public Long getVersion() { return version; } + public void setVersion(Long version) { this.version = version; } } @@ -373,8 +373,8 @@ public void customStartingValueAndIncrementWithAnnotation_worksAsExpected() { @Test public void customAnnotationValuesAndBuilderValues_annotationShouldTakePrecedence() { VersionedRecordExtension recordExtension = VersionedRecordExtension.builder() - .startAt(5) - .incrementBy(2) + .startAt(5L) + .incrementBy(2L) .build(); FakeVersionedThroughAnnotationItem item = new FakeVersionedThroughAnnotationItem(); @@ -405,7 +405,7 @@ public void customAnnotationValuesAndBuilderValues_annotationShouldTakePrecedenc @DynamoDbBean public static class FakeVersionedThroughAnnotationItemWithExplicitDefaultValues { private String id; - private Integer version; + private Long version; public FakeVersionedThroughAnnotationItemWithExplicitDefaultValues() { } @@ -415,15 +415,15 @@ public FakeVersionedThroughAnnotationItemWithExplicitDefaultValues() { public void setId(String id) { this.id = id; } @DynamoDbVersionAttribute(startAt = 0, incrementBy = 1) - public Integer getVersion() { return version; } - public void setVersion(Integer version) { this.version = version; } + public Long getVersion() { return version; } + public void setVersion(Long version) { this.version = version; } } @Test public void customAnnotationDefaultValuesAndBuilderValues_annotationShouldTakePrecedence() { VersionedRecordExtension recordExtension = VersionedRecordExtension.builder() - .startAt(5) - .incrementBy(2) + .startAt(5L) + .incrementBy(2L) .build(); FakeVersionedThroughAnnotationItemWithExplicitDefaultValues item = new FakeVersionedThroughAnnotationItemWithExplicitDefaultValues(); @@ -454,7 +454,7 @@ public void customAnnotationDefaultValuesAndBuilderValues_annotationShouldTakePr @DynamoDbBean public static class FakeVersionedThroughAnnotationItemWithInvalidValues { private String id; - private Integer version; + private Long version; public FakeVersionedThroughAnnotationItemWithInvalidValues() { } @@ -464,8 +464,8 @@ public FakeVersionedThroughAnnotationItemWithInvalidValues() { public void setId(String id) { this.id = id; } @DynamoDbVersionAttribute(startAt = -1, incrementBy = -1) - public Integer getVersion() { return version; } - public void setVersion(Integer version) { this.version = version; } + public Long getVersion() { return version; } + public void setVersion(Long version) { this.version = version; } } @Test @@ -478,8 +478,8 @@ public void invalidAnnotationValues_shouldThrowException() { @ParameterizedTest @MethodSource("customIncrementForExistingVersionValues") - public void customIncrementForExistingVersion_worksAsExpected(Integer startAt, Integer incrementBy, - Integer existingVersion, String expectedNextVersion) { + public void customIncrementForExistingVersion_worksAsExpected(Long startAt, Long incrementBy, + Long existingVersion, String expectedNextVersion) { VersionedRecordExtension.Builder recordExtensionBuilder = VersionedRecordExtension.builder(); if (startAt != null) { recordExtensionBuilder.startAt(startAt); @@ -490,7 +490,7 @@ public void customIncrementForExistingVersion_worksAsExpected(Integer startAt, I VersionedRecordExtension recordExtension = recordExtensionBuilder.build(); FakeItem fakeItem = createUniqueFakeItem(); - fakeItem.setVersion(existingVersion); + fakeItem.setVersion(existingVersion.intValue()); Map inputMap = new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); @@ -513,9 +513,9 @@ public void customIncrementForExistingVersion_worksAsExpected(Integer startAt, I public static Stream customIncrementForExistingVersionValues() { return Stream.of( - Arguments.of(0, 1, 5, "6"), - Arguments.of(3, 2, 7, "9"), - Arguments.of(3, null, 10, "11"), - Arguments.of(null, 3, 4, "7")); + Arguments.of(0L, 1L, 5L, "6"), + Arguments.of(3L, 2L, 7L, "9"), + Arguments.of(3L, null, 10L, "11"), + Arguments.of(null, 3L, 4L, "7")); } } From b7b79c25ddc6ef6885311f8407a0cd5984167862 Mon Sep 17 00:00:00 2001 From: Ran Vaknin Date: Tue, 6 May 2025 13:50:51 -0700 Subject: [PATCH 09/11] refactoring and adding test coverage --- ...eature-DynamoDBEnhancedClient-2a501d8.json | 2 +- pom.xml | 1 + .../awssdk/enhanced/dynamodb/Expression.java | 16 ++- .../extensions/VersionedRecordExtension.java | 29 +++-- .../VersionedRecordExtensionTest.java | 43 ++++++ .../models/ImmutableFakeVersionedItem.java | 122 ++++++++++++++++++ 6 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/ImmutableFakeVersionedItem.java diff --git a/.changes/next-release/feature-DynamoDBEnhancedClient-2a501d8.json b/.changes/next-release/feature-DynamoDBEnhancedClient-2a501d8.json index cfb75bbcf67e..d9d2457d5776 100644 --- a/.changes/next-release/feature-DynamoDBEnhancedClient-2a501d8.json +++ b/.changes/next-release/feature-DynamoDBEnhancedClient-2a501d8.json @@ -2,5 +2,5 @@ "type": "feature", "category": "DynamoDB Enhanced Client", "contributor": "akiesler", - "description": "DynamoDB Enhanced Client: Support for Version Starting at 0 with Configurable Increment" + "description": "Support for Version Starting at 0 with Configurable Increment" } diff --git a/pom.xml b/pom.xml index 7d9fde9f2682..0c46743eaf38 100644 --- a/pom.xml +++ b/pom.xml @@ -680,6 +680,7 @@ polly + software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute#incrementBy() software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute#startAt() *.internal.* 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 5a4d9e454e47..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 @@ -313,11 +314,16 @@ public int hashCode() { @Override public String toString() { - return "Expression{" + - "expression='" + expression + '\'' + - ", expressionValues=" + expressionValues + - ", expressionNames=" + expressionNames + - '}'; + // return "Expression{" + + // "expression='" + expression + '\'' + + // ", expressionValues=" + expressionValues + + // ", expressionNames=" + expressionNames + + // '}'; + return ToString.builder("Expression") + .add("expression", expression) + .add("expressionValues", expressionValues) + .add("expressionNames", expressionNames) + .build(); } /** 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 89bb11aa31df..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 @@ -69,7 +69,7 @@ private VersionedRecordExtension(Long startAt, Long incrementBy) { Validate.isNotNegativeOrNull(startAt, "startAt"); if (incrementBy != null && incrementBy < 1) { - throw new IllegalArgumentException("IncrementBy must be greater than 0."); + throw new IllegalArgumentException("incrementBy must be greater than 0."); } this.startAt = startAt != null ? startAt : 0L; @@ -156,13 +156,7 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex 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))) { - + if (isInitialVersion(existingVersionValue, versionStartAtFromAnnotation)) { long startValue = versionStartAtFromAnnotation.orElse(this.startAt); long increment = versionIncrementByFromAnnotation.orElse(this.incrementBy); @@ -200,10 +194,25 @@ 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 = 0L; - private Long incrementBy = 1L; + private Long startAt; + private Long incrementBy; private Builder() { } 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 2dac494b943c..f51f3d9ef398 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 @@ -30,13 +30,18 @@ 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.AttributeConverterProvider; +import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType; 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.document.DocumentTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.document.EnhancedDocument; 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.ImmutableFakeVersionedItem; 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; @@ -511,6 +516,44 @@ public void customIncrementForExistingVersion_worksAsExpected(Long startAt, Long 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(); + + ImmutableFakeVersionedItem fakeItem = ImmutableFakeVersionedItem + .builder() + .id(UUID.randomUUID().toString()) + .version(existingVersion) + .build(); + + Map inputMap = + new HashMap<>(ImmutableFakeVersionedItem.getTableSchema().itemToMap(fakeItem, true)); + + Map expectedVersionedItem = + new HashMap<>(ImmutableFakeVersionedItem.getTableSchema().itemToMap(fakeItem, true)); + expectedVersionedItem.put("version", AttributeValue.builder().n(expectedNextVersion).build()); + + WriteModification result = + recordExtension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(inputMap) + .tableMetadata(ImmutableFakeVersionedItem.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"), diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/ImmutableFakeVersionedItem.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/ImmutableFakeVersionedItem.java new file mode 100644 index 000000000000..4d8f3f8e98ee --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/ImmutableFakeVersionedItem.java @@ -0,0 +1,122 @@ +/* + * 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 = ImmutableFakeVersionedItem.Builder.class) +public class ImmutableFakeVersionedItem { + private final String id; + private final String attribute; + private final long version; + + private ImmutableFakeVersionedItem(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; + } + ImmutableFakeVersionedItem that = (ImmutableFakeVersionedItem) 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(ImmutableFakeVersionedItem.class, ImmutableFakeVersionedItem.Builder.class) + .newItemBuilder(ImmutableFakeVersionedItem::builder, ImmutableFakeVersionedItem.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(ImmutableFakeVersionedItem::id) + .setter(ImmutableFakeVersionedItem.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(Long.class, a -> a.name("version") + .getter(ImmutableFakeVersionedItem::version) + .setter(ImmutableFakeVersionedItem.Builder::version) + .tags(versionAttribute())) + .addAttribute(String.class, a -> a.name("attribute") + .getter(ImmutableFakeVersionedItem::attribute) + .setter(ImmutableFakeVersionedItem.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 ImmutableFakeVersionedItem build() { + return new ImmutableFakeVersionedItem(this); + } + } +} From 4de6ae4be10eac206ce566b7f22adc6e9d668e0c Mon Sep 17 00:00:00 2001 From: Ran Vaknin Date: Tue, 6 May 2025 16:38:51 -0700 Subject: [PATCH 10/11] adding annotation test --- .../VersionedRecordExtensionTest.java | 46 ++++++++++--- .../models/FakeVersionedImmutableItem.java | 64 +++++++++++++++++++ ... => FakeVersionedStaticImmutableItem.java} | 33 +++++----- 3 files changed, 116 insertions(+), 27 deletions(-) create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/FakeVersionedImmutableItem.java rename services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/{ImmutableFakeVersionedItem.java => FakeVersionedStaticImmutableItem.java} (76%) 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 f51f3d9ef398..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 @@ -30,18 +30,15 @@ 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.AttributeConverterProvider; -import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType; 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.document.DocumentTableSchema; -import software.amazon.awssdk.enhanced.dynamodb.document.EnhancedDocument; 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.ImmutableFakeVersionedItem; +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; @@ -529,24 +526,24 @@ public void customIncrementForExistingVersion_withImmutableSchema_worksAsExpecte } VersionedRecordExtension recordExtension = recordExtensionBuilder.build(); - ImmutableFakeVersionedItem fakeItem = ImmutableFakeVersionedItem + FakeVersionedStaticImmutableItem fakeItem = FakeVersionedStaticImmutableItem .builder() .id(UUID.randomUUID().toString()) .version(existingVersion) .build(); Map inputMap = - new HashMap<>(ImmutableFakeVersionedItem.getTableSchema().itemToMap(fakeItem, true)); + new HashMap<>(FakeVersionedStaticImmutableItem.getTableSchema().itemToMap(fakeItem, true)); Map expectedVersionedItem = - new HashMap<>(ImmutableFakeVersionedItem.getTableSchema().itemToMap(fakeItem, true)); + new HashMap<>(FakeVersionedStaticImmutableItem.getTableSchema().itemToMap(fakeItem, true)); expectedVersionedItem.put("version", AttributeValue.builder().n(expectedNextVersion).build()); WriteModification result = recordExtension.beforeWrite(DefaultDynamoDbExtensionContext .builder() .items(inputMap) - .tableMetadata(ImmutableFakeVersionedItem.getTableMetadata()) + .tableMetadata(FakeVersionedStaticImmutableItem.getTableMetadata()) .operationContext(PRIMARY_CONTEXT).build()); assertThat(result.transformedItem(), is(expectedVersionedItem)); @@ -554,6 +551,37 @@ public void customIncrementForExistingVersion_withImmutableSchema_worksAsExpecte 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"), 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/ImmutableFakeVersionedItem.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/FakeVersionedStaticImmutableItem.java similarity index 76% rename from services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/ImmutableFakeVersionedItem.java rename to services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/FakeVersionedStaticImmutableItem.java index 4d8f3f8e98ee..527991276146 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/ImmutableFakeVersionedItem.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/FakeVersionedStaticImmutableItem.java @@ -25,13 +25,13 @@ import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -@DynamoDbImmutable(builder = ImmutableFakeVersionedItem.Builder.class) -public class ImmutableFakeVersionedItem { +@DynamoDbImmutable(builder = FakeVersionedStaticImmutableItem.Builder.class) +public class FakeVersionedStaticImmutableItem { private final String id; private final String attribute; private final long version; - private ImmutableFakeVersionedItem(Builder b) { + private FakeVersionedStaticImmutableItem(Builder b) { this.id = b.id; this.attribute = b.attribute; this.version = b.version; @@ -62,7 +62,7 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) { return false; } - ImmutableFakeVersionedItem that = (ImmutableFakeVersionedItem) o; + FakeVersionedStaticImmutableItem that = (FakeVersionedStaticImmutableItem) o; return version == that.version && Objects.equals(id, that.id) && Objects.equals(attribute, that.attribute); } @@ -71,26 +71,23 @@ public int hashCode() { return Objects.hash(id, attribute, version); } - public static TableSchema getTableSchema() { - return StaticImmutableTableSchema.builder(ImmutableFakeVersionedItem.class, ImmutableFakeVersionedItem.Builder.class) - .newItemBuilder(ImmutableFakeVersionedItem::builder, ImmutableFakeVersionedItem.Builder::build) + 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(ImmutableFakeVersionedItem::id) - .setter(ImmutableFakeVersionedItem.Builder::id) + .getter(FakeVersionedStaticImmutableItem::id) + .setter(FakeVersionedStaticImmutableItem.Builder::id) .tags(primaryPartitionKey())) .addAttribute(Long.class, a -> a.name("version") - .getter(ImmutableFakeVersionedItem::version) - .setter(ImmutableFakeVersionedItem.Builder::version) + .getter(FakeVersionedStaticImmutableItem::version) + .setter(FakeVersionedStaticImmutableItem.Builder::version) .tags(versionAttribute())) .addAttribute(String.class, a -> a.name("attribute") - .getter(ImmutableFakeVersionedItem::attribute) - .setter(ImmutableFakeVersionedItem.Builder::attribute)) + .getter(FakeVersionedStaticImmutableItem::attribute) + .setter(FakeVersionedStaticImmutableItem.Builder::attribute)) .build(); } - - - public static TableMetadata getTableMetadata() { return getTableSchema().tableMetadata(); } @@ -115,8 +112,8 @@ public Builder attribute(String attribute) { return this; } - public ImmutableFakeVersionedItem build() { - return new ImmutableFakeVersionedItem(this); + public FakeVersionedStaticImmutableItem build() { + return new FakeVersionedStaticImmutableItem(this); } } } From 48fec4966f4794d473575ae976f8b762098a1f6f Mon Sep 17 00:00:00 2001 From: RanVaknin <50976344+RanVaknin@users.noreply.github.com> Date: Mon, 2 Jun 2025 15:25:50 -0700 Subject: [PATCH 11/11] adding localstack test, removing optional from function signature --- .../awssdk/enhanced/dynamodb/Expression.java | 5 - .../extensions/VersionedRecordExtension.java | 38 +++--- .../functionaltests/VersionedRecordTest.java | 116 ++++++++++++++++++ 3 files changed, 133 insertions(+), 26 deletions(-) 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 c521e1f5d958..84e424bb0ae2 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 @@ -314,11 +314,6 @@ public int hashCode() { @Override public String toString() { - // return "Expression{" + - // "expression='" + expression + '\'' + - // ", expressionValues=" + expressionValues + - // ", expressionNames=" + expressionNames + - // '}'; return ToString.builder("Expression") .add("expression", expression) .add("expressionValues", expressionValues) 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 9c9e48f9e739..9aefea37014a 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 @@ -146,43 +146,40 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex String attributeKeyRef = keyRef(versionAttributeKey.get()); AttributeValue newVersionValue; Expression condition; - Optional existingVersionValue = - Optional.ofNullable(itemToTransform.get(versionAttributeKey.get())); - Optional versionStartAtFromAnnotation = context.tableMetadata() + AttributeValue existingVersionValue = itemToTransform.get(versionAttributeKey.get()); + Long versionStartAtFromAnnotation = context.tableMetadata() .customMetadataObject(VersionAttribute.START_AT_METADATA_KEY, - Long.class); - - Optional versionIncrementByFromAnnotation = context.tableMetadata() + Long.class).orElse(this.startAt); + Long 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); + Long.class).orElse(this.incrementBy); - newVersionValue = AttributeValue.builder().n(Long.toString(startValue + increment)).build(); + + if (isInitialVersion(existingVersionValue, versionStartAtFromAnnotation)) { + newVersionValue = AttributeValue.builder().n(Long.toString(versionStartAtFromAnnotation + versionIncrementByFromAnnotation)).build(); condition = Expression.builder() .expression(String.format("attribute_not_exists(%s)", attributeKeyRef)) .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get())) .build(); } else { // Existing record, increment version - if (existingVersionValue.get().n() == null) { + if (existingVersionValue.n() == null) { // In this case a non-null version attribute is present, but it's not an N throw new IllegalArgumentException("Version attribute appears to be the wrong type. N is required."); } - long existingVersion = Long.parseLong(existingVersionValue.get().n()); + long existingVersion = Long.parseLong(existingVersionValue.n()); String existingVersionValueKey = VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER.apply(versionAttributeKey.get()); - long increment = versionIncrementByFromAnnotation.orElse(this.incrementBy); + long increment = versionIncrementByFromAnnotation; 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())) .expressionValues(Collections.singletonMap(existingVersionValueKey, - existingVersionValue.get())) + existingVersionValue)) .build(); } @@ -194,15 +191,14 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex .build(); } - private boolean isInitialVersion(Optional existingVersionValue, Optional versionStartAtFromAnnotation) { - if (!existingVersionValue.isPresent() || isNullAttributeValue(existingVersionValue.get())) { + private boolean isInitialVersion(AttributeValue existingVersionValue, Long versionStartAtFromAnnotation) { + if (existingVersionValue == null || isNullAttributeValue(existingVersionValue)) { return true; } - AttributeValue value = existingVersionValue.get(); - if (value.n() != null) { - long currentVersion = Long.parseLong(value.n()); - return (versionStartAtFromAnnotation.isPresent() && currentVersion == versionStartAtFromAnnotation.get()) + if (existingVersionValue.n() != null) { + long currentVersion = Long.parseLong(existingVersionValue.n()); + return (versionStartAtFromAnnotation != null && currentVersion == versionStartAtFromAnnotation) || currentVersion == this.startAt; } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/VersionedRecordTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/VersionedRecordTest.java index 198bb1c20fe0..32cbfd5332a4 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/VersionedRecordTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/VersionedRecordTest.java @@ -32,7 +32,10 @@ import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; @@ -103,19 +106,77 @@ public int hashCode() { .tags(versionAttribute())) .build(); + @DynamoDbBean + public static class AnnotatedRecord { + private String id; + private String attribute; + private Long version; + + public AnnotatedRecord() { + } + + @DynamoDbPartitionKey + public String getId() { return id; } + public AnnotatedRecord setId(String id) { + this.id = id; + return this; + } + + @DynamoDbVersionAttribute(startAt = 5, incrementBy = 3) + public Long getVersion() { return version; } + public AnnotatedRecord setVersion(Long version) { + this.version = version; + return this; + } + + public String getAttribute() { + return attribute; + } + + public AnnotatedRecord setAttribute(String attribute) { + this.attribute = attribute; + return this; + } + } + private DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() .dynamoDbClient(getDynamoDbClient()) .extensions(VersionedRecordExtension.builder().build()) .build(); + private DynamoDbEnhancedClient customVersionedEnhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(VersionedRecordExtension + .builder() + .incrementBy(2L) + .startAt(10L) + .build() + ) + .build(); + private DynamoDbTable mappedTable = enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + private DynamoDbTable mappedCustomVersionedTable = customVersionedEnhancedClient + .table(getConcreteTableName("table-name2"), TABLE_SCHEMA); + + + private static final TableSchema ANNOTATED_TABLE_SCHEMA = + TableSchema.fromBean(AnnotatedRecord.class); + + private DynamoDbTable annotatedTable = enhancedClient + .table(getConcreteTableName("annotated-table"), ANNOTATED_TABLE_SCHEMA); + + + + @Rule public ExpectedException exception = ExpectedException.none(); @Before public void createTable() { mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + mappedCustomVersionedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + annotatedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); } @After @@ -123,8 +184,18 @@ public void deleteTable() { getDynamoDbClient().deleteTable(DeleteTableRequest.builder() .tableName(getConcreteTableName("table-name")) .build()); + + getDynamoDbClient().deleteTable(DeleteTableRequest.builder() + .tableName(getConcreteTableName("table-name2")) + .build()); + + getDynamoDbClient().deleteTable(DeleteTableRequest.builder() + .tableName(getConcreteTableName("annotated-table")) + .build()); } + + @Test public void putNewRecordSetsInitialVersion() { mappedTable.putItem(r -> r.item(new Record().setId("id").setAttribute("one"))); @@ -291,4 +362,49 @@ public void putRecordWithWrongVersionNumber() { mappedTable.putItem(r -> r.item(new Record().setId("id").setAttribute("one"))); mappedTable.putItem(r -> r.item(new Record().setId("id").setAttribute("one").setVersion(2))); } + + @Test + public void updateVersionIncrementByExpected() { + mappedCustomVersionedTable.putItem(r -> r.item(new Record().setId("id").setAttribute("one"))); + + Record currentRecord = mappedCustomVersionedTable.getItem(r -> r.key(k -> k.partitionValue("id"))); + + Record result = mappedCustomVersionedTable.updateItem(r -> r.item(new Record() + .setId("id") + .setAttribute("two") + .setVersion(currentRecord.getVersion()))); + + Record expectedResult = new Record().setId("id").setAttribute("two").setVersion(14); + assertThat(result.getVersion(), is(expectedResult.getVersion())); + } + + @Test + public void customStartAtValueIsUsedForFirstRecord() { + mappedCustomVersionedTable.putItem(r -> r.item(new Record().setId("custom-start").setAttribute("test"))); + + Record record = mappedCustomVersionedTable.getItem(r -> r.key(k -> k.partitionValue("custom-start"))); + assertThat(record.getVersion(), is(12)); + } + + @Test(expected = ConditionalCheckFailedException.class) + public void recordWithVersionBetweenStartAtAndFirstVersionFails() { + Record invalidRecord = new Record().setId("invalid-version").setAttribute("test").setVersion(11); + mappedCustomVersionedTable.putItem(r -> r.item(invalidRecord)); + } + + @Test + public void annotationBasedCustomVersioningWorks() { + annotatedTable.putItem(r -> r.item(new AnnotatedRecord().setAttribute("test").setId("annotated"))); + + AnnotatedRecord result = annotatedTable.getItem(r -> r.key(k -> k.partitionValue("annotated"))); + + assertThat(result.getVersion(), is(8L)); + + AnnotatedRecord updated = annotatedTable.updateItem(r -> r.item(new AnnotatedRecord() + .setId("annotated") + .setAttribute("updated") + .setVersion(8L))); + + assertThat(updated.getVersion(), is(11L)); + } }