|
15 | 15 |
|
16 | 16 | package software.amazon.awssdk.enhanced.dynamodb.extensions;
|
17 | 17 |
|
| 18 | +import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.getNestedSchema; |
| 19 | +import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE; |
| 20 | + |
18 | 21 | import java.time.Clock;
|
19 | 22 | import java.time.Instant;
|
20 | 23 | import java.util.Collection;
|
21 | 24 | import java.util.Collections;
|
22 | 25 | import java.util.HashMap;
|
| 26 | +import java.util.List; |
23 | 27 | import java.util.Map;
|
| 28 | +import java.util.Optional; |
24 | 29 | import java.util.function.Consumer;
|
| 30 | +import java.util.regex.Pattern; |
| 31 | +import java.util.stream.Collectors; |
25 | 32 | import software.amazon.awssdk.annotations.NotThreadSafe;
|
26 | 33 | import software.amazon.awssdk.annotations.SdkPublicApi;
|
27 | 34 | import software.amazon.awssdk.annotations.ThreadSafe;
|
|
30 | 37 | import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension;
|
31 | 38 | import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext;
|
32 | 39 | import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
|
| 40 | +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; |
33 | 41 | import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag;
|
34 | 42 | import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata;
|
35 | 43 | import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
|
64 | 72 | * <p>
|
65 | 73 | * Every time a new update of the record is successfully written to the database, the timestamp at which it was modified will
|
66 | 74 | * be automatically updated. This extension applies the conversions as defined in the attribute convertor.
|
| 75 | + * The implementation handles both flattened nested parameters (identified by keys separated with |
| 76 | + * {@code "_NESTED_ATTR_UPDATE_"}) and entire nested maps or lists, ensuring consistent behavior across both representations. |
| 77 | + * The same timestamp value is used for both top-level attributes and all applicable nested fields. |
67 | 78 | */
|
68 | 79 | @SdkPublicApi
|
69 | 80 | @ThreadSafe
|
70 | 81 | public final class AutoGeneratedTimestampRecordExtension implements DynamoDbEnhancedClientExtension {
|
71 | 82 | private static final String CUSTOM_METADATA_KEY = "AutoGeneratedTimestampExtension:AutoGeneratedTimestampAttribute";
|
72 | 83 | private static final AutoGeneratedTimestampAttribute
|
73 | 84 | AUTO_GENERATED_TIMESTAMP_ATTRIBUTE = new AutoGeneratedTimestampAttribute();
|
| 85 | + private static final Pattern NESTED_OBJECT_PATTERN = Pattern.compile(NESTED_OBJECT_UPDATE); |
74 | 86 | private final Clock clock;
|
75 | 87 |
|
76 | 88 | private AutoGeneratedTimestampRecordExtension() {
|
@@ -126,26 +138,155 @@ public static AutoGeneratedTimestampRecordExtension create() {
|
126 | 138 | */
|
127 | 139 | @Override
|
128 | 140 | public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) {
|
| 141 | + Map<String, AttributeValue> itemToTransform = new HashMap<>(context.items()); |
| 142 | + |
| 143 | + Map<String, AttributeValue> updatedItems = new HashMap<>(); |
| 144 | + Instant currentInstant = clock.instant(); |
129 | 145 |
|
130 |
| - Collection<String> customMetadataObject = context.tableMetadata() |
131 |
| - .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null); |
| 146 | + itemToTransform.forEach((key, value) -> { |
| 147 | + if (value.hasM() && value.m() != null) { |
| 148 | + Optional<? extends TableSchema<?>> nestedSchema = getNestedSchema(context.tableSchema(), key); |
| 149 | + if (nestedSchema.isPresent()) { |
| 150 | + Map<String, AttributeValue> processed = processNestedObject(value.m(), nestedSchema.get(), currentInstant); |
| 151 | + updatedItems.put(key, AttributeValue.builder().m(processed).build()); |
| 152 | + } |
| 153 | + } else if (value.hasL() && !value.l().isEmpty() && value.l().get(0).hasM()) { |
| 154 | + TableSchema<?> elementListSchema = getTableSchemaForElementList(context.tableSchema(), key); |
| 155 | + |
| 156 | + List<AttributeValue> updatedList = value.l().stream() |
| 157 | + .map(listItem -> listItem.hasM() ? |
| 158 | + AttributeValue.builder().m(processNestedObject(listItem.m(), elementListSchema, currentInstant)).build() : listItem) |
| 159 | + .collect(Collectors.toList()); |
| 160 | + updatedItems.put(key, AttributeValue.builder().l(updatedList).build()); |
| 161 | + } |
| 162 | + }); |
| 163 | + |
| 164 | + Map<String, TableSchema<?>> stringTableSchemaMap = resolveSchemasPerPath(itemToTransform, context.tableSchema()); |
| 165 | + |
| 166 | + stringTableSchemaMap.forEach((path, schema) -> { |
| 167 | + Collection<String> customMetadataObject = schema.tableMetadata() |
| 168 | + .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null); |
| 169 | + |
| 170 | + if (customMetadataObject != null) { |
| 171 | + customMetadataObject.forEach( |
| 172 | + key -> insertTimestampInItemToTransform(updatedItems, reconstructCompositeKey(path, key), |
| 173 | + schema.converterForAttribute(key), currentInstant)); |
| 174 | + } |
| 175 | + }); |
132 | 176 |
|
133 |
| - if (customMetadataObject == null) { |
| 177 | + if (updatedItems.isEmpty()) { |
134 | 178 | return WriteModification.builder().build();
|
135 | 179 | }
|
136 |
| - Map<String, AttributeValue> itemToTransform = new HashMap<>(context.items()); |
137 |
| - customMetadataObject.forEach( |
138 |
| - key -> insertTimestampInItemToTransform(itemToTransform, key, |
139 |
| - context.tableSchema().converterForAttribute(key))); |
| 180 | + |
| 181 | + itemToTransform.putAll(updatedItems); |
| 182 | + |
140 | 183 | return WriteModification.builder()
|
141 | 184 | .transformedItem(Collections.unmodifiableMap(itemToTransform))
|
142 | 185 | .build();
|
143 | 186 | }
|
144 | 187 |
|
| 188 | + private TableSchema<?> getTableSchemaForElementList(TableSchema<?> rootSchema, String key) { |
| 189 | + TableSchema<?> elementListSchema; |
| 190 | + try { |
| 191 | + if (!key.contains(NESTED_OBJECT_UPDATE)) { |
| 192 | + elementListSchema = TableSchema.fromClass( |
| 193 | + Class.forName(rootSchema.converterForAttribute(key).type().rawClassParameters().get(0).rawClass().getName())); |
| 194 | + } else { |
| 195 | + String[] parts = NESTED_OBJECT_PATTERN.split(key); |
| 196 | + TableSchema<?> currentSchema = rootSchema; |
| 197 | + |
| 198 | + for (int i = 0; i < parts.length - 1; i++) { |
| 199 | + Optional<? extends TableSchema<?>> nestedSchema = getNestedSchema(currentSchema, parts[i]); |
| 200 | + if (nestedSchema.isPresent()) { |
| 201 | + currentSchema = nestedSchema.get(); |
| 202 | + } |
| 203 | + } |
| 204 | + String attributeName = parts[parts.length - 1]; |
| 205 | + elementListSchema = TableSchema.fromClass( |
| 206 | + Class.forName(currentSchema.converterForAttribute(attributeName).type().rawClassParameters().get(0).rawClass().getName())); |
| 207 | + } |
| 208 | + } catch (ClassNotFoundException e) { |
| 209 | + throw new IllegalArgumentException("Class not found for field name: " + key, e); |
| 210 | + } |
| 211 | + return elementListSchema; |
| 212 | + } |
| 213 | + |
| 214 | + private Map<String, TableSchema<?>> resolveSchemasPerPath(Map<String, AttributeValue> attributesToSet, |
| 215 | + TableSchema<?> rootSchema) { |
| 216 | + Map<String, TableSchema<?>> schemaMap = new HashMap<>(); |
| 217 | + schemaMap.put("", rootSchema); |
| 218 | + |
| 219 | + for (String key : attributesToSet.keySet()) { |
| 220 | + String[] parts = NESTED_OBJECT_PATTERN.split(key); |
| 221 | + |
| 222 | + String path = ""; |
| 223 | + TableSchema<?> currentSchema = rootSchema; |
| 224 | + |
| 225 | + for (int i = 0; i < parts.length - 1; i++) { |
| 226 | + path = path.isEmpty() ? parts[i] : path + "." + parts[i]; |
| 227 | + |
| 228 | + if (!schemaMap.containsKey(path)) { |
| 229 | + Optional<? extends TableSchema<?>> nestedSchema = getNestedSchema(currentSchema, parts[i]); |
| 230 | + if (nestedSchema.isPresent()) { |
| 231 | + schemaMap.put(path, nestedSchema.get()); |
| 232 | + currentSchema = nestedSchema.get(); |
| 233 | + } |
| 234 | + } else { |
| 235 | + currentSchema = schemaMap.get(path); |
| 236 | + } |
| 237 | + } |
| 238 | + } |
| 239 | + return schemaMap; |
| 240 | + } |
| 241 | + |
| 242 | + private static String reconstructCompositeKey(String path, String attributeName) { |
| 243 | + if (path == null || path.isEmpty()) { |
| 244 | + return attributeName; |
| 245 | + } |
| 246 | + return String.join(NESTED_OBJECT_UPDATE, path.split("\\.")) |
| 247 | + + NESTED_OBJECT_UPDATE + attributeName; |
| 248 | + } |
| 249 | + |
| 250 | + private Map<String, AttributeValue> processNestedObject(Map<String, AttributeValue> nestedMap, TableSchema<?> nestedSchema, |
| 251 | + Instant currentInstant) { |
| 252 | + Map<String, AttributeValue> updatedNestedMap = new HashMap<>(nestedMap); |
| 253 | + Collection<String> customMetadataObject = nestedSchema.tableMetadata() |
| 254 | + .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null); |
| 255 | + |
| 256 | + if (customMetadataObject!= null) { |
| 257 | + customMetadataObject.forEach( |
| 258 | + key -> insertTimestampInItemToTransform(updatedNestedMap, String.valueOf(key), |
| 259 | + nestedSchema.converterForAttribute(key), currentInstant)); |
| 260 | + } |
| 261 | + |
| 262 | + nestedMap.forEach((nestedKey, nestedValue) -> { |
| 263 | + if (nestedValue.hasM()) { |
| 264 | + updatedNestedMap.put(nestedKey, |
| 265 | + AttributeValue.builder().m(processNestedObject(nestedValue.m(), nestedSchema, |
| 266 | + currentInstant)).build()); |
| 267 | + } else if (nestedValue.hasL() && !nestedValue.l().isEmpty() |
| 268 | + && nestedValue.l().get(0).hasM()) { |
| 269 | + try { |
| 270 | + TableSchema<?> listElementSchema = TableSchema.fromClass( |
| 271 | + Class.forName(nestedSchema.converterForAttribute(nestedKey).type().rawClassParameters().get(0).rawClass().getName())); |
| 272 | + List<AttributeValue> updatedList = nestedValue.l().stream() |
| 273 | + .map(listItem -> listItem.hasM() ? |
| 274 | + AttributeValue.builder().m(processNestedObject(listItem.m(), listElementSchema, currentInstant)).build() : listItem) |
| 275 | + .collect(Collectors.toList()); |
| 276 | + updatedNestedMap.put(nestedKey, AttributeValue.builder().l(updatedList).build()); |
| 277 | + } catch (ClassNotFoundException e) { |
| 278 | + throw new IllegalArgumentException("Class not found for field name: " + nestedKey, e); |
| 279 | + } |
| 280 | + } |
| 281 | + }); |
| 282 | + return updatedNestedMap; |
| 283 | + } |
| 284 | + |
145 | 285 | private void insertTimestampInItemToTransform(Map<String, AttributeValue> itemToTransform,
|
146 | 286 | String key,
|
147 |
| - AttributeConverter converter) { |
148 |
| - itemToTransform.put(key, converter.transformFrom(clock.instant())); |
| 287 | + AttributeConverter converter, |
| 288 | + Instant instant) { |
| 289 | + itemToTransform.put(key, converter.transformFrom(instant)); |
149 | 290 | }
|
150 | 291 |
|
151 | 292 | /**
|
|
0 commit comments