Skip to content

Commit 9bdd712

Browse files
committed
DynamoDB Enhanced Client Polymorphic Types Support
1 parent 631a538 commit 9bdd712

31 files changed

+2533
-100
lines changed

services-custom/dynamodb-enhanced/README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,3 +685,85 @@ private static final StaticTableSchema<Customer> CUSTOMER_TABLE_SCHEMA =
685685
```
686686
Just as for annotations, you can flatten as many different eligible classes as you like using the
687687
builder pattern.
688+
689+
690+
691+
692+
### Using subtypes to assist with single-table design
693+
It's considered a best practice in some situations to combine entities of various types into a single table in DynamoDb
694+
to enable the querying of multiple related entities without the need to actually join data across multiple tables. The
695+
enhanced client assists with this by supporting polymorphic mapping into distinct subtypes.
696+
697+
Let's say you have a customer:
698+
699+
```java
700+
public class Customer {
701+
String getCustomerId();
702+
void setId(String id);
703+
704+
String getName();
705+
void setName(String name);
706+
}
707+
```
708+
709+
And an order that's associated with a customer:
710+
711+
```java
712+
public class Order {
713+
String getOrderId();
714+
void setOrderId();
715+
716+
String getCustomerId();
717+
void setCustomerId();
718+
}
719+
```
720+
721+
You could choose to store both of these in a single table that is indexed by customer ID, and create a TableSchema that
722+
is capable of mapping both types of entities into a common supertype:
723+
724+
```java
725+
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
726+
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey;
727+
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSubtypeDiscriminator;
728+
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype;
729+
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype.Subtype;
730+
731+
@DynamoDbBean
732+
@DynamoDbSupertype({
733+
@Subtype(discriminatorValue = "CUSTOMER", subtypeClass = Customer.class),
734+
@Subtype(discriminatorValue = "ORDER", subtypeClass = Order.class)})
735+
public class CustomerRelatedEntity {
736+
@DynamoDbSubtypeDiscriminator
737+
String getEntityType();
738+
void setEntityType();
739+
740+
@DynamoDbPartitionKey
741+
String getCustomerId();
742+
void setCustomerId();
743+
}
744+
745+
@DynamoDbBean
746+
public class Customer extends CustomerRelatedEntity {
747+
String getName();
748+
void setName(String name);
749+
}
750+
751+
@DynamoDbBean
752+
public class Order extends CustomerRelatedEntity {
753+
String getOrderId();
754+
void setOrderId();
755+
}
756+
```
757+
758+
Now all you have to do is create a TableSchema that maps the supertype class:
759+
```java
760+
TableSchema<CustomerRelatedEntity> tableSchema = TableSchema.fromClass(CustomerRelatedEntity.class);
761+
```
762+
Now you have a `TableSchema` that can map any objects of both `Customer` and `Order` and write them to the table,
763+
and can also read any record from the table and correctly instantiate it using the subtype class. So it's now possible
764+
to write a single query that will return both the customer record and all order records associated with a specific
765+
customer ID.
766+
767+
As with all the other `TableSchema` implementations, a static version is provided that allows reflective introspection
768+
to be skipped entirely and is recommended for applications where cold-start latency is critical. See the javadocs for
769+
`StaticPolymorphicTableSchema` for an example of how to use this.

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

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema;
3232
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
3333
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable;
34+
import software.amazon.awssdk.enhanced.dynamodb.mapper.TableSchemaFactory;
3435
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPreserveEmptyObject;
3536
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
3637

@@ -200,16 +201,7 @@ static <T> ImmutableTableSchema<T> fromImmutableClass(ImmutableTableSchemaParams
200201
* @return An initialized {@link TableSchema}
201202
*/
202203
static <T> TableSchema<T> fromClass(Class<T> annotatedClass) {
203-
if (annotatedClass.getAnnotation(DynamoDbImmutable.class) != null) {
204-
return fromImmutableClass(annotatedClass);
205-
}
206-
207-
if (annotatedClass.getAnnotation(DynamoDbBean.class) != null) {
208-
return fromBean(annotatedClass);
209-
}
210-
211-
throw new IllegalArgumentException("Class does not appear to be a valid DynamoDb annotated class. [class = " +
212-
"\"" + annotatedClass + "\"]");
204+
return TableSchemaFactory.fromClass(annotatedClass);
213205
}
214206

215207
/**
@@ -344,4 +336,30 @@ default T mapToItem(Map<String, AttributeValue> attributeMap, boolean preserveEm
344336
default AttributeConverter<T> converterForAttribute(Object key) {
345337
throw new UnsupportedOperationException();
346338
}
339+
340+
/**
341+
* If applicable, returns a {@link TableSchema} for a specific object subtype. If the implementation does not support
342+
* polymorphic mapping, then this method will, by default, return the current instance. This method is primarily used to pass
343+
* the right contextual information to extensions when they are invoked mid-operation. This method is not required to get a
344+
* polymorphic {@link TableSchema} to correctly map subtype objects using 'mapToItem' or 'itemToMap'.
345+
*
346+
* @param itemContext the subtype object to retrieve the subtype {@link TableSchema} for.
347+
* @return the subtype {@link TableSchema} or the current {@link TableSchema} if subtypes are not supported.
348+
*/
349+
default TableSchema<? extends T> subtypeTableSchema(T itemContext) {
350+
return this;
351+
}
352+
353+
/**
354+
* If applicable, returns a {@link TableSchema} for a specific object subtype. If the implementation does not support
355+
* polymorphic mapping, then this method will, by default, return the current instance. This method is primarily used to pass
356+
* the right contextual information to extensions when they are invoked mid-operation. This method is not required to get a
357+
* polymorphic {@link TableSchema} to correctly map subtype objects using 'mapToItem' or 'itemToMap'.
358+
*
359+
* @param itemContext the subtype object map to retrieve the subtype {@link TableSchema} for.
360+
* @return the subtype {@link TableSchema} or the current {@link TableSchema} if subtypes are not supported.
361+
*/
362+
default TableSchema<? extends T> subtypeTableSchema(Map<String, AttributeValue> itemContext) {
363+
return this;
364+
}
347365
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,14 @@ public static <T> T readAndTransformSingleItem(Map<String, AttributeValue> itemM
106106
}
107107

108108
if (dynamoDbEnhancedClientExtension != null) {
109+
TableSchema<? extends T> subtypeTableSchema = tableSchema.subtypeTableSchema(itemMap);
110+
109111
ReadModification readModification = dynamoDbEnhancedClientExtension.afterRead(
110112
DefaultDynamoDbExtensionContext.builder()
111113
.items(itemMap)
112-
.tableSchema(tableSchema)
114+
.tableSchema(subtypeTableSchema)
113115
.operationContext(operationContext)
114-
.tableMetadata(tableSchema.tableMetadata())
116+
.tableMetadata(subtypeTableSchema.tableMetadata())
115117
.build());
116118
if (readModification != null && readModification.transformedItem() != null) {
117119
return tableSchema.mapToItem(readModification.transformedItem());

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey;
2828
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey;
2929
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbUpdateBehavior;
30+
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSubtypeDiscriminator;
3031

3132
/**
3233
* Static provider class for core {@link BeanTableSchema} attribute tags. Each of the implemented annotations has a
@@ -62,4 +63,8 @@ public static StaticAttributeTag attributeTagFor(DynamoDbUpdateBehavior annotati
6263
public static StaticAttributeTag attributeTagFor(DynamoDbAtomicCounter annotation) {
6364
return StaticAttributeTags.atomicCounter(annotation.delta(), annotation.startValue());
6465
}
66+
67+
public static StaticAttributeTag attributeTagFor(DynamoDbSubtypeDiscriminator annotation) {
68+
return StaticAttributeTags.subtypeName();
69+
}
6570
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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.mapper;
17+
18+
import java.util.Optional;
19+
import java.util.function.Consumer;
20+
import software.amazon.awssdk.annotations.SdkInternalApi;
21+
import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType;
22+
import software.amazon.awssdk.enhanced.dynamodb.TableMetadata;
23+
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag;
24+
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata;
25+
26+
@SdkInternalApi
27+
public class SubtypeNameTag implements StaticAttributeTag {
28+
private static final SubtypeNameTag INSTANCE = new SubtypeNameTag();
29+
private static final String CUSTOM_METADATA_KEY = "SubtypeName";
30+
31+
private SubtypeNameTag() {
32+
}
33+
34+
public static Optional<String> resolve(TableMetadata tableMetadata) {
35+
return tableMetadata.customMetadataObject(CUSTOM_METADATA_KEY, String.class);
36+
}
37+
38+
@Override
39+
public Consumer<StaticTableMetadata.Builder> modifyMetadata(String attributeName,
40+
AttributeValueType attributeValueType) {
41+
if (!AttributeValueType.S.equals(attributeValueType)) {
42+
throw new IllegalArgumentException(
43+
String.format("Attribute '%s' of type %s is not a suitable type to be used as a subtype name. Only string is "
44+
+ "supported for this purpose.", attributeName, attributeValueType.name()));
45+
}
46+
47+
return metadata ->
48+
metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, attributeName);
49+
}
50+
51+
public static SubtypeNameTag create() {
52+
return INSTANCE;
53+
}
54+
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,14 @@ public PutItemRequest generateRequest(TableSchema<T> tableSchema,
8080
throw new IllegalArgumentException("PutItem cannot be executed against a secondary index.");
8181
}
8282

83-
TableMetadata tableMetadata = tableSchema.tableMetadata();
83+
T item = request.map(PutItemEnhancedRequest::item, TransactPutItemEnhancedRequest::item);
84+
TableSchema<? extends T> subtypeTableSchema = tableSchema.subtypeTableSchema(item);
85+
TableMetadata tableMetadata = subtypeTableSchema.tableMetadata();
8486

8587
// Fail fast if required primary partition key does not exist and avoid the call to DynamoDb
8688
tableMetadata.primaryPartitionKey();
8789

8890
boolean alwaysIgnoreNulls = true;
89-
T item = request.map(PutItemEnhancedRequest::item, TransactPutItemEnhancedRequest::item);
9091
Map<String, AttributeValue> itemMap = tableSchema.itemToMap(item, alwaysIgnoreNulls);
9192

9293
WriteModification transformation =
@@ -95,7 +96,7 @@ public PutItemRequest generateRequest(TableSchema<T> tableSchema,
9596
.items(itemMap)
9697
.operationContext(operationContext)
9798
.tableMetadata(tableMetadata)
98-
.tableSchema(tableSchema)
99+
.tableSchema(subtypeTableSchema)
99100
.operationName(operationName())
100101
.build())
101102
: null;

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,16 +109,17 @@ public UpdateItemRequest generateRequest(TableSchema<T> tableSchema,
109109

110110
Map<String, AttributeValue> itemMap = ignoreNullsMode == IgnoreNullsMode.SCALAR_ONLY ?
111111
transformItemToMapForUpdateExpression(itemMapImmutable) : itemMapImmutable;
112-
113-
TableMetadata tableMetadata = tableSchema.tableMetadata();
112+
113+
TableSchema<? extends T> subtypeTableSchema = tableSchema.subtypeTableSchema(item);
114+
TableMetadata tableMetadata = subtypeTableSchema.tableMetadata();
114115

115116
WriteModification transformation =
116117
extension != null
117118
? extension.beforeWrite(DefaultDynamoDbExtensionContext.builder()
118119
.items(itemMap)
119120
.operationContext(operationContext)
120121
.tableMetadata(tableMetadata)
121-
.tableSchema(tableSchema)
122+
.tableSchema(subtypeTableSchema)
122123
.operationName(operationName())
123124
.build())
124125
: null;

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/BeanTableSchema.java

Lines changed: 12 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@
6565
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbFlatten;
6666
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbIgnore;
6767
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbIgnoreNulls;
68-
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable;
6968
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPreserveEmptyObject;
7069
import software.amazon.awssdk.utils.StringUtils;
7170

@@ -100,7 +99,7 @@
10099
* public Instant getCreatedDate() { return this.createdDate; }
101100
* public void setCreatedDate(Instant createdDate) { this.createdDate = createdDate; }
102101
* }
103-
*
102+
* </code>
104103
* </pre>
105104
*
106105
* Creating an {@link BeanTableSchema} is a moderately expensive operation, and should be performed sparingly. This is
@@ -167,39 +166,21 @@ public static <T> BeanTableSchema<T> create(BeanTableSchemaParams<T> params) {
167166
new MetaTableSchemaCache()));
168167
}
169168

170-
private static <T> BeanTableSchema<T> create(BeanTableSchemaParams<T> params, MetaTableSchemaCache metaTableSchemaCache) {
169+
static <T> BeanTableSchema<T> create(BeanTableSchemaParams<T> params, MetaTableSchemaCache metaTableSchemaCache) {
171170
Class<T> beanClass = params.beanClass();
172171
debugLog(beanClass, () -> "Creating bean schema");
173172
// Fetch or create a new reference to this yet-to-be-created TableSchema in the cache
174173
MetaTableSchema<T> metaTableSchema = metaTableSchemaCache.getOrCreate(beanClass);
175174

176-
BeanTableSchema<T> newTableSchema =
177-
new BeanTableSchema<>(createStaticTableSchema(params.beanClass(), params.lookup(), metaTableSchemaCache));
175+
BeanTableSchema<T> newTableSchema = createWithoutUsingCache(beanClass, params.lookup(), metaTableSchemaCache);
178176
metaTableSchema.initialize(newTableSchema);
179177
return newTableSchema;
180178
}
181179

182-
// Called when creating an immutable TableSchema recursively. Utilizes the MetaTableSchema cache to stop infinite
183-
// recursion
184-
static <T> TableSchema<T> recursiveCreate(Class<T> beanClass, MethodHandles.Lookup lookup,
185-
MetaTableSchemaCache metaTableSchemaCache) {
186-
Optional<MetaTableSchema<T>> metaTableSchema = metaTableSchemaCache.get(beanClass);
187-
188-
// If we get a cache hit...
189-
if (metaTableSchema.isPresent()) {
190-
// Either: use the cached concrete TableSchema if we have one
191-
if (metaTableSchema.get().isInitialized()) {
192-
return metaTableSchema.get().concreteTableSchema();
193-
}
194-
195-
// Or: return the uninitialized MetaTableSchema as this must be a recursive reference and it will be
196-
// initialized later as the chain completes
197-
return metaTableSchema.get();
198-
}
199-
200-
// Otherwise: cache doesn't know about this class; create a new one from scratch
201-
return create(BeanTableSchemaParams.builder(beanClass).lookup(lookup).build());
202-
180+
static <T> BeanTableSchema<T> createWithoutUsingCache(Class<T> beanClass,
181+
MethodHandles.Lookup lookup,
182+
MetaTableSchemaCache metaTableSchemaCache) {
183+
return new BeanTableSchema<>(createStaticTableSchema(beanClass, lookup, metaTableSchemaCache));
203184
}
204185

205186
private static <T> StaticTableSchema<T> createStaticTableSchema(Class<T> beanClass,
@@ -363,22 +344,15 @@ private static EnhancedType<?> convertTypeToEnhancedType(Type type,
363344
clazz = (Class<?>) type;
364345
}
365346

366-
if (clazz != null) {
347+
if (clazz != null && TableSchemaFactory.isDynamoDbAnnotatedClass(clazz)) {
367348
Consumer<EnhancedTypeDocumentConfiguration.Builder> attrConfiguration =
368349
b -> b.preserveEmptyObject(attributeConfiguration.preserveEmptyObject())
369350
.ignoreNulls(attributeConfiguration.ignoreNulls());
370351

371-
if (clazz.getAnnotation(DynamoDbImmutable.class) != null) {
372-
return EnhancedType.documentOf(
373-
(Class<Object>) clazz,
374-
(TableSchema<Object>) ImmutableTableSchema.recursiveCreate(clazz, lookup, metaTableSchemaCache),
375-
attrConfiguration);
376-
} else if (clazz.getAnnotation(DynamoDbBean.class) != null) {
377-
return EnhancedType.documentOf(
378-
(Class<Object>) clazz,
379-
(TableSchema<Object>) BeanTableSchema.recursiveCreate(clazz, lookup, metaTableSchemaCache),
380-
attrConfiguration);
381-
}
352+
return EnhancedType.documentOf(
353+
(Class<Object>) clazz,
354+
(TableSchema<Object>) TableSchemaFactory.fromClass(clazz, lookup, metaTableSchemaCache),
355+
attrConfiguration);
382356
}
383357

384358
return EnhancedType.of(type);

0 commit comments

Comments
 (0)