Skip to content

Commit 3e5c890

Browse files
committed
Added support for @DynamoDbUpdateBehavior on attributes within nested objects
1 parent 7f19e6a commit 3e5c890

File tree

12 files changed

+2154
-139
lines changed

12 files changed

+2154
-139
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "feature",
3+
"category": "Amazon DynamoDB Enhanced Client",
4+
"contributor": "",
5+
"description": "Added support for @DynamoDbUpdateBehavior on attributes within nested objects. The @DynamoDbUpdateBehavior annotation will only take effect for nested attributes when using IgnoreNullsMode.SCALAR_ONLY."
6+
}

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

Lines changed: 195 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,20 @@
1515

1616
package software.amazon.awssdk.enhanced.dynamodb.extensions;
1717

18+
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.getNestedSchema;
19+
import static software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility.NestedRecordUtils.getTableSchemaForListElement;
20+
import static software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility.NestedRecordUtils.reconstructCompositeKey;
21+
import static software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility.NestedRecordUtils.resolveSchemasPerPath;
22+
1823
import java.time.Clock;
1924
import java.time.Instant;
25+
import java.util.ArrayList;
2026
import java.util.Collection;
2127
import java.util.Collections;
2228
import java.util.HashMap;
2329
import java.util.Map;
30+
import java.util.Objects;
31+
import java.util.Optional;
2432
import java.util.function.Consumer;
2533
import software.amazon.awssdk.annotations.NotThreadSafe;
2634
import software.amazon.awssdk.annotations.SdkPublicApi;
@@ -30,6 +38,7 @@
3038
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension;
3139
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext;
3240
import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
41+
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
3342
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag;
3443
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata;
3544
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
@@ -64,10 +73,23 @@
6473
* <p>
6574
* Every time a new update of the record is successfully written to the database, the timestamp at which it was modified will
6675
* be automatically updated. This extension applies the conversions as defined in the attribute convertor.
76+
* The implementation handles both flattened nested parameters (identified by keys separated with
77+
* {@code "_NESTED_ATTR_UPDATE_"}) and entire nested maps or lists, ensuring consistent behavior across both representations.
78+
* If a nested object or list is {@code null}, no timestamp values will be generated for any of its annotated fields.
79+
* The same timestamp value is used for both top-level attributes and all applicable nested fields.
80+
*
81+
* <p>
82+
* <b>Note:</b> This implementation uses a temporary cache keyed by {@link TableSchema} instance.
83+
* When updating timestamps in nested objects or lists, the correct {@code TableSchema} must be used for each object.
84+
* This cache ensures that each nested object is processed with its own schema, avoiding redundant lookups and ensuring
85+
* all annotated timestamp fields are updated correctly.
86+
* </p>
6787
*/
6888
@SdkPublicApi
6989
@ThreadSafe
7090
public final class AutoGeneratedTimestampRecordExtension implements DynamoDbEnhancedClientExtension {
91+
92+
private static final String NESTED_OBJECT_UPDATE = "_NESTED_ATTR_UPDATE_";
7193
private static final String CUSTOM_METADATA_KEY = "AutoGeneratedTimestampExtension:AutoGeneratedTimestampAttribute";
7294
private static final AutoGeneratedTimestampAttribute
7395
AUTO_GENERATED_TIMESTAMP_ATTRIBUTE = new AutoGeneratedTimestampAttribute();
@@ -126,26 +148,179 @@ public static AutoGeneratedTimestampRecordExtension create() {
126148
*/
127149
@Override
128150
public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) {
151+
Map<String, AttributeValue> itemToTransform = new HashMap<>(context.items());
152+
153+
Map<String, AttributeValue> updatedItems = new HashMap<>();
154+
Instant currentInstant = clock.instant();
129155

130-
Collection<String> customMetadataObject = context.tableMetadata()
131-
.customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null);
156+
// Use TableSchema<?> instance as the cache key
157+
Map<TableSchema<?>, TableSchema<?>> schemaInstanceCache = new HashMap<>();
132158

133-
if (customMetadataObject == null) {
159+
itemToTransform.forEach((key, value) -> {
160+
if (value.hasM() && value.m() != null) {
161+
Optional<? extends TableSchema<?>> nestedSchemaOpt = getNestedSchema(context.tableSchema(), key);
162+
if (nestedSchemaOpt.isPresent()) {
163+
TableSchema<?> nestedSchema = nestedSchemaOpt.get();
164+
TableSchema<?> cachedSchema = getOrCacheSchema(schemaInstanceCache, nestedSchema);
165+
Map<String, AttributeValue> processed =
166+
processNestedObject(value.m(), cachedSchema, currentInstant, schemaInstanceCache);
167+
updatedItems.put(key, AttributeValue.builder().m(processed).build());
168+
}
169+
} else if (value.hasL() && !value.l().isEmpty()) {
170+
// Check first non-null element to determine if this is a list of maps
171+
AttributeValue firstElement = value.l().stream()
172+
.filter(Objects::nonNull)
173+
.findFirst()
174+
.orElse(null);
175+
176+
if (firstElement != null && firstElement.hasM()) {
177+
TableSchema<?> elementListSchema = getTableSchemaForListElement(context.tableSchema(), key);
178+
if (elementListSchema != null) {
179+
TableSchema<?> cachedSchema = getOrCacheSchema(schemaInstanceCache, elementListSchema);
180+
Collection<AttributeValue> updatedList = new ArrayList<>(value.l().size());
181+
for (AttributeValue listItem : value.l()) {
182+
if (listItem != null && listItem.hasM()) {
183+
updatedList.add(AttributeValue.builder()
184+
.m(processNestedObject(
185+
listItem.m(),
186+
cachedSchema,
187+
currentInstant,
188+
schemaInstanceCache))
189+
.build());
190+
} else {
191+
updatedList.add(listItem);
192+
}
193+
}
194+
updatedItems.put(key, AttributeValue.builder().l(updatedList).build());
195+
}
196+
}
197+
}
198+
});
199+
200+
Map<String, TableSchema<?>> stringTableSchemaMap = resolveSchemasPerPath(itemToTransform, context.tableSchema());
201+
202+
stringTableSchemaMap.forEach((path, schema) -> {
203+
Collection<String> customMetadataObject = schema.tableMetadata()
204+
.customMetadataObject(CUSTOM_METADATA_KEY, Collection.class)
205+
.orElse(null);
206+
207+
if (customMetadataObject != null) {
208+
customMetadataObject.forEach(
209+
key -> insertTimestampInItemToTransform(updatedItems, reconstructCompositeKey(path, key),
210+
schema.converterForAttribute(key), currentInstant));
211+
}
212+
});
213+
214+
if (updatedItems.isEmpty()) {
134215
return WriteModification.builder().build();
135216
}
136-
Map<String, AttributeValue> itemToTransform = new HashMap<>(context.items());
137-
customMetadataObject.forEach(
138-
key -> insertTimestampInItemToTransform(itemToTransform, key,
139-
context.tableSchema().converterForAttribute(key)));
217+
218+
itemToTransform.putAll(updatedItems);
219+
140220
return WriteModification.builder()
141221
.transformedItem(Collections.unmodifiableMap(itemToTransform))
142222
.build();
143223
}
144224

225+
private static TableSchema<?> getOrCacheSchema(
226+
Map<TableSchema<?>, TableSchema<?>> schemaInstanceCache, TableSchema<?> schema) {
227+
228+
TableSchema<?> cachedSchema = schemaInstanceCache.get(schema);
229+
if (cachedSchema == null) {
230+
schemaInstanceCache.put(schema, schema);
231+
cachedSchema = schema;
232+
}
233+
return cachedSchema;
234+
}
235+
236+
private Map<String, AttributeValue> processNestedObject(Map<String, AttributeValue> nestedMap, TableSchema<?> nestedSchema,
237+
Instant currentInstant,
238+
Map<TableSchema<?>, TableSchema<?>> schemaInstanceCache) {
239+
Map<String, AttributeValue> updatedNestedMap = nestedMap;
240+
boolean updated = false;
241+
242+
Collection<String> customMetadataObject = nestedSchema.tableMetadata()
243+
.customMetadataObject(CUSTOM_METADATA_KEY, Collection.class)
244+
.orElse(null);
245+
246+
if (customMetadataObject != null) {
247+
for (String key : customMetadataObject) {
248+
if (!updated) {
249+
updatedNestedMap = new HashMap<>(nestedMap);
250+
updated = true;
251+
}
252+
insertTimestampInItemToTransform(updatedNestedMap, String.valueOf(key),
253+
nestedSchema.converterForAttribute(key), currentInstant);
254+
}
255+
}
256+
257+
for (Map.Entry<String, AttributeValue> entry : nestedMap.entrySet()) {
258+
String nestedKey = entry.getKey();
259+
AttributeValue nestedValue = entry.getValue();
260+
if (nestedValue.hasM()) {
261+
Optional<? extends TableSchema<?>> childSchemaOpt = getNestedSchema(nestedSchema, nestedKey);
262+
if (childSchemaOpt.isPresent()) {
263+
TableSchema<?> childSchema = childSchemaOpt.get();
264+
TableSchema<?> cachedSchema = getOrCacheSchema(schemaInstanceCache, childSchema);
265+
Map<String, AttributeValue> processed = processNestedObject(
266+
nestedValue.m(), cachedSchema, currentInstant, schemaInstanceCache);
267+
268+
if (!Objects.equals(processed, nestedValue.m())) {
269+
if (!updated) {
270+
updatedNestedMap = new HashMap<>(nestedMap);
271+
updated = true;
272+
}
273+
updatedNestedMap.put(nestedKey, AttributeValue.builder().m(processed).build());
274+
}
275+
}
276+
} else if (nestedValue.hasL() && !nestedValue.l().isEmpty()) {
277+
// Check first non-null element to determine if this is a list of maps
278+
AttributeValue firstElement = nestedValue.l().stream()
279+
.filter(Objects::nonNull)
280+
.findFirst()
281+
.orElse(null);
282+
if (firstElement != null && firstElement.hasM()) {
283+
TableSchema<?> listElementSchema = getTableSchemaForListElement(nestedSchema, nestedKey);
284+
if (listElementSchema != null) {
285+
TableSchema<?> cachedSchema = getOrCacheSchema(schemaInstanceCache, listElementSchema);
286+
Collection<AttributeValue> updatedList = new ArrayList<>(nestedValue.l().size());
287+
boolean listModified = false;
288+
for (AttributeValue listItem : nestedValue.l()) {
289+
if (listItem != null && listItem.hasM()) {
290+
AttributeValue updatedItem = AttributeValue.builder()
291+
.m(processNestedObject(
292+
listItem.m(),
293+
cachedSchema,
294+
currentInstant,
295+
schemaInstanceCache))
296+
.build();
297+
updatedList.add(updatedItem);
298+
if (!Objects.equals(updatedItem, listItem)) {
299+
listModified = true;
300+
}
301+
} else {
302+
updatedList.add(listItem);
303+
}
304+
}
305+
if (listModified) {
306+
if (!updated) {
307+
updatedNestedMap = new HashMap<>(nestedMap);
308+
updated = true;
309+
}
310+
updatedNestedMap.put(nestedKey, AttributeValue.builder().l(updatedList).build());
311+
}
312+
}
313+
}
314+
}
315+
}
316+
return updatedNestedMap;
317+
}
318+
145319
private void insertTimestampInItemToTransform(Map<String, AttributeValue> itemToTransform,
146320
String key,
147-
AttributeConverter converter) {
148-
itemToTransform.put(key, converter.transformFrom(clock.instant()));
321+
AttributeConverter converter,
322+
Instant instant) {
323+
itemToTransform.put(key, converter.transformFrom(instant));
149324
}
150325

151326
/**
@@ -190,6 +365,7 @@ public <R> void validateType(String attributeName, EnhancedType<R> type,
190365
Validate.notNull(type, "type is null");
191366
Validate.notNull(type.rawClass(), "rawClass is null");
192367
Validate.notNull(attributeValueType, "attributeValueType is null");
368+
validateAttributeName(attributeName);
193369

194370
if (!type.rawClass().equals(Instant.class)) {
195371
throw new IllegalArgumentException(String.format(
@@ -204,5 +380,15 @@ public Consumer<StaticTableMetadata.Builder> modifyMetadata(String attributeName
204380
return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, Collections.singleton(attributeName))
205381
.markAttributeAsKey(attributeName, attributeValueType);
206382
}
383+
384+
private static void validateAttributeName(String attributeName) {
385+
if (attributeName.contains(NESTED_OBJECT_UPDATE)) {
386+
throw new IllegalArgumentException(
387+
String.format(
388+
"Attribute name '%s' contains reserved marker '%s' and is not allowed.",
389+
attributeName,
390+
NESTED_OBJECT_UPDATE));
391+
}
392+
}
207393
}
208394
}

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import java.util.stream.Stream;
3030
import software.amazon.awssdk.annotations.SdkInternalApi;
3131
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension;
32+
import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
3233
import software.amazon.awssdk.enhanced.dynamodb.Key;
3334
import software.amazon.awssdk.enhanced.dynamodb.OperationContext;
3435
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
@@ -204,4 +205,24 @@ public static <T> List<T> getItemsFromSupplier(List<Supplier<T>> itemSupplierLis
204205
public static boolean isNullAttributeValue(AttributeValue attributeValue) {
205206
return attributeValue.nul() != null && attributeValue.nul();
206207
}
208+
209+
/**
210+
* Retrieves the {@link TableSchema} for a nested attribute within the given parent schema. When the attribute is a
211+
* parameterized type (e.g., List<?>), it retrieves the schema of the first type parameter. Otherwise, it retrieves the schema
212+
* directly from the attribute's enhanced type.
213+
*
214+
* @param parentSchema the schema of the parent bean class
215+
* @param attributeName the name of the nested attribute
216+
* @return an {@link Optional} containing the nested attribute's {@link TableSchema}, or empty if unavailable
217+
*/
218+
public static Optional<? extends TableSchema<?>> getNestedSchema(TableSchema<?> parentSchema, String attributeName) {
219+
EnhancedType<?> enhancedType = parentSchema.converterForAttribute(attributeName).type();
220+
List<EnhancedType<?>> rawClassParameters = enhancedType.rawClassParameters();
221+
222+
if (rawClassParameters != null && !rawClassParameters.isEmpty()) {
223+
enhancedType = rawClassParameters.get(0);
224+
}
225+
226+
return enhancedType.tableSchema();
227+
}
207228
}

0 commit comments

Comments
 (0)