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));
+ }
}