Skip to content

Commit 806da70

Browse files
Support AutoGeneratedTimestamp annotations in nested objects (#6546)
* Support AutoGeneratedTimestamp and UpdateBehavior annotations in nested objects * Support AutoGeneratedTimestamp and UpdateBehavior annotations in nested objects * Support AutoGeneratedTimestamp and UpdateBehavior annotations in nested objects * Support AutoGeneratedTimestamp and UpdateBehavior annotations in nested objects * Support AutoGeneratedTimestamp and UpdateBehavior annotations in nested objects * Support AutoGeneratedTimestamp and UpdateBehavior annotations in nested objects * Support AutoGeneratedTimestamp and UpdateBehavior annotations in nested objects * Support AutoGeneratedTimestamp and UpdateBehavior annotations in nested objects * Support AutoGeneratedTimestamp and UpdateBehavior annotations in nested objects * Support AutoGeneratedTimestamp and UpdateBehavior annotations in nested objects * Exclude nested update behavior functionality - Remove NestedUpdateBehaviorTest.java - tests for nested update behavior support - Remove UpdateBehaviorTestModels.java - test models for nested update behavior These files will be included in a separate PR for nested update behavior support. * Added support for @DynamoDbAutoGeneratedTimestampAttribute on attributes within nested objects * Added support for @DynamoDbAutoGeneratedTimestampAttribute on attributes within nested objects * Added support for @DynamoDbAutoGeneratedTimestampAttribute on attributes within nested objects * Added support for @DynamoDbAutoGeneratedTimestampAttribute on attributes within nested objects * Added support for @DynamoDbAutoGeneratedTimestampAttribute on attributes within nested objects * Added support for @DynamoDbAutoGeneratedTimestampAttribute on attributes within nested objects * Added support for @DynamoDbAutoGeneratedTimestampAttribute on attributes within nested objects * Fixed caching mechanism for nested schemas. * fixed checkstyle * Addressed PR feedback * Addressed PR feedback --------- Co-authored-by: Deepesh Shetty <shetsa@amazon.com>
1 parent d97564a commit 806da70

File tree

11 files changed

+4703
-682
lines changed

11 files changed

+4703
-682
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 @DynamoDbAutoGeneratedTimestampAttribute on attributes within nested objects."
6+
}

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

Lines changed: 372 additions & 15 deletions
Large diffs are not rendered by default.

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
* Denotes this attribute as recording the auto generated last updated timestamp for the record.
2828
* Every time a record with this attribute is written to the database it will update the attribute with current timestamp when
2929
* its updated.
30+
* <p>
31+
* Note: This annotation must not be applied to fields whose names contain the reserved marker "_NESTED_ATTR_UPDATE_".
32+
* This marker is used internally by the Enhanced Client to represent flattened paths for nested attribute updates.
33+
* If a field name contains this marker, an IllegalArgumentException will be thrown during schema registration.
3034
*/
3135
@SdkPublicApi
3236
@Target({ElementType.METHOD})

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
import java.util.stream.Collectors;
2929
import java.util.stream.Stream;
3030
import software.amazon.awssdk.annotations.SdkInternalApi;
31+
import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter;
3132
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension;
33+
import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
3234
import software.amazon.awssdk.enhanced.dynamodb.Key;
3335
import software.amazon.awssdk.enhanced.dynamodb.OperationContext;
3436
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
@@ -37,6 +39,8 @@
3739
import software.amazon.awssdk.enhanced.dynamodb.model.Page;
3840
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
3941
import software.amazon.awssdk.services.dynamodb.model.ConsumedCapacity;
42+
import software.amazon.awssdk.utils.CollectionUtils;
43+
import software.amazon.awssdk.utils.StringUtils;
4044

4145
@SdkInternalApi
4246
public final class EnhancedClientUtils {
@@ -146,7 +150,7 @@ public static <ResponseT, ItemT> Page<ItemT> readAndTransformPaginatedItems(
146150
.scannedCount(scannedCount.apply(response))
147151
.consumedCapacity(consumedCapacity.apply(response));
148152

149-
if (getLastEvaluatedKey.apply(response) != null && !getLastEvaluatedKey.apply(response).isEmpty()) {
153+
if (CollectionUtils.isNotEmpty(getLastEvaluatedKey.apply(response))) {
150154
pageBuilder.lastEvaluatedKey(getLastEvaluatedKey.apply(response));
151155
}
152156
return pageBuilder.build();
@@ -204,4 +208,42 @@ public static <T> List<T> getItemsFromSupplier(List<Supplier<T>> itemSupplierLis
204208
public static boolean isNullAttributeValue(AttributeValue attributeValue) {
205209
return attributeValue.nul() != null && attributeValue.nul();
206210
}
211+
212+
public static boolean hasMap(AttributeValue attributeValue) {
213+
return attributeValue != null && attributeValue.hasM();
214+
}
215+
216+
/**
217+
* Retrieves the nested {@link TableSchema} for an attribute from the parent schema.
218+
* For parameterized types (e.g., Set, List, Map), extracts the first type parameter's schema.
219+
*
220+
* @param parentSchema the parent schema; must not be null
221+
* @param attributeName the attribute name; must not be null or empty
222+
* @return the nested schema, or empty if unavailable
223+
*/
224+
public static Optional<TableSchema<?>> getNestedSchema(TableSchema<?> parentSchema, String attributeName) {
225+
if (parentSchema == null) {
226+
throw new IllegalArgumentException("Parent schema cannot be null.");
227+
}
228+
if (StringUtils.isEmpty(attributeName)) {
229+
throw new IllegalArgumentException("Attribute name cannot be null or empty.");
230+
}
231+
232+
AttributeConverter<?> converter = parentSchema.converterForAttribute(attributeName);
233+
if (converter == null) {
234+
return Optional.empty();
235+
}
236+
237+
EnhancedType<?> enhancedType = converter.type();
238+
if (enhancedType == null) {
239+
return Optional.empty();
240+
}
241+
242+
List<EnhancedType<?>> rawClassParameters = enhancedType.rawClassParameters();
243+
if (!CollectionUtils.isNullOrEmpty(rawClassParameters)) {
244+
enhancedType = rawClassParameters.get(0);
245+
}
246+
247+
return enhancedType.tableSchema().flatMap(Optional::of);
248+
}
207249
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility;
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+
21+
import java.util.HashMap;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.Objects;
25+
import java.util.Optional;
26+
import java.util.regex.Pattern;
27+
import software.amazon.awssdk.annotations.SdkInternalApi;
28+
import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter;
29+
import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
30+
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
31+
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
32+
import software.amazon.awssdk.utils.CollectionUtils;
33+
import software.amazon.awssdk.utils.StringUtils;
34+
35+
@SdkInternalApi
36+
public final class NestedRecordUtils {
37+
38+
private static final Pattern NESTED_OBJECT_PATTERN = Pattern.compile(NESTED_OBJECT_UPDATE);
39+
40+
private NestedRecordUtils() {
41+
}
42+
43+
/**
44+
* Resolves and returns the {@link TableSchema} for the element type of list attribute from the provided root schema.
45+
* <p>
46+
* This method is useful when dealing with lists of nested objects in a DynamoDB-enhanced table schema, particularly in
47+
* scenarios where the list is part of a flattened nested structure.
48+
* <p>
49+
* If the provided key contains the nested object delimiter (e.g., {@code _NESTED_ATTR_UPDATE_}), the method traverses the
50+
* nested hierarchy based on that path to locate the correct schema for the target attribute. Otherwise, it directly resolves
51+
* the list element type from the root schema using reflection.
52+
*
53+
* @param rootSchema The root {@link TableSchema} representing the top-level entity.
54+
* @param key The key representing the list attribute, either flat or nested (using a delimiter).
55+
* @return The {@link TableSchema} representing the list element type of the specified attribute.
56+
* @throws IllegalArgumentException If the list element class cannot be found via reflection.
57+
*/
58+
public static TableSchema<?> getTableSchemaForListElement(TableSchema<?> rootSchema, String key) {
59+
return getTableSchemaForListElement(rootSchema, key, new HashMap<>());
60+
}
61+
62+
/**
63+
* Same as {@link #getTableSchemaForListElement(TableSchema, String)} but allows callers to provide a shared per-operation
64+
* cache for nested schema lookups.
65+
*/
66+
public static TableSchema<?> getTableSchemaForListElement(
67+
TableSchema<?> rootSchema,
68+
String key,
69+
Map<SchemaLookupKey, Optional<TableSchema<?>>> nestedSchemaCache) {
70+
71+
if (key.contains(NESTED_OBJECT_UPDATE)) {
72+
return listElementSchemaForDelimitedKey(rootSchema, key, nestedSchemaCache);
73+
}
74+
75+
Optional<TableSchema<?>> staticSchema = getNestedSchemaCached(nestedSchemaCache, rootSchema, key);
76+
if (staticSchema.isPresent()) {
77+
return staticSchema.get();
78+
}
79+
80+
AttributeConverter<?> converter = rootSchema.converterForAttribute(key);
81+
if (converter == null) {
82+
throw new IllegalArgumentException("No converter found for attribute: " + key);
83+
}
84+
List<EnhancedType<?>> rawClassParameters = converter.type().rawClassParameters();
85+
if (CollectionUtils.isNullOrEmpty(rawClassParameters)) {
86+
throw new IllegalArgumentException("No type parameters found for list attribute: " + key);
87+
}
88+
return TableSchema.fromClass(rawClassParameters.get(0).rawClass());
89+
}
90+
91+
private static TableSchema<?> listElementSchemaForDelimitedKey(
92+
TableSchema<?> rootSchema,
93+
String key,
94+
Map<SchemaLookupKey, Optional<TableSchema<?>>> nestedSchemaCache) {
95+
96+
String[] parts = NESTED_OBJECT_PATTERN.split(key);
97+
TableSchema<?> currentSchema = rootSchema;
98+
99+
for (int i = 0; i < parts.length - 1; i++) {
100+
Optional<TableSchema<?>> nestedSchema =
101+
getNestedSchemaCached(nestedSchemaCache, currentSchema, parts[i]);
102+
if (nestedSchema.isPresent()) {
103+
currentSchema = nestedSchema.get();
104+
}
105+
}
106+
107+
String attributeName = parts[parts.length - 1];
108+
return getNestedSchemaCached(nestedSchemaCache, currentSchema, attributeName)
109+
.orElseThrow(() -> new IllegalArgumentException("Unable to resolve schema for list element at: " + key));
110+
}
111+
112+
/**
113+
* Traverses the attribute keys representing flattened nested structures and resolves the corresponding {@link TableSchema}
114+
* for each nested path.
115+
* <p>
116+
* The method constructs a mapping between each unique nested path (represented as dot-delimited strings) and the
117+
* corresponding {@link TableSchema} object derived from the root schema. It supports resolving schemas for arbitrarily deep
118+
* nesting, using the {@code _NESTED_ATTR_UPDATE_} pattern as a path delimiter.
119+
* <p>
120+
* This is typically used in update or transformation flows where fields from nested objects are represented as flattened keys
121+
* in the attribute map (e.g., {@code parent_NESTED_ATTR_UPDATE_child}).
122+
*
123+
* @param attributesToSet A map of flattened attribute keys to values, where keys may represent paths to nested attributes.
124+
* @param rootSchema The root {@link TableSchema} of the top-level entity.
125+
* @return A map where the key is the nested path (e.g., {@code "parent.child"}) and the value is the {@link TableSchema}
126+
* corresponding to that level in the object hierarchy.
127+
*/
128+
public static Map<String, TableSchema<?>> resolveSchemasPerPath(Map<String, AttributeValue> attributesToSet,
129+
TableSchema<?> rootSchema) {
130+
return resolveSchemasPerPath(attributesToSet, rootSchema, new HashMap<>());
131+
}
132+
133+
/**
134+
* Same as {@link #resolveSchemasPerPath(Map, TableSchema)} but allows callers to provide a shared per-operation cache for
135+
* nested schema lookups.
136+
*/
137+
public static Map<String, TableSchema<?>> resolveSchemasPerPath(
138+
Map<String, AttributeValue> attributesToSet,
139+
TableSchema<?> rootSchema,
140+
Map<SchemaLookupKey, Optional<TableSchema<?>>> nestedSchemaCache) {
141+
142+
Map<String, TableSchema<?>> schemaMap = new HashMap<>();
143+
schemaMap.put("", rootSchema);
144+
145+
for (String key : attributesToSet.keySet()) {
146+
String[] parts = NESTED_OBJECT_PATTERN.split(key);
147+
148+
StringBuilder pathBuilder = new StringBuilder();
149+
TableSchema<?> currentSchema = rootSchema;
150+
151+
for (int i = 0; i < parts.length - 1; i++) {
152+
if (pathBuilder.length() > 0) {
153+
pathBuilder.append(".");
154+
}
155+
pathBuilder.append(parts[i]);
156+
157+
String path = pathBuilder.toString();
158+
159+
if (!schemaMap.containsKey(path)) {
160+
Optional<TableSchema<?>> nestedSchema =
161+
getNestedSchemaCached(nestedSchemaCache, currentSchema, parts[i]);
162+
163+
if (nestedSchema.isPresent()) {
164+
TableSchema<?> resolved = nestedSchema.get();
165+
schemaMap.put(path, resolved);
166+
currentSchema = resolved;
167+
}
168+
} else {
169+
currentSchema = schemaMap.get(path);
170+
}
171+
}
172+
}
173+
174+
return schemaMap;
175+
}
176+
177+
/**
178+
* Converts a dot-separated path to a composite key using nested object delimiters. Example:
179+
* {@code reconstructCompositeKey("parent.child", "attr")} returns
180+
* {@code "parent_NESTED_ATTR_UPDATE_child_NESTED_ATTR_UPDATE_attr"}
181+
*
182+
* @param path the dot-separated path; may be null or empty
183+
* @param attributeName the attribute name to append; must not be null
184+
* @return the composite key with nested object delimiters
185+
*/
186+
public static String reconstructCompositeKey(String path, String attributeName) {
187+
if (attributeName == null) {
188+
throw new IllegalArgumentException("Attribute name cannot be null");
189+
}
190+
191+
if (StringUtils.isEmpty(path)) {
192+
return attributeName;
193+
}
194+
195+
return String.join(NESTED_OBJECT_UPDATE, path.split("\\."))
196+
+ NESTED_OBJECT_UPDATE + attributeName;
197+
}
198+
199+
/**
200+
* Cached wrapper around {@link software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils#getNestedSchema}. Cache
201+
* key is based on (parent schema identity, attribute name).
202+
*/
203+
public static Optional<TableSchema<?>> getNestedSchemaCached(
204+
Map<SchemaLookupKey, Optional<TableSchema<?>>> cache,
205+
TableSchema<?> parentSchema,
206+
String attributeName) {
207+
208+
SchemaLookupKey key = new SchemaLookupKey(parentSchema, attributeName);
209+
return cache.computeIfAbsent(key, k -> getNestedSchema(parentSchema, attributeName));
210+
}
211+
212+
/**
213+
* Cached wrapper for resolving list element schema, storing results (including null) in the provided cache.
214+
* <p>
215+
* Note: {@link #getTableSchemaForListElement(TableSchema, String, Map)} does not return null today, but this helper is used
216+
* by callers that previously cached the list element schema separately, and it keeps the "cache null" behavior.
217+
*/
218+
public static TableSchema<?> getListElementSchemaCached(
219+
Map<SchemaLookupKey, TableSchema<?>> cache,
220+
TableSchema<?> parentSchema,
221+
String attributeName,
222+
Map<SchemaLookupKey, Optional<TableSchema<?>>> nestedSchemaCache) {
223+
224+
SchemaLookupKey key = new SchemaLookupKey(parentSchema, attributeName);
225+
226+
if (cache.containsKey(key)) {
227+
return cache.get(key);
228+
}
229+
230+
TableSchema<?> schema = getTableSchemaForListElement(parentSchema, attributeName, nestedSchemaCache);
231+
cache.put(key, schema);
232+
return schema;
233+
}
234+
235+
/**
236+
* Identity-based cache key for schema lookups: - compares TableSchema by identity (==) to avoid depending on its
237+
* equals/hashCode semantics - compares attribute name by value
238+
*/
239+
public static final class SchemaLookupKey {
240+
private final TableSchema<?> parentSchema;
241+
private final String attributeName;
242+
243+
public SchemaLookupKey(TableSchema<?> parentSchema, String attributeName) {
244+
this.parentSchema = parentSchema;
245+
this.attributeName = attributeName;
246+
}
247+
248+
@Override
249+
public boolean equals(Object o) {
250+
if (o == null || getClass() != o.getClass()) {
251+
return false;
252+
}
253+
SchemaLookupKey that = (SchemaLookupKey) o;
254+
return Objects.equals(parentSchema, that.parentSchema) && Objects.equals(attributeName, that.attributeName);
255+
}
256+
257+
@Override
258+
public int hashCode() {
259+
return 31 * System.identityHashCode(parentSchema) + (attributeName == null ? 0 : attributeName.hashCode());
260+
}
261+
}
262+
}

0 commit comments

Comments
 (0)