From ecb48de909a975d62f7def858acce73ae1eff578 Mon Sep 17 00:00:00 2001 From: Andy Kiesler Date: Sat, 9 Dec 2023 10:31:26 -0800 Subject: [PATCH 1/2] Refactor DynamoDB Enhanced VersionedRecordExtension internal logic The intent is to breakdown the internal logic into smaller methods to make the overall flow more obvious to other developers. --- .../extensions/VersionedRecordExtension.java | 100 ++++++++++++------ 1 file changed, 69 insertions(+), 31 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 34a6396c5109..622836af8731 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 @@ -101,39 +102,13 @@ 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.items()); - 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,6 +117,69 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex .build(); } + private Pair getRecordUpdates(String versionAttributeKey, + Map itemToTransform) { + Optional existingVersionValue = + Optional.ofNullable(itemToTransform.get(versionAttributeKey)); + + if (isInitialVersion(existingVersionValue)) { + // First version of the record ensure it does not exist + return createInitialRecord(versionAttributeKey); + } + // Existing record, increment version + return updateExistingRecord(versionAttributeKey, existingVersionValue.get()); + } + + private boolean isInitialVersion(Optional existingVersionValue) { + return !existingVersionValue.isPresent() || isNullAttributeValue(existingVersionValue.get()); + } + + private Pair createInitialRecord(String versionAttributeKey) { + AttributeValue newVersionValue = incrementVersion(0); + + 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) { + int existingVersion = getExistingVersion(existingVersionValue); + AttributeValue newVersionValue = incrementVersion(existingVersion); + + 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) { + return AttributeValue.fromN(Integer.toString(version + 1)); + } + @NotThreadSafe public static final class Builder { private Builder() { From 0af21cbc1d45c6d39bca09156765c55b45433c83 Mon Sep 17 00:00:00 2001 From: Andy Kiesler Date: Thu, 5 Sep 2024 10:47:01 -0700 Subject: [PATCH 2/2] Add support for starting DynamoDB Record Version with explicit value The prior behavior required that a version be initialized with a null value, this required mapper clients to use Integer instead of the int primitive. This change allows clients to explicitly initialize the version to a value which makes it simpler for clients to use primitive values and potentially avoid null pointer exceptions and checks. The default starting value of 0 and increment value of 1 are intended to provide sane defaults that are identical to the existing behavior while enabling clients to have more fine-graned control over how the versioning is managed for their specific use-cases. The current implementation configures the values at the extension level only but the implementation can be expanded to gather the value from the model annotation to customize the values on a per table basis. --- ...-AmazonDyanmoDBEnhancedClient-2047f83.json | 6 ++ .../awssdk/enhanced/dynamodb/Expression.java | 9 +++ .../extensions/VersionedRecordExtension.java | 60 +++++++++++++++---- .../VersionedRecordExtensionTest.java | 27 +++++++++ 4 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 .changes/next-release/feature-AmazonDyanmoDBEnhancedClient-2047f83.json diff --git a/.changes/next-release/feature-AmazonDyanmoDBEnhancedClient-2047f83.json b/.changes/next-release/feature-AmazonDyanmoDBEnhancedClient-2047f83.json new file mode 100644 index 000000000000..9a572ea45ef6 --- /dev/null +++ b/.changes/next-release/feature-AmazonDyanmoDBEnhancedClient-2047f83.json @@ -0,0 +1,6 @@ +{ + "category": "Amazon DyanmoDB Enhanced Client", + "contributor": "kiesler", + "type": "feature", + "description": "DynamoDB Enhanced Client Versioned Record can start at 0" +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/Expression.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/Expression.java index fa0f69ad9ed3..5a4d9e454e47 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/Expression.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/Expression.java @@ -311,6 +311,15 @@ public int hashCode() { return result; } + @Override + public String toString() { + return "Expression{" + + "expression='" + expression + '\'' + + ", expressionValues=" + expressionValues + + ", expressionNames=" + expressionNames + + '}'; + } + /** * A builder for {@link Expression} */ diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/VersionedRecordExtension.java index 622836af8731..b41a6335cd2e 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 @@ -61,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 VersionedRecordExtension() { + private static final AttributeValue DEFAULT_VALUE = AttributeValue.fromNul(Boolean.TRUE); + + private final int startingValue; + private final int increment; + + /** + * Creates a new {@link VersionedRecordExtension} using the supplied starting and incrementing value. + * + * @param startingValue the value used to compare if a record is the initial version of a record. + * @param increment the amount to increment the version by with each subsequent update. + */ + private VersionedRecordExtension(int startingValue, int increment) { + this.startingValue = startingValue; + this.increment = increment; } public static Builder builder() { @@ -119,23 +131,24 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex private Pair getRecordUpdates(String versionAttributeKey, Map itemToTransform) { - Optional existingVersionValue = - Optional.ofNullable(itemToTransform.get(versionAttributeKey)); + // Default to NUL if not present to reduce additional checks further along + AttributeValue existingVersionValue = itemToTransform.getOrDefault(versionAttributeKey, DEFAULT_VALUE); if (isInitialVersion(existingVersionValue)) { // First version of the record ensure it does not exist return createInitialRecord(versionAttributeKey); } // Existing record, increment version - return updateExistingRecord(versionAttributeKey, existingVersionValue.get()); + return updateExistingRecord(versionAttributeKey, existingVersionValue); } - private boolean isInitialVersion(Optional existingVersionValue) { - return !existingVersionValue.isPresent() || isNullAttributeValue(existingVersionValue.get()); + private boolean isInitialVersion(AttributeValue existingVersionValue) { + return isNullAttributeValue(existingVersionValue) + || getExistingVersion(existingVersionValue) == this.startingValue; } private Pair createInitialRecord(String versionAttributeKey) { - AttributeValue newVersionValue = incrementVersion(0); + AttributeValue newVersionValue = incrementVersion(this.startingValue); String attributeKeyRef = keyRef(versionAttributeKey); @@ -177,16 +190,43 @@ private int getExistingVersion(AttributeValue existingVersionValue) { } private AttributeValue incrementVersion(int version) { - return AttributeValue.fromN(Integer.toString(version + 1)); + return AttributeValue.fromN(Integer.toString(version + this.increment)); } @NotThreadSafe public static final class Builder { + private int startingValue = 0; + private int increment = 1; + private Builder() { } + /** + * Sets the startingValue used to compare if a record is the initial version of a record. + * Default value - {@code 0}. + * + * @param startingValue + * @return the builder instance + */ + public Builder startAt(int startingValue) { + this.startingValue = startingValue; + return this; + } + + /** + * Sets the amount to increment the version by with each subsequent update. + * Default value - {@code 1}. + * + * @param increment + * @return the builder instance + */ + public Builder incrementBy(int increment) { + this.increment = increment; + return this; + } + public VersionedRecordExtension build() { - return new VersionedRecordExtension(); + return new VersionedRecordExtension(this.startingValue, this.increment); } } } 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..2d215721653e 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 @@ -112,6 +112,33 @@ public void beforeWrite_initialVersionDueToExplicitNull_transformedItemIsCorrect assertThat(result.transformedItem(), is(fakeItemWithInitialVersion)); } + @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();