Skip to content

Commit 5998f4b

Browse files
committed
Added support for DynamoDbAutoGeneratedKey annotation
1 parent 6f5bd53 commit 5998f4b

File tree

8 files changed

+525
-25
lines changed

8 files changed

+525
-25
lines changed

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedKeyExtension.java

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@
4242
* Generates a random UUID (via {@link java.util.UUID#randomUUID()}) for any attribute tagged with
4343
* {@code @DynamoDbAutoGeneratedKey} when that attribute is missing or empty on a write (put/update).
4444
* <p>
45+
* <b>Key Difference from @DynamoDbAutoGeneratedUuid:</b> This extension only generates UUIDs when the
46+
* attribute value is null or empty, preserving existing values. In contrast, {@code @DynamoDbAutoGeneratedUuid}
47+
* always generates new UUIDs regardless of existing values.
48+
* <p>
49+
* <b>Conflict Detection:</b> This extension cannot be used together with {@code @DynamoDbAutoGeneratedUuid} on the same
50+
* attribute. If both annotations are applied to the same field, an {@link IllegalArgumentException} will be thrown
51+
* at runtime to prevent unpredictable behavior based on extension load order.
52+
* <p>
4553
* The annotation may be placed <b>only</b> on key attributes:
4654
* <ul>
4755
* <li>Primary partition key (PK) or primary sort key (SK)</li>
@@ -51,6 +59,9 @@
5159
* <p><b>Validation:</b> The extension enforces this at runtime during {@link #beforeWrite} by comparing the
5260
* annotated attributes against the table's known key attributes. If an annotated attribute
5361
* is not a PK/SK or an GSI/LSI, an {@link IllegalArgumentException} is thrown.</p>
62+
*
63+
* <p><b>UpdateBehavior Limitations:</b> {@code @DynamoDbUpdateBehavior} has no effect on primary keys due to
64+
* DynamoDB's UpdateItem API requirements. It only affects secondary index keys.</p>
5465
*/
5566
@SdkPublicApi
5667
@ThreadSafe
@@ -62,6 +73,12 @@ public final class AutoGeneratedKeyExtension implements DynamoDbEnhancedClientEx
6273
private static final String CUSTOM_METADATA_KEY =
6374
"software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedKeyExtension:AutoGeneratedKeyAttribute";
6475

76+
/**
77+
* Metadata key used by AutoGeneratedUuidExtension to detect conflicts.
78+
*/
79+
private static final String UUID_EXTENSION_METADATA_KEY =
80+
"software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension:AutoGeneratedUuidAttribute";
81+
6582
private static final AutoGeneratedKeyAttribute AUTO_GENERATED_KEY_ATTRIBUTE = new AutoGeneratedKeyAttribute();
6683

6784
private AutoGeneratedKeyExtension() {
@@ -73,9 +90,10 @@ public static Builder builder() {
7390

7491
/**
7592
* If this table has attributes tagged for auto-generation, insert a UUID value into the outgoing item for any such attribute
76-
* that is currently missing/empty.
93+
* that is currently missing/empty. Unlike {@code @DynamoDbAutoGeneratedUuid}, this preserves existing values.
7794
* <p>
78-
* Also validates that the annotation is only used on PK/SK/GSI/LSI key attributes.
95+
* Also validates that the annotation is only used on PK/SK/GSI/LSI key attributes and that there are no conflicts
96+
* with @DynamoDbAutoGeneratedUuid.
7997
*/
8098
@Override
8199
public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) {
@@ -87,6 +105,21 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
87105
return WriteModification.builder().build();
88106
}
89107

108+
// Check for conflicts with @DynamoDbAutoGeneratedUuid
109+
Collection<String> uuidTaggedAttributes = context.tableMetadata()
110+
.customMetadataObject(UUID_EXTENSION_METADATA_KEY, Collection.class)
111+
.orElse(Collections.emptyList());
112+
113+
taggedAttributes.stream()
114+
.filter(uuidTaggedAttributes::contains)
115+
.findFirst()
116+
.ifPresent(attribute -> {
117+
throw new IllegalArgumentException(
118+
"Attribute '" + attribute + "' cannot have both @DynamoDbAutoGeneratedKey and "
119+
+ "@DynamoDbAutoGeneratedUuid annotations. These annotations have conflicting behaviors "
120+
+ "and cannot be used together on the same attribute.");
121+
});
122+
90123
TableMetadata meta = context.tableMetadata();
91124
Set<String> allowedKeys = new HashSet<>();
92125

@@ -100,15 +133,15 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
100133
meta.indexSortKey(indexName).ifPresent(allowedKeys::add);
101134
}
102135

103-
for (String attr : taggedAttributes) {
104-
if (!allowedKeys.contains(attr)) {
105-
throw new IllegalArgumentException(
106-
"@DynamoDbAutoGeneratedKey can only be applied to key attributes: " +
107-
"primary partition key, primary sort key, or GSI/LSI partition/sort keys." +
108-
"Invalid placement on attribute: " + attr);
109-
110-
}
111-
}
136+
taggedAttributes.stream()
137+
.filter(attr -> !allowedKeys.contains(attr))
138+
.findFirst()
139+
.ifPresent(attr -> {
140+
throw new IllegalArgumentException(
141+
"@DynamoDbAutoGeneratedKey can only be applied to key attributes: "
142+
+ "primary partition key, primary sort key, or GSI/LSI partition/sort keys."
143+
+ "Invalid placement on attribute: " + attr);
144+
});
112145

113146
// Generate UUIDs for missing/empty annotated attributes
114147
Map<String, AttributeValue> itemToTransform = new HashMap<>(context.items());

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedUuidExtension.java

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@
3939
* every time a new record is written to the database. The generated UUID is obtained using the
4040
* {@link java.util.UUID#randomUUID()} method.
4141
* <p>
42+
* <b>Key Difference from @DynamoDbAutoGeneratedKey:</b> This extension always generates new UUIDs on every write,
43+
* regardless of existing values. In contrast, {@code @DynamoDbAutoGeneratedKey} only generates UUIDs when the
44+
* attribute value is null or empty, preserving existing values.
45+
* <p>
46+
* <b>Conflict Detection:</b> This extension cannot be used together with {@code @DynamoDbAutoGeneratedKey} on the same
47+
* attribute. If both annotations are applied to the same field, an {@link IllegalArgumentException} will be thrown
48+
* at runtime to prevent unpredictable behavior.
49+
* <p>
4250
* This extension is not loaded by default when you instantiate a
4351
* {@link software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient}. Therefore, you need to specify it in a custom
4452
* extension when creating the enhanced client.
@@ -79,6 +87,13 @@
7987
public final class AutoGeneratedUuidExtension implements DynamoDbEnhancedClientExtension {
8088
private static final String CUSTOM_METADATA_KEY =
8189
"software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedUuidExtension:AutoGeneratedUuidAttribute";
90+
91+
/**
92+
* Metadata key used by AutoGeneratedKeyExtension to detect conflicts.
93+
*/
94+
private static final String KEY_EXTENSION_METADATA_KEY =
95+
"software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedKeyExtension:AutoGeneratedKeyAttribute";
96+
8297
private static final AutoGeneratedUuidAttribute AUTO_GENERATED_UUID_ATTRIBUTE = new AutoGeneratedUuidAttribute();
8398

8499
private AutoGeneratedUuidExtension() {
@@ -99,8 +114,6 @@ public static AutoGeneratedUuidExtension create() {
99114
*/
100115
@Override
101116
public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) {
102-
103-
104117
Collection<String> customMetadataObject = context.tableMetadata()
105118
.customMetadataObject(CUSTOM_METADATA_KEY, Collection.class)
106119
.orElse(null);
@@ -109,6 +122,21 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
109122
return WriteModification.builder().build();
110123
}
111124

125+
// Check for conflicts with @DynamoDbAutoGeneratedKey
126+
Collection<String> keyTaggedAttributes = context.tableMetadata()
127+
.customMetadataObject(KEY_EXTENSION_METADATA_KEY, Collection.class)
128+
.orElse(Collections.emptyList());
129+
130+
customMetadataObject.stream()
131+
.filter(keyTaggedAttributes::contains)
132+
.findFirst()
133+
.ifPresent(attribute -> {
134+
throw new IllegalArgumentException(
135+
"Attribute '" + attribute + "' cannot have both @DynamoDbAutoGeneratedKey and "
136+
+ "@DynamoDbAutoGeneratedUuid annotations. These annotations have conflicting behaviors "
137+
+ "and cannot be used together on the same attribute.");
138+
});
139+
112140
Map<String, AttributeValue> itemToTransform = new HashMap<>(context.items());
113141
customMetadataObject.forEach(key -> insertUuidInItemToTransform(itemToTransform, key));
114142
return WriteModification.builder()

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbAutoGeneratedKey.java

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbUpdateBehavior;
2828

2929
/**
30-
* Annotation that marks a key attribute to be automatically populated with a random UUID if no value is provided during a
31-
* write operation (put or update). This annotation is intended to work specifically with key attributes.
30+
* Annotation that marks a key attribute to be automatically populated with a random UUID if no value is provided during a write
31+
* operation (put or update). This annotation is intended to work specifically with key attributes.
3232
*
3333
* <p>This annotation is designed for use with the V2 {@link software.amazon.awssdk.enhanced.dynamodb.mapper.BeanTableSchema}.
3434
* It is registered via {@link BeanTableSchemaAttributeTag} and its behavior is implemented by
@@ -48,19 +48,22 @@
4848
* <li>On writes where the annotated attribute is null or empty, a new UUID value is generated
4949
* using {@link java.util.UUID#randomUUID()}.</li>
5050
* <li>If a value is already set on the attribute, that value is preserved and not replaced.</li>
51+
* <li>This behavior differs from {@code @DynamoDbAutoGeneratedUuid}, which always generates new UUIDs regardless of existing
52+
* values.</li>
5153
* </ul>
5254
*
53-
* <h3>Controlling regeneration on update</h3>
54-
* This annotation can be combined with {@link DynamoDbUpdateBehavior} to control regeneration behavior:
55+
* <h3>Behavior with UpdateBehavior</h3>
56+
* <p><strong>Primary Keys:</strong> {@link DynamoDbUpdateBehavior} has <strong>no effect</strong> on primary partition keys
57+
* or primary sort keys. Primary keys are required for UpdateItem operations in DynamoDB and cannot be conditionally
58+
* updated. UUIDs will be generated whenever the primary key attribute is missing or empty, regardless of any
59+
* {@code UpdateBehavior} setting.</p>
60+
*
61+
* <p><strong>Secondary Index Keys:</strong> For GSI/LSI keys, {@link DynamoDbUpdateBehavior} can be used:
5562
* <ul>
56-
* <li>{@link UpdateBehavior#WRITE_ALWAYS} (default) –
57-
* Generate a new UUID whenever the attribute is missing during write.</li>
58-
* <li>{@link UpdateBehavior#WRITE_IF_NOT_EXISTS} –
59-
* Generate a UUID only the first time (on insert), and preserve that value on subsequent updates.</li>
60-
* </ul>
61-
* <p><strong>Important:</strong> {@link DynamoDbUpdateBehavior} only affects secondary index keys.
62-
* Primary keys cannot be null in DynamoDB and are required for UpdateItem operations, so UpdateBehavior
63-
* has no practical effect on primary keys.</p>
63+
* <li>{@link UpdateBehavior#WRITE_ALWAYS} (default) – Generate a new UUID whenever the attribute is missing during write.</li>
64+
* <li>{@link UpdateBehavior#WRITE_IF_NOT_EXISTS} – Generate a UUID only on the first write, preserving the value on
65+
* subsequent updates.</li>
66+
* </ul></p>
6467
*
6568
* <h3>Type restriction</h3>
6669
* This annotation is only valid on attributes of type {@link String}.

services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedKeyExtensionTest.java

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,121 @@ public void autoGeneratedKey_onNonKey_throwsIllegalArgumentException() {
251251
.withMessageContaining("keyAttribute");
252252
}
253253

254+
@Test
255+
public void conflictingAnnotations_throwsIllegalArgumentException() {
256+
// Create a schema with both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid on the same attribute
257+
StaticTableSchema<ItemWithKey> conflictingSchema =
258+
StaticTableSchema
259+
.builder(ItemWithKey.class)
260+
.newItemSupplier(ItemWithKey::new)
261+
.addAttribute(String.class, a -> a.name("id")
262+
.getter(ItemWithKey::getId)
263+
.setter(ItemWithKey::setId)
264+
.addTag(primaryPartitionKey())
265+
// Both annotations on the same attribute
266+
.addTag(AutoGeneratedKeyExtension.AttributeTags.autoGeneratedKeyAttribute())
267+
.addTag(AutoGeneratedUuidExtension.AttributeTags.autoGeneratedUuidAttribute()))
268+
.addAttribute(String.class, a -> a.name("simpleString")
269+
.getter(ItemWithKey::getSimpleString)
270+
.setter(ItemWithKey::setSimpleString))
271+
.build();
272+
273+
ItemWithKey item = new ItemWithKey();
274+
item.setId(RECORD_ID);
275+
276+
Map<String, AttributeValue> items = conflictingSchema.itemToMap(item, true);
277+
278+
assertThatExceptionOfType(IllegalArgumentException.class)
279+
.isThrownBy(() ->
280+
extension.beforeWrite(DefaultDynamoDbExtensionContext.builder()
281+
.items(items)
282+
.tableMetadata(conflictingSchema.tableMetadata())
283+
.operationName(OperationName.PUT_ITEM)
284+
.operationContext(PRIMARY_CONTEXT)
285+
.build())
286+
)
287+
.withMessage("Attribute 'id' cannot have both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid annotations. "
288+
+ "These annotations have conflicting behaviors and cannot be used together on the same attribute.");
289+
}
290+
291+
@Test
292+
public void conflictingAnnotations_onSecondaryKey_throwsIllegalArgumentException() {
293+
// Create a schema with both annotations on a GSI key
294+
StaticTableSchema<ItemWithKey> conflictingGsiSchema =
295+
StaticTableSchema
296+
.builder(ItemWithKey.class)
297+
.newItemSupplier(ItemWithKey::new)
298+
.addAttribute(String.class, a -> a.name("id")
299+
.getter(ItemWithKey::getId)
300+
.setter(ItemWithKey::setId)
301+
.addTag(primaryPartitionKey()))
302+
.addAttribute(String.class, a -> a.name("keyAttribute")
303+
.getter(ItemWithKey::getKeyAttribute)
304+
.setter(ItemWithKey::setKeyAttribute)
305+
.addTag(secondaryPartitionKey("gsi1"))
306+
// Both annotations on the same GSI key
307+
.addTag(AutoGeneratedKeyExtension.AttributeTags.autoGeneratedKeyAttribute())
308+
.addTag(AutoGeneratedUuidExtension.AttributeTags.autoGeneratedUuidAttribute()))
309+
.addAttribute(String.class, a -> a.name("simpleString")
310+
.getter(ItemWithKey::getSimpleString)
311+
.setter(ItemWithKey::setSimpleString))
312+
.build();
313+
314+
ItemWithKey item = new ItemWithKey();
315+
item.setId(RECORD_ID);
316+
317+
Map<String, AttributeValue> items = conflictingGsiSchema.itemToMap(item, true);
318+
319+
assertThatExceptionOfType(IllegalArgumentException.class)
320+
.isThrownBy(() ->
321+
extension.beforeWrite(
322+
DefaultDynamoDbExtensionContext.builder()
323+
.items(items)
324+
.tableMetadata(conflictingGsiSchema.tableMetadata())
325+
.operationName(OperationName.PUT_ITEM)
326+
.operationContext(PRIMARY_CONTEXT)
327+
.build()))
328+
.withMessage("Attribute 'keyAttribute' cannot have both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid "
329+
+ "annotations. "
330+
+ "These annotations have conflicting behaviors and cannot be used together on the same attribute.");
331+
}
332+
333+
@Test
334+
public void conflictDetection_worksRegardlessOfExtensionOrder() {
335+
// Verify that AutoGeneratedKeyExtension detects conflicts even when
336+
// AutoGeneratedUuidExtension metadata is already present
337+
StaticTableSchema<ItemWithKey> conflictingSchema =
338+
StaticTableSchema
339+
.builder(ItemWithKey.class)
340+
.newItemSupplier(ItemWithKey::new)
341+
.addAttribute(String.class, a -> a.name("id")
342+
.getter(ItemWithKey::getId)
343+
.setter(ItemWithKey::setId)
344+
.addTag(primaryPartitionKey())
345+
.addTag(AutoGeneratedKeyExtension.AttributeTags.autoGeneratedKeyAttribute())
346+
.addTag(AutoGeneratedUuidExtension.AttributeTags.autoGeneratedUuidAttribute()))
347+
.build();
348+
349+
ItemWithKey item = new ItemWithKey();
350+
item.setId(RECORD_ID);
351+
352+
Map<String, AttributeValue> items = conflictingSchema.itemToMap(item, true);
353+
354+
// Test that the conflict is detected regardless of which extension runs first
355+
assertThatExceptionOfType(IllegalArgumentException.class)
356+
.isThrownBy(() ->
357+
extension.beforeWrite(
358+
DefaultDynamoDbExtensionContext.builder()
359+
.items(items)
360+
.tableMetadata(conflictingSchema.tableMetadata())
361+
.operationName(OperationName.PUT_ITEM)
362+
.operationContext(PRIMARY_CONTEXT)
363+
.build()))
364+
.withMessage("Attribute 'id' cannot have both @DynamoDbAutoGeneratedKey and @DynamoDbAutoGeneratedUuid "
365+
+ "annotations. "
366+
+ "These annotations have conflicting behaviors and cannot be used together on the same attribute.");
367+
}
368+
254369
private static class ItemWithKey {
255370

256371
private String id;

0 commit comments

Comments
 (0)