diff --git a/.changes/next-release/bugfix-DynamoDBEnhancedClient-191a1f8.json b/.changes/next-release/bugfix-DynamoDBEnhancedClient-191a1f8.json
new file mode 100644
index 000000000000..3bd017a31ca0
--- /dev/null
+++ b/.changes/next-release/bugfix-DynamoDBEnhancedClient-191a1f8.json
@@ -0,0 +1,6 @@
+{
+ "type": "bugfix",
+ "category": "DynamoDB Enhanced Client",
+ "contributor": "",
+ "description": "Allow new records to be initialized with version=0 by supporting startAt=-1 in VersionedRecordExtension"
+}
diff --git a/.changes/next-release/feature-DynamoDBEnhancedClient-2025f0e.json b/.changes/next-release/feature-DynamoDBEnhancedClient-2025f0e.json
new file mode 100644
index 000000000000..6745f42d2dd2
--- /dev/null
+++ b/.changes/next-release/feature-DynamoDBEnhancedClient-2025f0e.json
@@ -0,0 +1,6 @@
+{
+ "type": "feature",
+ "category": "DynamoDB Enhanced Client",
+ "contributor": "",
+ "description": "modify VersionedRecordExtension to support updating existing records with version=0 using OR condition"
+}
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 5f5cdb02c354..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.");
@@ -158,7 +165,7 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
.orElse(this.incrementBy);
- if (isInitialVersion(existingVersionValue, versionStartAtFromAnnotation)) {
+ if (existingVersionValue == null || isNullAttributeValue(existingVersionValue)) {
newVersionValue = AttributeValue.builder()
.n(Long.toString(versionStartAtFromAnnotation + versionIncrementByFromAnnotation))
.build();
@@ -175,7 +182,6 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
long existingVersion = Long.parseLong(existingVersionValue.n());
String existingVersionValueKey = VERSIONED_RECORD_EXPRESSION_VALUE_KEY_MAPPER.apply(versionAttributeKey.get());
-
long increment = versionIncrementByFromAnnotation;
/*
@@ -190,12 +196,25 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
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))
- .build();
+ // When version equals startAt, we can't distinguish between new and existing records
+ // Use OR condition to handle both cases
+ if (existingVersion == versionStartAtFromAnnotation) {
+ 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,
+ 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,
+ existingVersionValue))
+ .build();
+ }
}
itemToTransform.put(versionAttributeKey.get(), newVersionValue);
@@ -206,21 +225,6 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
.build();
}
- private boolean isInitialVersion(AttributeValue existingVersionValue, Long versionStartAtFromAnnotation) {
- if (existingVersionValue == null || isNullAttributeValue(existingVersionValue)) {
- return true;
- }
-
- if (existingVersionValue.n() != null) {
- long currentVersion = Long.parseLong(existingVersionValue.n());
- // If annotation value is present, use it, otherwise fall back to the extension's value
- Long effectiveStartAt = versionStartAtFromAnnotation != null ? versionStartAtFromAnnotation : this.startAt;
- return currentVersion == effectiveStartAt;
- }
-
- return false;
- }
-
@NotThreadSafe
public static final class Builder {
private Long startAt;
@@ -231,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 4feb23a43943..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
@@ -212,7 +212,7 @@ public void beforeWrite_versionEqualsStartAt_treatedAsInitialVersion() {
.operationContext(PRIMARY_CONTEXT).build());
assertThat(result.additionalConditionalExpression().expression(),
- is("attribute_not_exists(#AMZN_MAPPED_version)"));
+ is("attribute_not_exists(#AMZN_MAPPED_version) OR #AMZN_MAPPED_version = :old_version_value"));
}
@ParameterizedTest
@@ -321,7 +321,7 @@ public void beforeWrite_versionEqualsAnnotationStartAt_isTreatedAsInitialVersion
.operationContext(PRIMARY_CONTEXT).build());
assertThat(result.additionalConditionalExpression().expression(),
- is("attribute_not_exists(#AMZN_MAPPED_version)"));
+ is("attribute_not_exists(#AMZN_MAPPED_version) OR #AMZN_MAPPED_version = :old_version_value"));
}
@@ -634,6 +634,46 @@ public void isInitialVersion_shouldPrioritizeAnnotationValueOverBuilderValue() {
is("#AMZN_MAPPED_version = :old_version_value"));
}
+ @Test
+ public void updateItem_existingRecordWithVersionEqualToStartAt_shouldSucceed() {
+ VersionedRecordExtension recordExtension = VersionedRecordExtension.builder().build();
+ FakeItem item = createUniqueFakeItem();
+ item.setVersion(0);
+
+ 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 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(
Arguments.of(0L, 1L, 5L, "6"),
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 32cbfd5332a4..9d9eec77cabc 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
@@ -17,10 +17,13 @@
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
import static software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension.AttributeTags.versionAttribute;
import static software.amazon.awssdk.enhanced.dynamodb.internal.AttributeValues.stringValue;
import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey;
+import java.util.HashMap;
+import java.util.Map;
import java.util.Objects;
import org.junit.After;
import org.junit.Before;
@@ -38,6 +41,7 @@
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.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest;
@@ -139,6 +143,30 @@ 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 static final int CUSTOM_START_AT = 10;
+ private static final int CUSTOM_INCREMENT_BY = 2;
+ private static final long ANNOTATED_START_AT = 5L;
+ private static final long ANNOTATED_INCREMENT_BY = 3L;
+
private DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder()
.dynamoDbClient(getDynamoDbClient())
.extensions(VersionedRecordExtension.builder().build())
@@ -154,11 +182,37 @@ public AnnotatedRecord setAttribute(String attribute) {
)
.build();
+ private DynamoDbEnhancedClient startAtNegativeOneClient = DynamoDbEnhancedClient.builder()
+ .dynamoDbClient(getDynamoDbClient())
+ .extensions(VersionedRecordExtension
+ .builder()
+ .startAt(-1L)
+ .incrementBy(1L)
+ .build()
+ )
+ .build();
+
+ private DynamoDbEnhancedClient startAtNegativeOneIncrementByTwoClient = DynamoDbEnhancedClient.builder()
+ .dynamoDbClient(getDynamoDbClient())
+ .extensions(VersionedRecordExtension
+ .builder()
+ .startAt(-1L)
+ .incrementBy(2L)
+ .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 DynamoDbTable startAtNegativeOneIncrementByTwoTable = startAtNegativeOneIncrementByTwoClient
+ .table(getConcreteTableName("startAt-neg-one-inc-two-table"), TABLE_SCHEMA);
+
private static final TableSchema ANNOTATED_TABLE_SCHEMA =
TableSchema.fromBean(AnnotatedRecord.class);
@@ -166,6 +220,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);
+
@@ -177,6 +237,9 @@ 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()));
+ startAtNegativeOneIncrementByTwoTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()));
+ annotatedStartAtNegativeOneTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()));
}
@After
@@ -192,6 +255,18 @@ 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("startAt-neg-one-inc-two-table"))
+ .build());
+
+ getDynamoDbClient().deleteTable(DeleteTableRequest.builder()
+ .tableName(getConcreteTableName("annotated-startAt-neg-one-table"))
+ .build());
}
@@ -383,12 +458,12 @@ 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));
+ assertThat(record.getVersion(), is(CUSTOM_START_AT + CUSTOM_INCREMENT_BY));
}
@Test(expected = ConditionalCheckFailedException.class)
public void recordWithVersionBetweenStartAtAndFirstVersionFails() {
- Record invalidRecord = new Record().setId("invalid-version").setAttribute("test").setVersion(11);
+ Record invalidRecord = new Record().setId("invalid-version").setAttribute("test").setVersion(CUSTOM_START_AT + 1);
mappedCustomVersionedTable.putItem(r -> r.item(invalidRecord));
}
@@ -398,13 +473,256 @@ public void annotationBasedCustomVersioningWorks() {
AnnotatedRecord result = annotatedTable.getItem(r -> r.key(k -> k.partitionValue("annotated")));
- assertThat(result.getVersion(), is(8L));
+ assertThat(result.getVersion(), is(ANNOTATED_START_AT + ANNOTATED_INCREMENT_BY));
AnnotatedRecord updated = annotatedTable.updateItem(r -> r.item(new AnnotatedRecord()
.setId("annotated")
.setAttribute("updated")
- .setVersion(8L)));
+ .setVersion(ANNOTATED_START_AT + ANNOTATED_INCREMENT_BY)));
+
+ assertThat(updated.getVersion(), is(ANNOTATED_START_AT + ANNOTATED_INCREMENT_BY + ANNOTATED_INCREMENT_BY));
+ }
+
+ @Test
+ public void updateItem_existingRecordWithVersionZero_defaultStartAt_shouldSucceed() {
+ Map item = new HashMap<>();
+ item.put("id", stringValue("version-zero"));
+ item.put("version", AttributeValue.builder().n("0").build());
+
+ getDynamoDbClient().putItem(r -> r.tableName(getConcreteTableName("table-name")).item(item));
+
+ Record retrieved = mappedTable.getItem(r -> r.key(k -> k.partitionValue("version-zero")));
+ assertThat(retrieved.getVersion(), is(0));
+
+ Record updated = mappedTable.updateItem(retrieved);
+ assertThat(updated.getVersion(), is(1));
+ }
+
+ @Test
+ public void updateItem_existingRecordWithVersionEqualToBuilderStartAt_shouldSucceed() {
+ Map item = new HashMap<>();
+ item.put("id", stringValue("version-ten"));
+ item.put("version", AttributeValue.builder().n(String.valueOf(CUSTOM_START_AT)).build());
+
+ getDynamoDbClient().putItem(r -> r.tableName(getConcreteTableName("table-name2")).item(item));
+
+ Record retrieved = mappedCustomVersionedTable.getItem(r -> r.key(k -> k.partitionValue("version-ten")));
+ assertThat(retrieved.getVersion(), is(CUSTOM_START_AT));
+
+ Record updated = mappedCustomVersionedTable.updateItem(retrieved);
+ assertThat(updated.getVersion(), is(CUSTOM_START_AT + CUSTOM_INCREMENT_BY));
+ }
+
+ @Test
+ public void updateItem_existingRecordWithVersionEqualToAnnotationStartAt_shouldSucceed() {
+ Map item = new HashMap<>();
+ item.put("id", stringValue("version-five"));
+ item.put("version", AttributeValue.builder().n(String.valueOf(ANNOTATED_START_AT)).build());
+
+ getDynamoDbClient().putItem(r -> r.tableName(getConcreteTableName("annotated-table")).item(item));
+
+ AnnotatedRecord retrieved = annotatedTable.getItem(r -> r.key(k -> k.partitionValue("version-five")));
+ assertThat(retrieved.getVersion(), is(ANNOTATED_START_AT));
+
+ AnnotatedRecord updated = annotatedTable.updateItem(retrieved);
+ assertThat(updated.getVersion(), is(ANNOTATED_START_AT + ANNOTATED_INCREMENT_BY));
+ }
+
+ @Test
+ public void putItem_existingRecordWithVersionEqualToStartAt_shouldSucceed() {
+ Map item = new HashMap<>();
+ item.put("id", stringValue("put-version-ten"));
+ item.put("version", AttributeValue.builder().n(String.valueOf(CUSTOM_START_AT)).build());
+
+ getDynamoDbClient().putItem(r -> r.tableName(getConcreteTableName("table-name2")).item(item));
+
+ Record overwrite = new Record().setId("put-version-ten").setVersion(CUSTOM_START_AT);
+ mappedCustomVersionedTable.putItem(overwrite);
+
+ Record retrieved = mappedCustomVersionedTable.getItem(r -> r.key(k -> k.partitionValue("put-version-ten")));
+ assertThat(retrieved.getVersion(), is(CUSTOM_START_AT + CUSTOM_INCREMENT_BY));
+ }
+
+ @Test
+ public void putItem_newRecordWithVersionEqualToStartAt_shouldSucceed() {
+ Record newRecord = new Record().setId("explicit-zero").setAttribute("test").setVersion(0);
+ mappedTable.putItem(newRecord);
+
+ Record retrieved = mappedTable.getItem(r -> r.key(k -> k.partitionValue("explicit-zero")));
+ assertThat(retrieved.getVersion(), is(1));
+ }
+
+ @Test
+ public void sdkV1MigrationFlow_createRetrieveUpdate_shouldSucceed() {
+ Map item = new HashMap<>();
+ item.put("id", stringValue("v1-record"));
+ item.put("attribute", stringValue("initial"));
+ item.put("version", AttributeValue.builder().n("0").build());
+ getDynamoDbClient().putItem(r -> r.tableName(getConcreteTableName("table-name")).item(item));
+
+ Record retrieved = mappedTable.getItem(r -> r.key(k -> k.partitionValue("v1-record")));
+ assertThat(retrieved.getVersion(), is(0));
+
+ retrieved.setAttribute("updated");
+ Record updated = mappedTable.updateItem(retrieved);
+ assertThat(updated.getVersion(), is(1));
+ assertThat(updated.getAttribute(), is("updated"));
+ }
+
+ @Test
+ public void deleteItem_existingRecordWithVersionZero_shouldSucceed() {
+ Map item = new HashMap<>();
+ item.put("id", stringValue("delete-test"));
+ item.put("attribute", stringValue("test"));
+ item.put("version", AttributeValue.builder().n("0").build());
+ getDynamoDbClient().putItem(r -> r.tableName(getConcreteTableName("table-name")).item(item));
+
+ Record toDelete = mappedTable.getItem(r -> r.key(k -> k.partitionValue("delete-test")));
+ assertThat(toDelete.getVersion(), is(0));
+
+ mappedTable.deleteItem(toDelete);
+
+ Record shouldBeNull = mappedTable.getItem(r -> r.key(k -> k.partitionValue("delete-test")));
+ assertThat(shouldBeNull, is(nullValue()));
+ }
+
+ @Test
+ public void updateItem_bothBuilderAndAnnotationWithVersionEqualToStartAt_shouldSucceed() {
+ Map item = new HashMap<>();
+ item.put("id", stringValue("both-config"));
+ item.put("attribute", stringValue("initial"));
+ item.put("version", AttributeValue.builder().n(String.valueOf(ANNOTATED_START_AT)).build());
+ getDynamoDbClient().putItem(r -> r.tableName(getConcreteTableName("annotated-table")).item(item));
+
+ AnnotatedRecord retrieved = annotatedTable.getItem(r -> r.key(k -> k.partitionValue("both-config")));
+ assertThat(retrieved.getVersion(), is(ANNOTATED_START_AT));
+
+ retrieved.setAttribute("updated");
+ AnnotatedRecord updated = annotatedTable.updateItem(retrieved);
+ assertThat(updated.getVersion(), is(ANNOTATED_START_AT + ANNOTATED_INCREMENT_BY));
+ }
+
+ @Test
+ public void putItem_annotationConfigWithVersionEqualToStartAt_shouldSucceed() {
+ Map item = new HashMap<>();
+ item.put("id", stringValue("annotation-put"));
+ item.put("attribute", stringValue("initial"));
+ item.put("version", AttributeValue.builder().n(String.valueOf(ANNOTATED_START_AT)).build());
+ getDynamoDbClient().putItem(r -> r.tableName(getConcreteTableName("annotated-table")).item(item));
+
+ AnnotatedRecord overwrite = new AnnotatedRecord().setId("annotation-put").setAttribute("overwritten").setVersion(ANNOTATED_START_AT);
+ annotatedTable.putItem(overwrite);
+
+ AnnotatedRecord retrieved = annotatedTable.getItem(r -> r.key(k -> k.partitionValue("annotation-put")));
+ assertThat(retrieved.getAttribute(), is("overwritten"));
+ assertThat(retrieved.getVersion(), is(ANNOTATED_START_AT + ANNOTATED_INCREMENT_BY));
+ }
+
+ @Test
+ public void deleteItem_builderConfigWithVersionEqualToStartAt_shouldSucceed() {
+ Map item = new HashMap<>();
+ item.put("id", stringValue("delete-builder"));
+ item.put("attribute", stringValue("test"));
+ item.put("version", AttributeValue.builder().n(String.valueOf(CUSTOM_START_AT)).build());
+ getDynamoDbClient().putItem(r -> r.tableName(getConcreteTableName("table-name2")).item(item));
+
+ Record toDelete = mappedCustomVersionedTable.getItem(r -> r.key(k -> k.partitionValue("delete-builder")));
+ assertThat(toDelete.getVersion(), is(CUSTOM_START_AT));
+
+ mappedCustomVersionedTable.deleteItem(toDelete);
+
+ Record shouldBeNull = mappedCustomVersionedTable.getItem(r -> r.key(k -> k.partitionValue("delete-builder")));
+ assertThat(shouldBeNull, is(nullValue()));
+ }
+
+ @Test
+ public void deleteItem_annotationConfigWithVersionEqualToStartAt_shouldSucceed() {
+ Map item = new HashMap<>();
+ item.put("id", stringValue("delete-annotation"));
+ item.put("attribute", stringValue("test"));
+ item.put("version", AttributeValue.builder().n(String.valueOf(ANNOTATED_START_AT)).build());
+ getDynamoDbClient().putItem(r -> r.tableName(getConcreteTableName("annotated-table")).item(item));
+
+ AnnotatedRecord toDelete = annotatedTable.getItem(r -> r.key(k -> k.partitionValue("delete-annotation")));
+ assertThat(toDelete.getVersion(), is(ANNOTATED_START_AT));
+
+ annotatedTable.deleteItem(toDelete);
+
+ 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 putItem_startAtNegativeOne_incrementByTwo_firstVersionIsOne() {
+ startAtNegativeOneIncrementByTwoTable.putItem(r -> r.item(new Record().setId("test-id").setAttribute("value")));
+
+ Record result = startAtNegativeOneIncrementByTwoTable.getItem(r -> r.key(k -> k.partitionValue("test-id")));
+ assertThat(result.getVersion(), is(1));
+ }
+
+ @Test
+ public void updateItem_startAtNegativeOne_incrementByTwo_incrementsByTwo() {
+ startAtNegativeOneIncrementByTwoTable.putItem(r -> r.item(new Record().setId("test-id-2").setAttribute("value")));
+
+ Record recordToUpdate = startAtNegativeOneIncrementByTwoTable.getItem(r -> r.key(k -> k.partitionValue("test-id-2")));
+ recordToUpdate.setAttribute("updated");
+ startAtNegativeOneIncrementByTwoTable.updateItem(r -> r.item(recordToUpdate));
+
+ Record result = startAtNegativeOneIncrementByTwoTable.getItem(r -> r.key(k -> k.partitionValue("test-id-2")));
+ assertThat(result.getVersion(), is(3));
+ }
+
+ @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(expected = IllegalArgumentException.class)
+ public void builder_startAtNegativeTwo_throwsException() {
+ VersionedRecordExtension.builder()
+ .startAt(-2L)
+ .incrementBy(1L)
+ .build();
+ }
+
+ @Test
+ public void annotatedRecord_startAtNegativeOne_firstVersionIsZero() {
+ AnnotatedRecordStartAtNegativeOne record = new AnnotatedRecordStartAtNegativeOne();
+ record.setId("test-id");
+ record.setAttribute("value");
+
+ annotatedStartAtNegativeOneTable.putItem(record);
- assertThat(updated.getVersion(), is(11L));
+ AnnotatedRecordStartAtNegativeOne result = annotatedStartAtNegativeOneTable.getItem(r -> r.key(k -> k.partitionValue("test-id")));
+ assertThat(result.getVersion(), is(0L));
}
}