Skip to content

Commit 7f787d5

Browse files
committed
DynamoDB Enhanced Client Polymorphic Types Support
1 parent 3af1858 commit 7f787d5

File tree

3 files changed

+175
-101
lines changed

3 files changed

+175
-101
lines changed

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

Lines changed: 72 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -15,89 +15,101 @@
1515

1616
package software.amazon.awssdk.enhanced.dynamodb.mapper;
1717

18-
import java.lang.invoke.MethodHandles;
19-
import java.util.Arrays;
2018
import java.util.Map;
19+
import software.amazon.awssdk.annotations.NotThreadSafe;
2120
import software.amazon.awssdk.annotations.SdkPublicApi;
2221
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
23-
import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.MetaTableSchema;
24-
import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.MetaTableSchemaCache;
2522
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype;
2623
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
27-
import software.amazon.awssdk.utils.StringUtils;
2824

2925
/**
30-
* A polymorphic wrapper that reads the {@link DynamoDbSupertype#discriminatorAttributeName()} and wires up each declared
31-
* subtype.
26+
* A polymorphic {@link TableSchema} that routes items to subtypes based on a discriminator attribute
27+
* (see {@link DynamoDbSupertype}).
28+
* <p>
29+
* Typically constructed automatically via {@link TableSchemaFactory#fromClass(Class)}
30+
* when a class is annotated with {@link DynamoDbSupertype}. For manual assembly, use {@link #builder(Class)}.
3231
*/
3332
@SdkPublicApi
3433
public final class PolymorphicTableSchema<T> extends WrappedTableSchema<T, StaticPolymorphicTableSchema<T>> {
35-
private final StaticPolymorphicTableSchema<T> staticPolymorphicTableSchema;
3634

37-
private PolymorphicTableSchema(StaticPolymorphicTableSchema<T> staticPolymorphicTableSchema) {
38-
super(staticPolymorphicTableSchema);
39-
this.staticPolymorphicTableSchema = staticPolymorphicTableSchema;
35+
private PolymorphicTableSchema(Builder<T> builder) {
36+
super(builder.delegate.build());
4037
}
4138

42-
public static <T> PolymorphicTableSchema<T> create(Class<T> polymorphicClass, MethodHandles.Lookup lookup) {
43-
return create(polymorphicClass, lookup, new MetaTableSchemaCache());
39+
/**
40+
* Returns a builder for manually creating a {@link PolymorphicTableSchema}.
41+
*
42+
* @param rootClass the root type that all subtypes must extend
43+
*/
44+
public static <T> Builder<T> builder(Class<T> rootClass) {
45+
return new Builder<>(rootClass);
4446
}
4547

46-
static <T> PolymorphicTableSchema<T> create(Class<T> polymorphicClass,
47-
MethodHandles.Lookup lookup,
48-
MetaTableSchemaCache cache) {
49-
50-
MetaTableSchema<T> metaTableSchema = cache.getOrCreate(polymorphicClass);
51-
TableSchema<T> root = TableSchemaFactory.fromMonomorphicClassWithoutUsingCache(polymorphicClass, lookup, cache);
52-
53-
DynamoDbSupertype dynamoDbSupertype = polymorphicClass.getAnnotation(DynamoDbSupertype.class);
54-
if (dynamoDbSupertype == null) {
55-
throw new IllegalArgumentException("A DynamoDb polymorphic class [" + polymorphicClass.getSimpleName()
56-
+ "] must be annotated with @DynamoDbSupertype");
57-
}
48+
@Override
49+
public TableSchema<? extends T> subtypeTableSchema(T itemContext) {
50+
return delegateTableSchema().subtypeTableSchema(itemContext);
51+
}
5852

59-
StaticPolymorphicTableSchema.Builder<T> staticBuilder =
60-
StaticPolymorphicTableSchema.builder(polymorphicClass)
61-
.rootTableSchema(root)
62-
.discriminatorAttributeName(dynamoDbSupertype.discriminatorAttributeName());
53+
@Override
54+
public TableSchema<? extends T> subtypeTableSchema(Map<String, AttributeValue> itemContext) {
55+
return delegateTableSchema().subtypeTableSchema(itemContext);
56+
}
6357

64-
Arrays.stream(dynamoDbSupertype.value())
65-
.forEach(sub -> staticBuilder.addStaticSubtype(resolveSubtype(polymorphicClass, lookup, sub, cache)));
58+
@NotThreadSafe
59+
public static final class Builder<T> {
60+
private final StaticPolymorphicTableSchema.Builder<T> delegate;
6661

67-
PolymorphicTableSchema<T> result = new PolymorphicTableSchema<>(staticBuilder.build());
68-
metaTableSchema.initialize(result);
69-
return result;
70-
}
62+
private Builder(Class<T> rootClass) {
63+
this.delegate = StaticPolymorphicTableSchema.builder(rootClass);
64+
}
7165

72-
private static <T> StaticSubtype<? extends T> resolveSubtype(Class<T> rootClass,
73-
MethodHandles.Lookup lookup,
74-
DynamoDbSupertype.Subtype subtype,
75-
MetaTableSchemaCache cache) {
76-
Class<?> subtypeClass = subtype.subtypeClass();
77-
if (!rootClass.isAssignableFrom(subtypeClass)) {
78-
throw new IllegalArgumentException("A subtype class [" + subtypeClass.getSimpleName() + "] listed in the "
79-
+ "@DynamoDbSupertype annotation is not extending the root class.");
66+
/**
67+
* Sets the schema for the root class.
68+
*/
69+
public Builder<T> rootTableSchema(TableSchema<T> root) {
70+
delegate.rootTableSchema(root);
71+
return this;
8072
}
81-
Class<T> typed = (Class<T>) subtypeClass;
8273

83-
//if the discriminator values is provided, it will be used; if not, we'll use the name of the class
84-
String subtypeName = StringUtils.isEmpty(subtype.discriminatorValue())
85-
? subtype.subtypeClass().getSimpleName()
86-
: subtype.discriminatorValue();
74+
/**
75+
* Sets the discriminator attribute name (defaults to {@code "type"}).
76+
*/
77+
public Builder<T> discriminatorAttributeName(String name) {
78+
delegate.discriminatorAttributeName(name);
79+
return this;
80+
}
8781

88-
return StaticSubtype.builder(typed)
89-
.tableSchema(TableSchemaFactory.fromClass(typed, lookup, cache))
90-
.name(subtypeName)
91-
.build();
92-
}
82+
/**
83+
* Adds a fully constructed static subtype.
84+
*/
85+
public Builder<T> addStaticSubtype(StaticSubtype<? extends T> subtype) {
86+
delegate.addStaticSubtype(subtype);
87+
return this;
88+
}
9389

94-
@Override
95-
public TableSchema<? extends T> subtypeTableSchema(T itemContext) {
96-
return staticPolymorphicTableSchema.subtypeTableSchema(itemContext);
97-
}
90+
/**
91+
* Convenience for adding a subtype with its schema and discriminator value.
92+
*
93+
* @param subtypeClass the Java class of the subtype
94+
* @param tableSchema the schema for the subtype
95+
* @param discriminatorValue the discriminator value used in DynamoDB
96+
*/
97+
public <S extends T> Builder<T> addSubtype(Class<S> subtypeClass,
98+
TableSchema<S> tableSchema,
99+
String discriminatorValue) {
100+
delegate.addStaticSubtype(
101+
StaticSubtype.builder(subtypeClass)
102+
.tableSchema(tableSchema)
103+
.name(discriminatorValue)
104+
.build());
105+
return this;
106+
}
98107

99-
@Override
100-
public TableSchema<? extends T> subtypeTableSchema(Map<String, AttributeValue> itemContext) {
101-
return staticPolymorphicTableSchema.subtypeTableSchema(itemContext);
108+
/**
109+
* Builds the {@link PolymorphicTableSchema}.
110+
*/
111+
public PolymorphicTableSchema<T> build() {
112+
return new PolymorphicTableSchema<>(this);
113+
}
102114
}
103115
}

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

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package software.amazon.awssdk.enhanced.dynamodb.mapper;
1717

1818
import java.lang.invoke.MethodHandles;
19+
import java.util.Arrays;
1920
import java.util.Optional;
2021
import software.amazon.awssdk.annotations.SdkPublicApi;
2122
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
@@ -26,27 +27,26 @@
2627
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype;
2728

2829
/**
29-
* This class is responsible for constructing {@link TableSchema} objects from annotated classes.
30+
* Constructs {@link TableSchema} instances from annotated classes.
3031
*/
3132
@SdkPublicApi
3233
public class TableSchemaFactory {
3334
private TableSchemaFactory() {
3435
}
3536

3637
/**
37-
* Scans a class that has been annotated with DynamoDb enhanced client annotations and then returns an appropriate
38-
* {@link TableSchema} implementation that can map records to and from items of that class. Currently supported top level
39-
* annotations (see documentation on those classes for more information on how to use them):
40-
* <p>
41-
* {@link software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean}<br>
42-
* {@link software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable}
43-
* <p>
44-
* This is a moderately expensive operation, and should be performed sparingly. This is usually done once at application
45-
* startup.
38+
* Build a {@link TableSchema} by inspecting annotations on the given class.
4639
*
47-
* @param annotatedClass A class that has been annotated with DynamoDb enhanced client annotations.
48-
* @param <T> The type of the item this {@link TableSchema} will map records to.
49-
* @return An initialized {@link TableSchema}
40+
* <p>Supported top-level annotations:
41+
* <ul>
42+
* <li>{@link DynamoDbBean}</li>
43+
* <li>{@link DynamoDbImmutable}</li>
44+
* <li>{@link DynamoDbSupertype}</li>
45+
* </ul>
46+
*
47+
* @param annotatedClass the annotated class
48+
* @param <T> item type
49+
* @return initialized {@link TableSchema}
5050
*/
5151
public static <T> TableSchema<T> fromClass(Class<T> annotatedClass) {
5252
return fromClass(annotatedClass, MethodHandles.lookup(), new MetaTableSchemaCache());
@@ -58,35 +58,27 @@ static <T> TableSchema<T> fromMonomorphicClassWithoutUsingCache(Class<T> annotat
5858
if (isImmutableClass(annotatedClass)) {
5959
return ImmutableTableSchema.createWithoutUsingCache(annotatedClass, lookup, metaTableSchemaCache);
6060
}
61-
6261
if (isBeanClass(annotatedClass)) {
6362
return BeanTableSchema.createWithoutUsingCache(annotatedClass, lookup, metaTableSchemaCache);
6463
}
65-
66-
throw new IllegalArgumentException("Class does not appear to be a valid DynamoDb annotated class. [class = " +
67-
"\"" + annotatedClass + "\"]");
64+
throw new IllegalArgumentException("Class does not appear to be a valid DynamoDb annotated class. " +
65+
"[class = \"" + annotatedClass + "\"]");
6866
}
6967

7068
static <T> TableSchema<T> fromClass(Class<T> annotatedClass,
7169
MethodHandles.Lookup lookup,
7270
MetaTableSchemaCache metaTableSchemaCache) {
7371
Optional<MetaTableSchema<T>> metaTableSchema = metaTableSchemaCache.get(annotatedClass);
7472

75-
// If we get a cache hit...
7673
if (metaTableSchema.isPresent()) {
77-
// Either: use the cached concrete TableSchema if we have one
7874
if (metaTableSchema.get().isInitialized()) {
7975
return metaTableSchema.get().concreteTableSchema();
8076
}
81-
82-
// Or: return the uninitialized MetaTableSchema as this must be a recursive reference and it will be
83-
// initialized later as the chain completes
8477
return metaTableSchema.get();
8578
}
8679

87-
// Otherwise: cache doesn't know about this class; create a new one from scratch
8880
if (isPolymorphicClass(annotatedClass)) {
89-
return PolymorphicTableSchema.create(annotatedClass, lookup, metaTableSchemaCache);
81+
return buildPolymorphicFromAnnotations(annotatedClass, lookup, metaTableSchemaCache);
9082
}
9183

9284
if (isImmutableClass(annotatedClass)) {
@@ -101,10 +93,78 @@ static <T> TableSchema<T> fromClass(Class<T> annotatedClass,
10193
return BeanTableSchema.create(beanTableSchemaParams, metaTableSchemaCache);
10294
}
10395

104-
throw new IllegalArgumentException("Class does not appear to be a valid DynamoDb annotated class. [class = " +
105-
"\"" + annotatedClass + "\"]");
96+
throw new IllegalArgumentException("Class does not appear to be a valid DynamoDb annotated class. " +
97+
"[class = \"" + annotatedClass + "\"]");
98+
}
99+
100+
// -----------------------------
101+
// Polymorphic builder
102+
// -----------------------------
103+
private static <T> TableSchema<T> buildPolymorphicFromAnnotations(Class<T> polymorphicClass,
104+
MethodHandles.Lookup lookup,
105+
MetaTableSchemaCache cache) {
106+
MetaTableSchema<T> meta = cache.getOrCreate(polymorphicClass);
107+
108+
// Root must be a valid bean/immutable schema (not polymorphic)
109+
TableSchema<T> root = fromMonomorphicClassWithoutUsingCache(polymorphicClass, lookup, cache);
110+
111+
DynamoDbSupertype supertypeAnnotation = polymorphicClass.getAnnotation(DynamoDbSupertype.class);
112+
validateSupertypeAnnotationUsage(polymorphicClass, supertypeAnnotation);
113+
114+
PolymorphicTableSchema.Builder<T> builder =
115+
PolymorphicTableSchema.builder(polymorphicClass)
116+
.rootTableSchema(root)
117+
.discriminatorAttributeName(supertypeAnnotation.discriminatorAttributeName());
118+
119+
Arrays.stream(supertypeAnnotation.value())
120+
.forEach(sub -> builder.addStaticSubtype(
121+
resolvePolymorphicSubtype(polymorphicClass, lookup, sub, cache)));
122+
123+
PolymorphicTableSchema<T> result = builder.build();
124+
meta.initialize(result);
125+
return result;
126+
}
127+
128+
@SuppressWarnings("unchecked")
129+
private static <T> StaticSubtype<? extends T> resolvePolymorphicSubtype(Class<T> rootClass,
130+
MethodHandles.Lookup lookup,
131+
DynamoDbSupertype.Subtype sub,
132+
MetaTableSchemaCache cache) {
133+
Class<?> subtypeClass = sub.subtypeClass();
134+
135+
// VALIDATION: subtype must be assignable to root
136+
if (!rootClass.isAssignableFrom(subtypeClass)) {
137+
throw new IllegalArgumentException(
138+
"A subtype class [" + subtypeClass.getSimpleName()
139+
+ "] listed in the @DynamoDbSupertype annotation is not extending the root class.");
140+
}
141+
142+
Class<T> typed = (Class<T>) subtypeClass;
143+
144+
// The subtype may itself be bean/immutable or polymorphic; reuse the factory path.
145+
TableSchema<T> subtypeSchema = fromClass(typed, lookup, cache);
146+
147+
return StaticSubtype.builder(typed)
148+
.tableSchema(subtypeSchema)
149+
.name(sub.discriminatorValue())
150+
.build();
151+
}
152+
153+
private static <T> void validateSupertypeAnnotationUsage(Class<T> polymorphicClass,
154+
DynamoDbSupertype supertypeAnnotation) {
155+
if (supertypeAnnotation == null) {
156+
throw new IllegalArgumentException("A DynamoDb polymorphic class [" + polymorphicClass.getSimpleName()
157+
+ "] must be annotated with @DynamoDbSupertype");
158+
}
159+
if (supertypeAnnotation.value().length == 0) {
160+
throw new IllegalArgumentException("A DynamoDb polymorphic class [" + polymorphicClass.getSimpleName()
161+
+ "] must declare at least one subtype in @DynamoDbSupertype");
162+
}
106163
}
107164

165+
// -----------------------------
166+
// Annotation detection helpers
167+
// -----------------------------
108168
static boolean isDynamoDbAnnotatedClass(Class<?> clazz) {
109169
return isBeanClass(clazz) || isImmutableClass(clazz);
110170
}

services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/PolymorphicTableSchemaTest.java

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -132,40 +132,42 @@ public void testSerialize_recursivePolymorphicRecord() {
132132
assertThat(tableSchema.mapToItem(itemMap)).isEqualTo(record);
133133
}
134134

135+
// ------------------------------
136+
// Negative validation tests
137+
// ------------------------------
135138
@DynamoDbSupertype(@DynamoDbSupertype.Subtype(discriminatorValue = "one", subtypeClass = SimpleBean.class))
136-
public static class InvalidParentMissingAnnotation extends SimpleBean {
139+
public static class InvalidParentMissingDynamoDbBeanAnnotation extends SimpleBean {
137140
}
138141

139142
@Test
140143
public void shouldThrowException_ifPolymorphicParentNotAnnotatedAsDynamoDbBean() {
141-
assertThatThrownBy(() -> PolymorphicTableSchema.create(InvalidParentMissingAnnotation.class, null))
144+
assertThatThrownBy(() -> TableSchemaFactory.fromClass(InvalidParentMissingDynamoDbBeanAnnotation.class))
142145
.isInstanceOf(IllegalArgumentException.class)
143-
.hasMessage("Class does not appear to be a valid DynamoDb annotated class. [class = \"class software.amazon.awssdk"
144-
+ ".enhanced.dynamodb.mapper.PolymorphicTableSchemaTest$InvalidParentMissingAnnotation\"]");
146+
.hasMessageContaining("Class does not appear to be a valid DynamoDb annotated class");
145147
}
146148

147-
@DynamoDbSupertype(@DynamoDbSupertype.Subtype(discriminatorValue = "one", subtypeClass = SimpleBean.class))
148149
@DynamoDbBean
149-
public static class ValidParentSubtypeNotExtendingParent {
150+
@DynamoDbSupertype(@DynamoDbSupertype.Subtype(discriminatorValue = "one", subtypeClass = SimpleBean.class))
151+
public static class SubtypeNotExtendingDeclaredParent {
150152
}
151153

152154
@Test
153155
public void shouldThrowException_ifSubtypeNotExtendingParent() {
154-
assertThatThrownBy(() -> PolymorphicTableSchema.create(ValidParentSubtypeNotExtendingParent.class, null))
156+
assertThatThrownBy(() -> TableSchemaFactory.fromClass(SubtypeNotExtendingDeclaredParent.class))
155157
.isInstanceOf(IllegalArgumentException.class)
156-
.hasMessage("A subtype class [SimpleBean] listed in the @DynamoDbSupertype annotation is not extending the root "
157-
+ "class.");
158+
.hasMessage("A subtype class [SimpleBean] listed in the @DynamoDbSupertype annotation "
159+
+ "is not extending the root class.");
158160
}
159161

160162
@DynamoDbBean
161-
public static class InvalidParentNoSubtypeAnnotation {
163+
@DynamoDbSupertype( {})
164+
public static class PolymorphicParentWithNoSubtypes {
162165
}
163166

164167
@Test
165-
public void shouldThrowException_ifNoSubtypeAnnotation() {
166-
assertThatThrownBy(() -> PolymorphicTableSchema.create(InvalidParentNoSubtypeAnnotation.class, null))
168+
public void shouldThrowException_ifNoSubtypeDeclared() {
169+
assertThatThrownBy(() -> TableSchemaFactory.fromClass(PolymorphicParentWithNoSubtypes.class))
167170
.isInstanceOf(IllegalArgumentException.class)
168-
.hasMessage("A DynamoDb polymorphic class [InvalidParentNoSubtypeAnnotation] "
169-
+ "must be annotated with @DynamoDbSupertype");
171+
.hasMessageContaining("must declare at least one subtype in @DynamoDbSupertype");
170172
}
171-
}
173+
}

0 commit comments

Comments
 (0)