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