-
Notifications
You must be signed in to change notification settings - Fork 912
Support AutoGeneratedTimestamp and UpdateBehavior annotation in nested objects #6109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
{ | ||
"type": "feature", | ||
"category": "Amazon DynamoDB Enhanced Client", | ||
"contributor": "", | ||
"description": "Added support for @DynamoDbAutoGeneratedTimestampAttribute and @DynamoDbUpdateBehavior on attributes within nested objects. The @DynamoDbUpdateBehavior annotation will only take effect for nested attributes when using IgnoreNullsMode.SCALAR_ONLY." | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,13 +15,20 @@ | |
|
||
package software.amazon.awssdk.enhanced.dynamodb.extensions; | ||
|
||
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.getNestedSchema; | ||
import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE; | ||
|
||
import java.time.Clock; | ||
import java.time.Instant; | ||
import java.util.Collection; | ||
import java.util.Collections; | ||
import java.util.HashMap; | ||
import java.util.List; | ||
import java.util.Map; | ||
import java.util.Optional; | ||
import java.util.function.Consumer; | ||
import java.util.regex.Pattern; | ||
import java.util.stream.Collectors; | ||
import software.amazon.awssdk.annotations.NotThreadSafe; | ||
import software.amazon.awssdk.annotations.SdkPublicApi; | ||
import software.amazon.awssdk.annotations.ThreadSafe; | ||
|
@@ -30,6 +37,7 @@ | |
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; | ||
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext; | ||
import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; | ||
import software.amazon.awssdk.enhanced.dynamodb.TableSchema; | ||
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; | ||
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; | ||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue; | ||
|
@@ -64,13 +72,17 @@ | |
* <p> | ||
* Every time a new update of the record is successfully written to the database, the timestamp at which it was modified will | ||
* be automatically updated. This extension applies the conversions as defined in the attribute convertor. | ||
* The implementation handles both flattened nested parameters (identified by keys separated with | ||
* {@code "_NESTED_ATTR_UPDATE_"}) and entire nested maps or lists, ensuring consistent behavior across both representations. | ||
* The same timestamp value is used for both top-level attributes and all applicable nested fields. | ||
*/ | ||
@SdkPublicApi | ||
@ThreadSafe | ||
public final class AutoGeneratedTimestampRecordExtension implements DynamoDbEnhancedClientExtension { | ||
private static final String CUSTOM_METADATA_KEY = "AutoGeneratedTimestampExtension:AutoGeneratedTimestampAttribute"; | ||
private static final AutoGeneratedTimestampAttribute | ||
AUTO_GENERATED_TIMESTAMP_ATTRIBUTE = new AutoGeneratedTimestampAttribute(); | ||
private static final Pattern NESTED_OBJECT_PATTERN = Pattern.compile(NESTED_OBJECT_UPDATE); | ||
private final Clock clock; | ||
|
||
private AutoGeneratedTimestampRecordExtension() { | ||
|
@@ -126,26 +138,173 @@ public static AutoGeneratedTimestampRecordExtension create() { | |
*/ | ||
@Override | ||
public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) { | ||
Map<String, AttributeValue> itemToTransform = new HashMap<>(context.items()); | ||
|
||
Map<String, AttributeValue> updatedItems = new HashMap<>(); | ||
Instant currentInstant = clock.instant(); | ||
|
||
Collection<String> customMetadataObject = context.tableMetadata() | ||
.customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null); | ||
itemToTransform.forEach((key, value) -> { | ||
if (value.hasM() && value.m() != null) { | ||
Optional<? extends TableSchema<?>> nestedSchema = getNestedSchema(context.tableSchema(), key); | ||
if (nestedSchema.isPresent()) { | ||
Map<String, AttributeValue> processed = processNestedObject(value.m(), nestedSchema.get(), currentInstant); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a possible performance degradation for deeply nested records? |
||
updatedItems.put(key, AttributeValue.builder().m(processed).build()); | ||
} | ||
} else if (value.hasL() && !value.l().isEmpty() && value.l().get(0).hasM()) { | ||
TableSchema<?> elementListSchema = getTableSchemaForListElement(context.tableSchema(), key); | ||
|
||
List<AttributeValue> updatedList = value.l() | ||
.stream() | ||
.map(listItem -> listItem.hasM() ? | ||
AttributeValue.builder() | ||
.m(processNestedObject(listItem.m(), | ||
elementListSchema, | ||
currentInstant)) | ||
.build() : listItem) | ||
.collect(Collectors.toList()); | ||
updatedItems.put(key, AttributeValue.builder().l(updatedList).build()); | ||
} | ||
}); | ||
|
||
Map<String, TableSchema<?>> stringTableSchemaMap = resolveSchemasPerPath(itemToTransform, context.tableSchema()); | ||
|
||
stringTableSchemaMap.forEach((path, schema) -> { | ||
Collection<String> customMetadataObject = schema.tableMetadata() | ||
.customMetadataObject(CUSTOM_METADATA_KEY, Collection.class) | ||
.orElse(null); | ||
|
||
if (customMetadataObject != null) { | ||
customMetadataObject.forEach( | ||
key -> insertTimestampInItemToTransform(updatedItems, reconstructCompositeKey(path, key), | ||
schema.converterForAttribute(key), currentInstant)); | ||
} | ||
}); | ||
|
||
if (customMetadataObject == null) { | ||
if (updatedItems.isEmpty()) { | ||
return WriteModification.builder().build(); | ||
} | ||
Map<String, AttributeValue> itemToTransform = new HashMap<>(context.items()); | ||
customMetadataObject.forEach( | ||
key -> insertTimestampInItemToTransform(itemToTransform, key, | ||
context.tableSchema().converterForAttribute(key))); | ||
|
||
itemToTransform.putAll(updatedItems); | ||
|
||
return WriteModification.builder() | ||
.transformedItem(Collections.unmodifiableMap(itemToTransform)) | ||
.build(); | ||
} | ||
|
||
private TableSchema<?> getTableSchemaForListElement(TableSchema<?> rootSchema, String key) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some of these private utility functions seem generic to nested records rather than to auto generated timestamps - I'm not against keeping them here since it limits the scope and I'm not all that familiar with this library - but would it make sense to move these to a utility? |
||
TableSchema<?> listElementSchema; | ||
try { | ||
if (!key.contains(NESTED_OBJECT_UPDATE)) { | ||
listElementSchema = TableSchema.fromClass( | ||
Class.forName(rootSchema.converterForAttribute(key).type().rawClassParameters().get(0).rawClass().getName())); | ||
} else { | ||
String[] parts = NESTED_OBJECT_PATTERN.split(key); | ||
TableSchema<?> currentSchema = rootSchema; | ||
|
||
for (int i = 0; i < parts.length - 1; i++) { | ||
Optional<? extends TableSchema<?>> nestedSchema = getNestedSchema(currentSchema, parts[i]); | ||
if (nestedSchema.isPresent()) { | ||
currentSchema = nestedSchema.get(); | ||
} | ||
} | ||
String attributeName = parts[parts.length - 1]; | ||
listElementSchema = TableSchema.fromClass( | ||
Class.forName(currentSchema.converterForAttribute(attributeName) | ||
.type().rawClassParameters().get(0).rawClass().getName())); | ||
} | ||
} catch (ClassNotFoundException e) { | ||
throw new IllegalArgumentException("Class not found for field name: " + key, e); | ||
} | ||
return listElementSchema; | ||
} | ||
|
||
private Map<String, TableSchema<?>> resolveSchemasPerPath(Map<String, AttributeValue> attributesToSet, | ||
TableSchema<?> rootSchema) { | ||
Map<String, TableSchema<?>> schemaMap = new HashMap<>(); | ||
schemaMap.put("", rootSchema); | ||
|
||
for (String key : attributesToSet.keySet()) { | ||
String[] parts = NESTED_OBJECT_PATTERN.split(key); | ||
|
||
StringBuilder pathBuilder = new StringBuilder(); | ||
TableSchema<?> currentSchema = rootSchema; | ||
|
||
for (int i = 0; i < parts.length - 1; i++) { | ||
if (pathBuilder.length() > 0) { | ||
pathBuilder.append("."); | ||
} | ||
pathBuilder.append(parts[i]); | ||
|
||
String path = pathBuilder.toString(); | ||
|
||
if (!schemaMap.containsKey(path)) { | ||
Optional<? extends TableSchema<?>> nestedSchema = getNestedSchema(currentSchema, parts[i]); | ||
if (nestedSchema.isPresent()) { | ||
schemaMap.put(path, nestedSchema.get()); | ||
currentSchema = nestedSchema.get(); | ||
} | ||
} else { | ||
currentSchema = schemaMap.get(path); | ||
} | ||
} | ||
} | ||
return schemaMap; | ||
} | ||
|
||
private static String reconstructCompositeKey(String path, String attributeName) { | ||
if (path == null || path.isEmpty()) { | ||
return attributeName; | ||
} | ||
return String.join(NESTED_OBJECT_UPDATE, path.split("\\.")) | ||
+ NESTED_OBJECT_UPDATE + attributeName; | ||
} | ||
|
||
private Map<String, AttributeValue> processNestedObject(Map<String, AttributeValue> nestedMap, TableSchema<?> nestedSchema, | ||
Instant currentInstant) { | ||
Map<String, AttributeValue> updatedNestedMap = new HashMap<>(nestedMap); | ||
Collection<String> customMetadataObject = nestedSchema.tableMetadata() | ||
.customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null); | ||
|
||
if (customMetadataObject != null) { | ||
customMetadataObject.forEach( | ||
key -> insertTimestampInItemToTransform(updatedNestedMap, String.valueOf(key), | ||
nestedSchema.converterForAttribute(key), currentInstant)); | ||
} | ||
|
||
nestedMap.forEach((nestedKey, nestedValue) -> { | ||
if (nestedValue.hasM()) { | ||
updatedNestedMap.put(nestedKey, | ||
AttributeValue.builder().m(processNestedObject(nestedValue.m(), nestedSchema, | ||
currentInstant)).build()); | ||
} else if (nestedValue.hasL() && !nestedValue.l().isEmpty() | ||
&& nestedValue.l().get(0).hasM()) { | ||
try { | ||
TableSchema<?> listElementSchema = TableSchema.fromClass( | ||
Class.forName(nestedSchema.converterForAttribute(nestedKey) | ||
.type().rawClassParameters().get(0).rawClass().getName())); | ||
List<AttributeValue> updatedList = nestedValue | ||
.l() | ||
.stream() | ||
.map(listItem -> listItem.hasM() ? | ||
AttributeValue.builder() | ||
.m(processNestedObject(listItem.m(), | ||
listElementSchema, | ||
currentInstant)).build() : listItem) | ||
.collect(Collectors.toList()); | ||
updatedNestedMap.put(nestedKey, AttributeValue.builder().l(updatedList).build()); | ||
} catch (ClassNotFoundException e) { | ||
throw new IllegalArgumentException("Class not found for field name: " + nestedKey, e); | ||
} | ||
} | ||
}); | ||
return updatedNestedMap; | ||
} | ||
|
||
private void insertTimestampInItemToTransform(Map<String, AttributeValue> itemToTransform, | ||
String key, | ||
AttributeConverter converter) { | ||
itemToTransform.put(key, converter.transformFrom(clock.instant())); | ||
AttributeConverter converter, | ||
Instant instant) { | ||
itemToTransform.put(key, converter.transformFrom(instant)); | ||
} | ||
|
||
/** | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we clarify here the difference in null value handling?