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..907ef521b1ec 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 @@ -55,6 +54,10 @@ * Then, whenever a record is written the write operation will only succeed if the version number of the record has not * been modified since it was last read by the application. Every time a new version of the record is successfully * written to the database, the record version number will be automatically incremented. + *

+ * Version Calculation: The first version written to a new record is calculated as {@code startAt + incrementBy}. + * For example, with {@code startAt=0} and {@code incrementBy=1} (defaults), the first version is 1. + * To start versioning from 0, use {@code startAt=-1} and {@code incrementBy=1}, which produces first version = 0. */ @SdkPublicApi @ThreadSafe @@ -68,7 +71,9 @@ public final class VersionedRecordExtension implements DynamoDbEnhancedClientExt private final long incrementBy; private VersionedRecordExtension(Long startAt, Long incrementBy) { - Validate.isNotNegativeOrNull(startAt, "startAt"); + if (startAt != null && startAt < -1) { + throw new IllegalArgumentException("startAt must be -1 or greater"); + } if (incrementBy != null && incrementBy < 1) { throw new IllegalArgumentException("incrementBy must be greater than 0."); @@ -121,7 +126,9 @@ public Consumer modifyMetadata(String attributeName "is supported.", attributeName, attributeValueType.name())); } - Validate.isNotNegativeOrNull(startAt, "startAt"); + if (startAt != null && startAt < -1) { + throw new IllegalArgumentException("startAt must be -1 or greater."); + } if (incrementBy != null && incrementBy < 1) { throw new IllegalArgumentException("incrementBy must be greater than 0."); @@ -228,9 +235,10 @@ private Builder() { /** * Sets the startAt used to compare if a record is the initial version of a record. + * The first version written to a new record is calculated as {@code startAt + incrementBy}. * Default value - {@code 0}. * - * @param startAt the starting value for version comparison, must not be negative + * @param startAt the starting value for version comparison, must be -1 or greater * @return the builder instance */ public Builder startAt(Long startAt) { 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..1d74d20d8341 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,10 @@ * 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. + *

+ * Version Calculation: The first version written to a new record is calculated as {@code startAt + incrementBy}. + * For example, with {@code startAt=0} and {@code incrementBy=1} (defaults), the first version is 1. + * To start versioning from 0, use {@code startAt=-1} and {@code incrementBy=1}, which produces first version = 0. */ @SdkPublicApi @Target({ElementType.METHOD}) 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..3f30fdc8ecdf 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 @@ -653,6 +653,26 @@ public void updateItem_existingRecordWithVersionEqualToStartAt_shouldSucceed() { is("attribute_not_exists(#AMZN_MAPPED_version) OR #AMZN_MAPPED_version = :old_version_value")); } + @Test + public void beforeWrite_startAtNegativeOne_firstVersionIsZero() { + VersionedRecordExtension extension = VersionedRecordExtension.builder() + .startAt(-1L) + .incrementBy(1L) + .build(); + FakeItem fakeItem = createUniqueFakeItem(); + Map expectedItem = + new HashMap<>(FakeItem.getTableSchema().itemToMap(fakeItem, true)); + expectedItem.put("version", AttributeValue.builder().n("0").build()); + + WriteModification result = + extension.beforeWrite(DefaultDynamoDbExtensionContext + .builder() + .items(FakeItem.getTableSchema().itemToMap(fakeItem, true)) + .tableMetadata(FakeItem.getTableMetadata()) + .operationContext(PRIMARY_CONTEXT).build()); + + assertThat(result.transformedItem(), is(expectedItem)); + } 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..e33d117bbd5e 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 @@ -143,6 +143,24 @@ public AnnotatedRecord setAttribute(String attribute) { } } + @DynamoDbBean + public static class AnnotatedRecordStartAtNegativeOne { + private String id; + private String attribute; + private Long version; + + @DynamoDbPartitionKey + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + @DynamoDbVersionAttribute(startAt = -1, incrementBy = 1) + public Long getVersion() { return version; } + public void setVersion(Long version) { this.version = version; } + + public String getAttribute() { return attribute; } + public void setAttribute(String attribute) { this.attribute = attribute; } + } + private DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() .dynamoDbClient(getDynamoDbClient()) .extensions(VersionedRecordExtension.builder().build()) @@ -158,11 +176,24 @@ public AnnotatedRecord setAttribute(String attribute) { ) .build(); + private DynamoDbEnhancedClient startAtNegativeOneClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(VersionedRecordExtension + .builder() + .startAt(-1L) + .incrementBy(1L) + .build() + ) + .build(); + private DynamoDbTable mappedTable = enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA); private DynamoDbTable mappedCustomVersionedTable = customVersionedEnhancedClient .table(getConcreteTableName("table-name2"), TABLE_SCHEMA); + private DynamoDbTable startAtNegativeOneTable = startAtNegativeOneClient + .table(getConcreteTableName("startAt-neg-one-table"), TABLE_SCHEMA); + private static final TableSchema ANNOTATED_TABLE_SCHEMA = TableSchema.fromBean(AnnotatedRecord.class); @@ -170,6 +201,12 @@ public AnnotatedRecord setAttribute(String attribute) { private DynamoDbTable annotatedTable = enhancedClient .table(getConcreteTableName("annotated-table"), ANNOTATED_TABLE_SCHEMA); + private static final TableSchema ANNOTATED_START_AT_NEG_ONE_SCHEMA = + TableSchema.fromBean(AnnotatedRecordStartAtNegativeOne.class); + + private DynamoDbTable annotatedStartAtNegativeOneTable = enhancedClient + .table(getConcreteTableName("annotated-startAt-neg-one-table"), ANNOTATED_START_AT_NEG_ONE_SCHEMA); + @@ -181,6 +218,8 @@ public void createTable() { mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); mappedCustomVersionedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); annotatedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + startAtNegativeOneTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + annotatedStartAtNegativeOneTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); } @After @@ -196,6 +235,14 @@ public void deleteTable() { getDynamoDbClient().deleteTable(DeleteTableRequest.builder() .tableName(getConcreteTableName("annotated-table")) .build()); + + getDynamoDbClient().deleteTable(DeleteTableRequest.builder() + .tableName(getConcreteTableName("startAt-neg-one-table")) + .build()); + + getDynamoDbClient().deleteTable(DeleteTableRequest.builder() + .tableName(getConcreteTableName("annotated-startAt-neg-one-table")) + .build()); } @@ -580,4 +627,50 @@ 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_startAtNegativeOne_firstVersionIsZero() { + startAtNegativeOneTable.putItem(r -> r.item(new Record().setId("test-id").setAttribute("value"))); + + Record result = startAtNegativeOneTable.getItem(r -> r.key(k -> k.partitionValue("test-id"))); + assertThat(result.getVersion(), is(0)); + } + + @Test + public void updateItem_startAtNegativeOne_incrementsFromZero() { + startAtNegativeOneTable.putItem(r -> r.item(new Record().setId("test-id-2").setAttribute("value"))); + + Record recordToUpdate = startAtNegativeOneTable.getItem(r -> r.key(k -> k.partitionValue("test-id-2"))); + recordToUpdate.setAttribute("updated"); + startAtNegativeOneTable.updateItem(r -> r.item(recordToUpdate)); + + Record result = startAtNegativeOneTable.getItem(r -> r.key(k -> k.partitionValue("test-id-2"))); + assertThat(result.getVersion(), is(1)); + } + + @Test + public void updateItem_startAtNegativeOne_versionMatchesStartAt_shouldSucceed() { + Map item = new HashMap<>(); + item.put("id", stringValue("test-id-3")); + item.put("attribute", stringValue("value")); + item.put("version", AttributeValue.builder().n("-1").build()); + getDynamoDbClient().putItem(r -> r.tableName(startAtNegativeOneTable.tableName()).item(item)); + + startAtNegativeOneTable.updateItem(r -> r.item(new Record().setId("test-id-3").setAttribute("updated").setVersion(-1))); + + Record result = startAtNegativeOneTable.getItem(r -> r.key(k -> k.partitionValue("test-id-3"))); + assertThat(result.getVersion(), is(0)); + } + + @Test + public void annotatedRecord_startAtNegativeOne_firstVersionIsZero() { + AnnotatedRecordStartAtNegativeOne record = new AnnotatedRecordStartAtNegativeOne(); + record.setId("test-id"); + record.setAttribute("value"); + + annotatedStartAtNegativeOneTable.putItem(record); + + AnnotatedRecordStartAtNegativeOne result = annotatedStartAtNegativeOneTable.getItem(r -> r.key(k -> k.partitionValue("test-id"))); + assertThat(result.getVersion(), is(0L)); + } }