1515
1616package 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+
1823import java .time .Clock ;
1924import java .time .Instant ;
25+ import java .util .ArrayList ;
2026import java .util .Collection ;
2127import java .util .Collections ;
2228import java .util .HashMap ;
2329import java .util .Map ;
30+ import java .util .Objects ;
31+ import java .util .Optional ;
2432import java .util .function .Consumer ;
2533import software .amazon .awssdk .annotations .NotThreadSafe ;
2634import software .amazon .awssdk .annotations .SdkPublicApi ;
3038import software .amazon .awssdk .enhanced .dynamodb .DynamoDbEnhancedClientExtension ;
3139import software .amazon .awssdk .enhanced .dynamodb .DynamoDbExtensionContext ;
3240import software .amazon .awssdk .enhanced .dynamodb .EnhancedType ;
41+ import software .amazon .awssdk .enhanced .dynamodb .TableSchema ;
3342import software .amazon .awssdk .enhanced .dynamodb .mapper .StaticAttributeTag ;
3443import software .amazon .awssdk .enhanced .dynamodb .mapper .StaticTableMetadata ;
3544import software .amazon .awssdk .services .dynamodb .model .AttributeValue ;
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
7090public 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}
0 commit comments