|
15 | 15 |
|
16 | 16 | package software.amazon.awssdk.enhanced.dynamodb.extensions;
|
17 | 17 |
|
| 18 | +import static software.amazon.awssdk.enhanced.dynamodb.extensions.utility.NestedRecordUtils.getTableSchemaForListElement; |
| 19 | +import static software.amazon.awssdk.enhanced.dynamodb.extensions.utility.NestedRecordUtils.reconstructCompositeKey; |
| 20 | +import static software.amazon.awssdk.enhanced.dynamodb.extensions.utility.NestedRecordUtils.resolveSchemasPerPath; |
| 21 | +import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.getNestedSchema; |
| 22 | + |
18 | 23 | import java.time.Clock;
|
19 | 24 | import java.time.Instant;
|
20 | 25 | import java.util.Collection;
|
21 | 26 | import java.util.Collections;
|
22 | 27 | import java.util.HashMap;
|
| 28 | +import java.util.List; |
23 | 29 | import java.util.Map;
|
| 30 | +import java.util.Optional; |
24 | 31 | import java.util.function.Consumer;
|
| 32 | +import java.util.stream.Collectors; |
25 | 33 | import software.amazon.awssdk.annotations.NotThreadSafe;
|
26 | 34 | import software.amazon.awssdk.annotations.SdkPublicApi;
|
27 | 35 | import software.amazon.awssdk.annotations.ThreadSafe;
|
|
30 | 38 | import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension;
|
31 | 39 | import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext;
|
32 | 40 | import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
|
| 41 | +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; |
33 | 42 | import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag;
|
34 | 43 | import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata;
|
35 | 44 | import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
|
64 | 73 | * <p>
|
65 | 74 | * Every time a new update of the record is successfully written to the database, the timestamp at which it was modified will
|
66 | 75 | * 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. |
67 | 80 | */
|
68 | 81 | @SdkPublicApi
|
69 | 82 | @ThreadSafe
|
@@ -126,26 +139,113 @@ public static AutoGeneratedTimestampRecordExtension create() {
|
126 | 139 | */
|
127 | 140 | @Override
|
128 | 141 | public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) {
|
| 142 | + Map<String, AttributeValue> itemToTransform = new HashMap<>(context.items()); |
| 143 | + |
| 144 | + Map<String, AttributeValue> updatedItems = new HashMap<>(); |
| 145 | + Instant currentInstant = clock.instant(); |
| 146 | + |
| 147 | + itemToTransform.forEach((key, value) -> { |
| 148 | + if (value.hasM() && value.m() != null) { |
| 149 | + Optional<? extends TableSchema<?>> nestedSchema = getNestedSchema(context.tableSchema(), key); |
| 150 | + if (nestedSchema.isPresent()) { |
| 151 | + Map<String, AttributeValue> processed = processNestedObject(value.m(), nestedSchema.get(), currentInstant); |
| 152 | + updatedItems.put(key, AttributeValue.builder().m(processed).build()); |
| 153 | + } |
| 154 | + } else if (value.hasL() && !value.l().isEmpty() && value.l().get(0).hasM()) { |
| 155 | + TableSchema<?> elementListSchema = getTableSchemaForListElement(context.tableSchema(), key); |
| 156 | + |
| 157 | + List<AttributeValue> updatedList = value.l() |
| 158 | + .stream() |
| 159 | + .map(listItem -> listItem.hasM() ? |
| 160 | + AttributeValue.builder() |
| 161 | + .m(processNestedObject(listItem.m(), |
| 162 | + elementListSchema, |
| 163 | + currentInstant)) |
| 164 | + .build() : listItem) |
| 165 | + .collect(Collectors.toList()); |
| 166 | + updatedItems.put(key, AttributeValue.builder().l(updatedList).build()); |
| 167 | + } |
| 168 | + }); |
| 169 | + |
| 170 | + Map<String, TableSchema<?>> stringTableSchemaMap = resolveSchemasPerPath(itemToTransform, context.tableSchema()); |
129 | 171 |
|
130 |
| - Collection<String> customMetadataObject = context.tableMetadata() |
131 |
| - .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null); |
| 172 | + stringTableSchemaMap.forEach((path, schema) -> { |
| 173 | + Collection<String> customMetadataObject = schema.tableMetadata() |
| 174 | + .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class) |
| 175 | + .orElse(null); |
132 | 176 |
|
133 |
| - if (customMetadataObject == null) { |
| 177 | + if (customMetadataObject != null) { |
| 178 | + customMetadataObject.forEach( |
| 179 | + key -> insertTimestampInItemToTransform(updatedItems, reconstructCompositeKey(path, key), |
| 180 | + schema.converterForAttribute(key), currentInstant)); |
| 181 | + } |
| 182 | + }); |
| 183 | + |
| 184 | + if (updatedItems.isEmpty()) { |
134 | 185 | return WriteModification.builder().build();
|
135 | 186 | }
|
136 |
| - Map<String, AttributeValue> itemToTransform = new HashMap<>(context.items()); |
137 |
| - customMetadataObject.forEach( |
138 |
| - key -> insertTimestampInItemToTransform(itemToTransform, key, |
139 |
| - context.tableSchema().converterForAttribute(key))); |
| 187 | + |
| 188 | + itemToTransform.putAll(updatedItems); |
| 189 | + |
140 | 190 | return WriteModification.builder()
|
141 | 191 | .transformedItem(Collections.unmodifiableMap(itemToTransform))
|
142 | 192 | .build();
|
143 | 193 | }
|
144 | 194 |
|
| 195 | + private Map<String, AttributeValue> processNestedObject(Map<String, AttributeValue> nestedMap, TableSchema<?> nestedSchema, |
| 196 | + Instant currentInstant) { |
| 197 | + Map<String, AttributeValue> updatedNestedMap = new HashMap<>(nestedMap); |
| 198 | + Collection<String> customMetadataObject = nestedSchema.tableMetadata() |
| 199 | + .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null); |
| 200 | + |
| 201 | + if (customMetadataObject != null) { |
| 202 | + customMetadataObject.forEach( |
| 203 | + key -> insertTimestampInItemToTransform(updatedNestedMap, String.valueOf(key), |
| 204 | + nestedSchema.converterForAttribute(key), currentInstant)); |
| 205 | + } |
| 206 | + |
| 207 | + nestedMap.forEach((nestedKey, nestedValue) -> { |
| 208 | + if (nestedValue.hasM()) { |
| 209 | + // Resolve the child-attribute schema; default to the current (parent) schema if none is registered |
| 210 | + Optional<? extends TableSchema<?>> childSchemaOptional = getNestedSchema(nestedSchema, nestedKey); |
| 211 | + TableSchema<?> schemaToUse = childSchemaOptional.isPresent() |
| 212 | + ? childSchemaOptional.get() |
| 213 | + : nestedSchema; |
| 214 | + |
| 215 | + updatedNestedMap.put(nestedKey, AttributeValue.builder() |
| 216 | + .m(processNestedObject( |
| 217 | + nestedValue.m(), schemaToUse, currentInstant)) |
| 218 | + .build()); |
| 219 | + |
| 220 | + } else if (nestedValue.hasL() && !nestedValue.l().isEmpty() |
| 221 | + && nestedValue.l().get(0).hasM()) { |
| 222 | + try { |
| 223 | + TableSchema<?> listElementSchema = TableSchema.fromClass( |
| 224 | + Class.forName(nestedSchema.converterForAttribute(nestedKey) |
| 225 | + .type().rawClassParameters().get(0).rawClass().getName())); |
| 226 | + List<AttributeValue> updatedList = nestedValue |
| 227 | + .l() |
| 228 | + .stream() |
| 229 | + .map(listItem -> listItem.hasM() ? |
| 230 | + AttributeValue.builder() |
| 231 | + .m(processNestedObject(listItem.m(), |
| 232 | + listElementSchema, |
| 233 | + currentInstant)).build() : listItem) |
| 234 | + .collect(Collectors.toList()); |
| 235 | + updatedNestedMap.put(nestedKey, AttributeValue.builder().l(updatedList).build()); |
| 236 | + } catch (ClassNotFoundException e) { |
| 237 | + throw new IllegalArgumentException("Class not found for field name: " + nestedKey, e); |
| 238 | + } |
| 239 | + } |
| 240 | + }); |
| 241 | + return updatedNestedMap; |
| 242 | + } |
| 243 | + |
145 | 244 | private void insertTimestampInItemToTransform(Map<String, AttributeValue> itemToTransform,
|
146 | 245 | String key,
|
147 |
| - AttributeConverter converter) { |
148 |
| - itemToTransform.put(key, converter.transformFrom(clock.instant())); |
| 246 | + AttributeConverter converter, |
| 247 | + Instant instant) { |
| 248 | + itemToTransform.put(key, converter.transformFrom(instant)); |
149 | 249 | }
|
150 | 250 |
|
151 | 251 | /**
|
|
0 commit comments