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 e9e4b85eab54..e693cd3cda9c 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 @@ -35,7 +35,6 @@ import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.utils.Validate; /** * This extension implements optimistic locking on record writes by means of a 'record version number' that is used @@ -64,18 +63,28 @@ public final class VersionedRecordExtension implements DynamoDbEnhancedClientExt private static final String CUSTOM_METADATA_KEY = "VersionedRecordExtension:VersionAttribute"; private static final VersionAttribute VERSION_ATTRIBUTE = new VersionAttribute(); + private final Long initialValue; private final long startAt; private final long incrementBy; - private VersionedRecordExtension(Long startAt, Long incrementBy) { - Validate.isNotNegativeOrNull(startAt, "startAt"); + private VersionedRecordExtension(Long startAt, Long incrementBy, Long initialValue) { + this.startAt = startAt != null ? startAt : -1L; + this.incrementBy = incrementBy != null ? incrementBy : 1L; + this.initialValue = initialValue; + if (initialValue != null && initialValue < 0) { + throw new IllegalArgumentException("initialValue must be non-negative."); + } + if (this.startAt != -1 && this.startAt < 0) { + throw new IllegalArgumentException("startAt must be non-negative when not -1."); + } + if (this.startAt >= 0 && initialValue != null) { + throw new IllegalArgumentException( + "Cannot set both startAt and initialValue. Use startAt=-1 with initialValue."); + } 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() { @@ -90,26 +99,35 @@ public static StaticAttributeTag versionAttribute() { return VERSION_ATTRIBUTE; } + @Deprecated public static StaticAttributeTag versionAttribute(Long startAt, Long incrementBy) { - return new VersionAttribute(startAt, incrementBy); + return versionAttribute(startAt, incrementBy, null); + } + + public static StaticAttributeTag versionAttribute(Long startAt, Long incrementBy, Long initialValue) { + return new VersionAttribute(startAt, incrementBy, initialValue); } } 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 static final String INITIAL_VALUE_METADATA_KEY = "VersionedRecordExtension:InitialValue"; private final Long startAt; private final Long incrementBy; + private final Long initialValue; private VersionAttribute() { this.startAt = null; this.incrementBy = null; + this.initialValue = null; } - private VersionAttribute(Long startAt, Long incrementBy) { + private VersionAttribute(Long startAt, Long incrementBy, Long initialValue) { this.startAt = startAt; this.incrementBy = incrementBy; + this.initialValue = initialValue; } @Override @@ -121,7 +139,9 @@ public Consumer modifyMetadata(String attributeName "is supported.", attributeName, attributeValueType.name())); } - Validate.isNotNegativeOrNull(startAt, "startAt"); + if (startAt != null && startAt != -1 && startAt < 0) { + throw new IllegalArgumentException("startAt must be non-negative or -1."); + } if (incrementBy != null && incrementBy < 1) { throw new IllegalArgumentException("incrementBy must be greater than 0."); @@ -130,6 +150,7 @@ public Consumer modifyMetadata(String attributeName return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, attributeName) .addCustomMetadataObject(START_AT_METADATA_KEY, startAt) .addCustomMetadataObject(INCREMENT_BY_METADATA_KEY, incrementBy) + .addCustomMetadataObject(INITIAL_VALUE_METADATA_KEY, initialValue) .markAttributeAsKey(attributeName, attributeValueType); } } @@ -154,22 +175,32 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex .customMetadataObject(VersionAttribute.START_AT_METADATA_KEY, Long.class) .orElse(this.startAt); Long versionIncrementByFromAnnotation = context.tableMetadata() - .customMetadataObject(VersionAttribute.INCREMENT_BY_METADATA_KEY, Long.class) - .orElse(this.incrementBy); - + .customMetadataObject(VersionAttribute.INCREMENT_BY_METADATA_KEY, + Long.class) + .orElse(this.incrementBy); + Long initialValueFromAnnotation = context.tableMetadata() + .customMetadataObject(VersionAttribute.INITIAL_VALUE_METADATA_KEY, Long.class) + .orElse(this.initialValue); if (existingVersionValue == null || isNullAttributeValue(existingVersionValue)) { - newVersionValue = AttributeValue.builder() - .n(Long.toString(versionStartAtFromAnnotation + versionIncrementByFromAnnotation)) - .build(); + if (versionStartAtFromAnnotation == -1) { + long effectiveInitialValue = initialValueFromAnnotation != null + ? initialValueFromAnnotation + : versionIncrementByFromAnnotation; + newVersionValue = AttributeValue.builder() + .n(Long.toString(effectiveInitialValue)) + .build(); + } else { + 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.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."); } @@ -189,22 +220,35 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex newVersionValue = AttributeValue.builder().n(Long.toString(existingVersion + increment)).build(); - // When version equals startAt, we can't distinguish between new and existing records - // Use OR condition to handle both cases - if (existingVersion == versionStartAtFromAnnotation) { + boolean needsOrCondition; + if (versionStartAtFromAnnotation == -1) { + // New: OR condition needed for effectiveInitialValue or 0 + long effectiveInitialValue = initialValueFromAnnotation != null + ? initialValueFromAnnotation + : versionIncrementByFromAnnotation; + needsOrCondition = (existingVersion == effectiveInitialValue || existingVersion == 0); + } else { + // Legacy: OR condition needed when version equals startAt + needsOrCondition = (existingVersion == versionStartAtFromAnnotation); + } + + if (needsOrCondition) { condition = Expression.builder() .expression(String.format("attribute_not_exists(%s) OR %s = %s", - attributeKeyRef, attributeKeyRef, existingVersionValueKey)) - .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get())) - .expressionValues(Collections.singletonMap(existingVersionValueKey, + attributeKeyRef, + attributeKeyRef, + existingVersionValueKey)) + .expressionNames(Collections.singletonMap(attributeKeyRef, + versionAttributeKey.get())) + .expressionValues(Collections.singletonMap(existingVersionValueKey, existingVersionValue)) .build(); } else { - // Normal case - version doesn't equal startAt, must be existing record condition = Expression.builder() .expression(String.format("%s = %s", attributeKeyRef, existingVersionValueKey)) - .expressionNames(Collections.singletonMap(attributeKeyRef, versionAttributeKey.get())) - .expressionValues(Collections.singletonMap(existingVersionValueKey, + .expressionNames(Collections.singletonMap(attributeKeyRef, + versionAttributeKey.get())) + .expressionValues(Collections.singletonMap(existingVersionValueKey, existingVersionValue)) .build(); } @@ -222,17 +266,23 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex public static final class Builder { private Long startAt; private Long incrementBy; + private Long initialValue; private Builder() { } /** * Sets the startAt used to compare if a record is the initial version of a record. - * Default value - {@code 0}. + * When startAt >= 0: First version = startAt + incrementBy. + * Default value when not set: {@code -1}, which enables {@link #initialValue(Long)} behavior. + *

+ * Cannot be used with {@link #initialValue(Long)} - setting both will throw IllegalArgumentException. * - * @param startAt the starting value for version comparison, must not be negative + * @param startAt the starting value for version comparison. When null, defaults to -1. Must be -1 or positive number. * @return the builder instance + * @deprecated Use {@link #initialValue(Long)} instead. */ + @Deprecated public Builder startAt(Long startAt) { this.startAt = startAt; return this; @@ -250,8 +300,29 @@ public Builder incrementBy(Long incrementBy) { return this; } + /** + * Sets the initial version value for new records. + * Default value - {@code null} (derives from incrementBy for backwards compatibility). + *

+ * Behavior: + *

+ *

+ * Cannot be used with deprecated {@link #startAt(Long)} when startAt >= 0. + * + * @param initialValue the initial version for new records, must be a positive number + * @return the builder instance + * @throws IllegalArgumentException if initialValue is negative or if startAt >= 0 is also set + */ + public Builder initialValue(Long initialValue) { + this.initialValue = initialValue; + return this; + } + public VersionedRecordExtension build() { - return new VersionedRecordExtension(this.startAt, this.incrementBy); + return new VersionedRecordExtension(this.startAt, this.incrementBy, this.initialValue); } } } 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 09ab6eb00159..dfb1fa46d301 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 @@ -27,6 +27,11 @@ * Denotes this attribute as recording the version record number to be used for optimistic locking. Every time a record * with this attribute is written to the database it will be incremented and a condition added to the request to check * for an exact match of the old version. + *

+ * Default behavior: startAt=-1, incrementBy=1, initialValue=1. First version will be 1. + *

+ * See {@link software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension.Builder#initialValue(Long)} + * for details on ambiguity handling and SDK v1 migration support. */ @SdkPublicApi @Target({ElementType.METHOD}) @@ -35,18 +40,30 @@ public @interface DynamoDbVersionAttribute { /** * The starting value for the version attribute. - * Default value - {@code 0}. + * Default value when not set: {@code -1}, which enables {@link #initialValue()} behavior. + *

+ * Cannot be used with {@link #initialValue()} - setting both will throw IllegalArgumentException. * - * @return the starting value + * @return the starting value, must be -1 or non-negative + * @deprecated Use {@link #initialValue()} instead. */ - long startAt() default 0; + @Deprecated + long startAt() default -1; /** * The amount to increment the version by with each update. * Default value - {@code 1}. * - * @return the increment value + * @return the increment value, must be greater than 0 */ long incrementBy() default 1; + /** + * The initial version value for new records. + * Default value - {@code 1}. + * Cannot be used with deprecated {@link #startAt()} when startAt >= 0. + * + * @return the initial version for new records, must be non-negative + */ + long initialValue() 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 d81cf268afff..a4d8204eb78e 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,7 @@ private VersionRecordAttributeTags() { } public static StaticAttributeTag attributeTagFor(DynamoDbVersionAttribute annotation) { - return VersionedRecordExtension.AttributeTags.versionAttribute(annotation.startAt(), annotation.incrementBy()); + return VersionedRecordExtension.AttributeTags.versionAttribute(annotation.startAt(), annotation.incrementBy(), + annotation.initialValue()); } } 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 30d3dd796693..d3cc476f4bdd 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 @@ -612,7 +612,6 @@ public void isInitialVersion_shouldPrioritizeAnnotationValueOverBuilderValue() { .startAt(5L) .build(); - // FakeVersionedThroughAnnotationItem value for startAt is 3, which would conflict with builder value of 5. FakeVersionedThroughAnnotationItem item = new FakeVersionedThroughAnnotationItem(); item.setId(UUID.randomUUID().toString()); @@ -653,6 +652,99 @@ public void updateItem_existingRecordWithVersionEqualToStartAt_shouldSucceed() { is("attribute_not_exists(#AMZN_MAPPED_version) OR #AMZN_MAPPED_version = :old_version_value")); } + @Test + public void updateItem_versionMatchesInitialValue_shouldUseOrCondition() { + VersionedRecordExtension recordExtension = VersionedRecordExtension.builder() + .initialValue(5L) + .build(); + FakeItem item = createUniqueFakeItem(); + item.setVersion(5); + + Map inputMap = new HashMap<>(FakeItem.getTableSchema().itemToMap(item, 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) OR #AMZN_MAPPED_version = :old_version_value")); + } + + @Test + public void putItem_nullInitialValue_firstVersionEqualsIncrementBy() { + VersionedRecordExtension recordExtension = VersionedRecordExtension.builder() + .incrementBy(3L) + .build(); + FakeItem item = createUniqueFakeItem(); + item.setVersion(null); + + Map inputMap = new HashMap<>(FakeItem.getTableSchema().itemToMap(item, true)); + + WriteModification result = + recordExtension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(inputMap) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result.transformedItem().get("version").n(), is("3")); + } + + @Test + public void putItem_customInitialValue_firstVersionEqualsInitialValue() { + VersionedRecordExtension recordExtension = VersionedRecordExtension.builder() + .incrementBy(3L) + .initialValue(7L) + .build(); + FakeItem item = createUniqueFakeItem(); + item.setVersion(null); + + Map inputMap = new HashMap<>(FakeItem.getTableSchema().itemToMap(item, true)); + + WriteModification result = + recordExtension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(inputMap) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result.transformedItem().get("version").n(), is("7")); + } + + @Test(expected = IllegalArgumentException.class) + public void builder_negativeInitialValue_shouldThrow() { + VersionedRecordExtension.builder().initialValue(-1L).build(); + } + + @Test(expected = IllegalArgumentException.class) + public void builder_conflictingStartAtAndInitialValue_shouldThrow() { + VersionedRecordExtension.builder().startAt(0L).initialValue(5L).build(); + } + + @Test + public void updateItem_versionDoesNotMatchInitialValue_shouldUseEqualityCondition() { + VersionedRecordExtension recordExtension = VersionedRecordExtension.builder() + .initialValue(5L) + .incrementBy(2L) + .build(); + FakeItem item = createUniqueFakeItem(); + item.setVersion(10); + + Map inputMap = new HashMap<>(FakeItem.getTableSchema().itemToMap(item, true)); + + WriteModification result = + recordExtension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(inputMap) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result.additionalConditionalExpression().expression(), + is("#AMZN_MAPPED_version = :old_version_value")); + } public static Stream customIncrementForExistingVersionValues() { return Stream.of( 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 6d3d0abcd670..76568fb23a04 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 @@ -580,4 +580,107 @@ public void deleteItem_annotationConfigWithVersionEqualToStartAt_shouldSucceed() AnnotatedRecord shouldBeNull = annotatedTable.getItem(r -> r.key(k -> k.partitionValue("delete-annotation"))); assertThat(shouldBeNull, is(nullValue())); } + + @Test + public void putItem_customInitialValue_firstVersionEqualsInitialValue() { + DynamoDbEnhancedClient clientWithInitialValue = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(VersionedRecordExtension + .builder() + .incrementBy(2L) + .initialValue(10L) + .build()) + .build(); + DynamoDbTable table = clientWithInitialValue.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + + Record record = new Record().setId("initial-value-test").setAttribute("test"); + table.putItem(record); + + Record retrieved = table.getItem(r -> r.key(k -> k.partitionValue("initial-value-test"))); + assertThat(retrieved.getVersion(), is(10)); + } + + @Test + public void updateItem_customInitialValue_versionMatchesInitialValue_shouldSucceed() { + DynamoDbEnhancedClient clientWithInitialValue = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(VersionedRecordExtension + .builder() + .incrementBy(2L) + .initialValue(10L) + .build()) + .build(); + DynamoDbTable table = clientWithInitialValue.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + + Record record = new Record().setId("initial-value-or").setAttribute("first").setVersion(10); + table.updateItem(record); + + Record retrieved = table.getItem(r -> r.key(k -> k.partitionValue("initial-value-or"))); + assertThat(retrieved.getAttribute(), is("first")); + assertThat(retrieved.getVersion(), is(12)); + } + + @Test + public void putItem_nullInitialValue_firstVersionEqualsIncrementBy() { + DynamoDbEnhancedClient clientWithNullInitialValue = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(VersionedRecordExtension + .builder() + .incrementBy(5L) + .build()) + .build(); + DynamoDbTable table = clientWithNullInitialValue.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + + Record record = new Record().setId("null-initial-value").setAttribute("test"); + table.putItem(record); + + Record retrieved = table.getItem(r -> r.key(k -> k.partitionValue("null-initial-value"))); + assertThat(retrieved.getVersion(), is(5)); + } + + @Test + public void putItem_explicitStartAtNegativeOne_usesInitialValue() { + DynamoDbEnhancedClient clientWithExplicitStartAt = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(VersionedRecordExtension + .builder() + .startAt(-1L) + .initialValue(7L) + .incrementBy(2L) + .build()) + .build(); + DynamoDbTable table = clientWithExplicitStartAt.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + + Record record = new Record().setId("explicit-start-at").setAttribute("test"); + table.putItem(record); + + Record retrieved = table.getItem(r -> r.key(k -> k.partitionValue("explicit-start-at"))); + assertThat(retrieved.getVersion(), is(7)); + } + + @Test + public void updateItem_versionDoesNotMatchInitialValue_shouldSucceed() { + DynamoDbEnhancedClient clientWithInitialValue = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(VersionedRecordExtension + .builder() + .initialValue(5L) + .incrementBy(2L) + .build()) + .build(); + DynamoDbTable table = clientWithInitialValue.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + + Record first = new Record().setId("equality-test").setAttribute("first"); + table.putItem(first); + + Record retrieved = table.getItem(r -> r.key(k -> k.partitionValue("equality-test"))); + assertThat(retrieved.getVersion(), is(5)); + + retrieved.setAttribute("second"); + table.updateItem(retrieved); + + Record updated = table.getItem(r -> r.key(k -> k.partitionValue("equality-test"))); + assertThat(updated.getAttribute(), is("second")); + assertThat(updated.getVersion(), is(7)); + } }