diff --git a/services-custom/dynamodb-enhanced/README.md b/services-custom/dynamodb-enhanced/README.md index 5cf3b0e8e076..9679a1c5cc69 100644 --- a/services-custom/dynamodb-enhanced/README.md +++ b/services-custom/dynamodb-enhanced/README.md @@ -685,3 +685,59 @@ private static final StaticTableSchema CUSTOMER_TABLE_SCHEMA = ``` Just as for annotations, you can flatten as many different eligible classes as you like using the builder pattern. + + + +## Polymorphic Types Support + +The Enhanced Client now supports **polymorphic type hierarchies**, allowing multiple subclasses to be stored in the same table. + +### Usage Example: Person Hierarchy + +```java +@DynamoDbBean +@DynamoDbSupertype( + value = { + @DynamoDbSupertype.Subtype(discriminatorValue = "EMPLOYEE", subtypeClass = Employee.class), + @DynamoDbSupertype.Subtype(discriminatorValue = "CUSTOMER", subtypeClass = Customer.class) + }, + discriminatorAttributeName = "discriminatorType" // optional, defaults to "type" +) +public class Person {} + +@DynamoDbBean +public class Employee extends Person { + private String employeeId; + public String getEmployeeId() { return employeeId; } + public void setEmployeeId(String id) { this.employeeId = id; } +} + +@DynamoDbBean +public class Customer extends Person { + private String customerId; + public String getCustomerId() { return customerId; } + public void setCustomerId(String id) { this.customerId = id; } +} +``` + +**Notes:** +- By default, the discriminator attribute is `"type"` unless overridden. + +### Static/Immutable Schema Support + +Polymorphism works for both **bean-style** and **immutable/builder-based** classes. + +```java +// Obtain schema for Person hierarchy +TableSchema schema = TableSchema.fromClass(Person.class); + +// Serialize Employee → DynamoDB item +Employee e = new Employee(); +e.setEmployeeId("E123"); +Map item = schema.itemToMap(e, false); +// → {"employeeId":"E123", "discriminatorType":"EMPLOYEE"} + +// Deserialize back +Person restored = schema.mapToItem(item); +// → returns Employee instance +``` \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/TableSchema.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/TableSchema.java index 068ea02ca919..38119f1cb73b 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/TableSchema.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/TableSchema.java @@ -31,6 +31,7 @@ import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable; +import software.amazon.awssdk.enhanced.dynamodb.mapper.TableSchemaFactory; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPreserveEmptyObject; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @@ -200,16 +201,7 @@ static ImmutableTableSchema fromImmutableClass(ImmutableTableSchemaParams * @return An initialized {@link TableSchema} */ static TableSchema fromClass(Class annotatedClass) { - if (annotatedClass.getAnnotation(DynamoDbImmutable.class) != null) { - return fromImmutableClass(annotatedClass); - } - - if (annotatedClass.getAnnotation(DynamoDbBean.class) != null) { - return fromBean(annotatedClass); - } - - throw new IllegalArgumentException("Class does not appear to be a valid DynamoDb annotated class. [class = " + - "\"" + annotatedClass + "\"]"); + return TableSchemaFactory.fromClass(annotatedClass); } /** @@ -344,4 +336,30 @@ default T mapToItem(Map attributeMap, boolean preserveEm default AttributeConverter converterForAttribute(Object key) { throw new UnsupportedOperationException(); } + + /** + * If applicable, returns a {@link TableSchema} for a specific object subtype. If the implementation does not support + * polymorphic mapping, then this method will, by default, return the current instance. This method is primarily used to pass + * the right contextual information to extensions when they are invoked mid-operation. This method is not required to get a + * polymorphic {@link TableSchema} to correctly map subtype objects using 'mapToItem' or 'itemToMap'. + * + * @param itemContext the subtype object to retrieve the subtype {@link TableSchema} for. + * @return the subtype {@link TableSchema} or the current {@link TableSchema} if subtypes are not supported. + */ + default TableSchema subtypeTableSchema(T itemContext) { + return this; + } + + /** + * If applicable, returns a {@link TableSchema} for a specific object subtype. If the implementation does not support + * polymorphic mapping, then this method will, by default, return the current instance. This method is primarily used to pass + * the right contextual information to extensions when they are invoked mid-operation. This method is not required to get a + * polymorphic {@link TableSchema} to correctly map subtype objects using 'mapToItem' or 'itemToMap'. + * + * @param itemContext the subtype object map to retrieve the subtype {@link TableSchema} for. + * @return the subtype {@link TableSchema} or the current {@link TableSchema} if subtypes are not supported. + */ + default TableSchema subtypeTableSchema(Map itemContext) { + return this; + } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java index 61d750e98a7e..6684fe29f1f0 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java @@ -106,12 +106,14 @@ public static T readAndTransformSingleItem(Map itemM } if (dynamoDbEnhancedClientExtension != null) { + TableSchema subtypeTableSchema = tableSchema.subtypeTableSchema(itemMap); + ReadModification readModification = dynamoDbEnhancedClientExtension.afterRead( DefaultDynamoDbExtensionContext.builder() .items(itemMap) - .tableSchema(tableSchema) + .tableSchema(subtypeTableSchema) .operationContext(operationContext) - .tableMetadata(tableSchema.tableMetadata()) + .tableMetadata(subtypeTableSchema.tableMetadata()) .build()); if (readModification != null && readModification.transformedItem() != null) { return tableSchema.mapToItem(readModification.transformedItem()); diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/PutItemOperation.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/PutItemOperation.java index 3e0b8a2cfa62..4223cc5f84af 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/PutItemOperation.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/PutItemOperation.java @@ -80,13 +80,14 @@ public PutItemRequest generateRequest(TableSchema tableSchema, throw new IllegalArgumentException("PutItem cannot be executed against a secondary index."); } - TableMetadata tableMetadata = tableSchema.tableMetadata(); + T item = request.map(PutItemEnhancedRequest::item, TransactPutItemEnhancedRequest::item); + TableSchema subtypeTableSchema = tableSchema.subtypeTableSchema(item); + TableMetadata tableMetadata = subtypeTableSchema.tableMetadata(); // Fail fast if required primary partition key does not exist and avoid the call to DynamoDb tableMetadata.primaryPartitionKey(); boolean alwaysIgnoreNulls = true; - T item = request.map(PutItemEnhancedRequest::item, TransactPutItemEnhancedRequest::item); Map itemMap = tableSchema.itemToMap(item, alwaysIgnoreNulls); WriteModification transformation = @@ -95,7 +96,7 @@ public PutItemRequest generateRequest(TableSchema tableSchema, .items(itemMap) .operationContext(operationContext) .tableMetadata(tableMetadata) - .tableSchema(tableSchema) + .tableSchema(subtypeTableSchema) .operationName(operationName()) .build()) : null; diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java index 0ffe361b5aed..97b2b6e672ac 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java @@ -109,8 +109,9 @@ public UpdateItemRequest generateRequest(TableSchema tableSchema, Map itemMap = ignoreNullsMode == IgnoreNullsMode.SCALAR_ONLY ? transformItemToMapForUpdateExpression(itemMapImmutable) : itemMapImmutable; - - TableMetadata tableMetadata = tableSchema.tableMetadata(); + + TableSchema subtypeTableSchema = tableSchema.subtypeTableSchema(item); + TableMetadata tableMetadata = subtypeTableSchema.tableMetadata(); WriteModification transformation = extension != null @@ -118,7 +119,7 @@ public UpdateItemRequest generateRequest(TableSchema tableSchema, .items(itemMap) .operationContext(operationContext) .tableMetadata(tableMetadata) - .tableSchema(tableSchema) + .tableSchema(subtypeTableSchema) .operationName(operationName()) .build()) : null; diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/BeanTableSchema.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/BeanTableSchema.java index a4a661dc274b..4f61da3ed1ac 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/BeanTableSchema.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/BeanTableSchema.java @@ -65,7 +65,6 @@ import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbFlatten; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbIgnore; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbIgnoreNulls; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPreserveEmptyObject; import software.amazon.awssdk.utils.StringUtils; @@ -100,7 +99,7 @@ * public Instant getCreatedDate() { return this.createdDate; } * public void setCreatedDate(Instant createdDate) { this.createdDate = createdDate; } * } - * + * * * * Creating an {@link BeanTableSchema} is a moderately expensive operation, and should be performed sparingly. This is @@ -167,39 +166,21 @@ public static BeanTableSchema create(BeanTableSchemaParams params) { new MetaTableSchemaCache())); } - private static BeanTableSchema create(BeanTableSchemaParams params, MetaTableSchemaCache metaTableSchemaCache) { + static BeanTableSchema create(BeanTableSchemaParams params, MetaTableSchemaCache metaTableSchemaCache) { Class beanClass = params.beanClass(); debugLog(beanClass, () -> "Creating bean schema"); // Fetch or create a new reference to this yet-to-be-created TableSchema in the cache MetaTableSchema metaTableSchema = metaTableSchemaCache.getOrCreate(beanClass); - BeanTableSchema newTableSchema = - new BeanTableSchema<>(createStaticTableSchema(params.beanClass(), params.lookup(), metaTableSchemaCache)); + BeanTableSchema newTableSchema = createWithoutUsingCache(beanClass, params.lookup(), metaTableSchemaCache); metaTableSchema.initialize(newTableSchema); return newTableSchema; } - // Called when creating an immutable TableSchema recursively. Utilizes the MetaTableSchema cache to stop infinite - // recursion - static TableSchema recursiveCreate(Class beanClass, MethodHandles.Lookup lookup, - MetaTableSchemaCache metaTableSchemaCache) { - Optional> metaTableSchema = metaTableSchemaCache.get(beanClass); - - // If we get a cache hit... - if (metaTableSchema.isPresent()) { - // Either: use the cached concrete TableSchema if we have one - if (metaTableSchema.get().isInitialized()) { - return metaTableSchema.get().concreteTableSchema(); - } - - // Or: return the uninitialized MetaTableSchema as this must be a recursive reference and it will be - // initialized later as the chain completes - return metaTableSchema.get(); - } - - // Otherwise: cache doesn't know about this class; create a new one from scratch - return create(BeanTableSchemaParams.builder(beanClass).lookup(lookup).build()); - + static BeanTableSchema createWithoutUsingCache(Class beanClass, + MethodHandles.Lookup lookup, + MetaTableSchemaCache metaTableSchemaCache) { + return new BeanTableSchema<>(createStaticTableSchema(beanClass, lookup, metaTableSchemaCache)); } private static StaticTableSchema createStaticTableSchema(Class beanClass, @@ -363,22 +344,15 @@ private static EnhancedType convertTypeToEnhancedType(Type type, clazz = (Class) type; } - if (clazz != null) { + if (clazz != null && TableSchemaFactory.isDynamoDbAnnotatedClass(clazz)) { Consumer attrConfiguration = b -> b.preserveEmptyObject(attributeConfiguration.preserveEmptyObject()) .ignoreNulls(attributeConfiguration.ignoreNulls()); - if (clazz.getAnnotation(DynamoDbImmutable.class) != null) { - return EnhancedType.documentOf( - (Class) clazz, - (TableSchema) ImmutableTableSchema.recursiveCreate(clazz, lookup, metaTableSchemaCache), - attrConfiguration); - } else if (clazz.getAnnotation(DynamoDbBean.class) != null) { - return EnhancedType.documentOf( - (Class) clazz, - (TableSchema) BeanTableSchema.recursiveCreate(clazz, lookup, metaTableSchemaCache), - attrConfiguration); - } + return EnhancedType.documentOf( + (Class) clazz, + (TableSchema) TableSchemaFactory.fromClass(clazz, lookup, metaTableSchemaCache), + attrConfiguration); } return EnhancedType.of(type); diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/ImmutableTableSchema.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/ImmutableTableSchema.java index 14ae05c0f5db..b0e93c1c48bc 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/ImmutableTableSchema.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/ImmutableTableSchema.java @@ -60,7 +60,6 @@ import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.StaticGetterMethod; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.BeanTableSchemaAttributeTag; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbConvertedBy; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbFlatten; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbIgnoreNulls; @@ -99,6 +98,7 @@ * public Customer build() { ... }; * } * } + * * * * Creating an {@link ImmutableTableSchema} is a moderately expensive operation, and should be performed sparingly. This is @@ -161,42 +161,24 @@ public static ImmutableTableSchema create(Class immutableClass) { return create(ImmutableTableSchemaParams.builder(immutableClass).build()); } - private static ImmutableTableSchema create(ImmutableTableSchemaParams params, - MetaTableSchemaCache metaTableSchemaCache) { + static ImmutableTableSchema create(ImmutableTableSchemaParams params, + MetaTableSchemaCache metaTableSchemaCache) { debugLog(params.immutableClass(), () -> "Creating immutable schema"); // Fetch or create a new reference to this yet-to-be-created TableSchema in the cache MetaTableSchema metaTableSchema = metaTableSchemaCache.getOrCreate(params.immutableClass()); - ImmutableTableSchema newTableSchema = - new ImmutableTableSchema<>(createStaticImmutableTableSchema(params.immutableClass(), - params.lookup(), - metaTableSchemaCache)); + ImmutableTableSchema newTableSchema = createWithoutUsingCache(params.immutableClass(), + params.lookup(), + metaTableSchemaCache); metaTableSchema.initialize(newTableSchema); return newTableSchema; } - // Called when creating an immutable TableSchema recursively. Utilizes the MetaTableSchema cache to stop infinite - // recursion - static TableSchema recursiveCreate(Class immutableClass, MethodHandles.Lookup lookup, - MetaTableSchemaCache metaTableSchemaCache) { - Optional> metaTableSchema = metaTableSchemaCache.get(immutableClass); - - // If we get a cache hit... - if (metaTableSchema.isPresent()) { - // Either: use the cached concrete TableSchema if we have one - if (metaTableSchema.get().isInitialized()) { - return metaTableSchema.get().concreteTableSchema(); - } - - // Or: return the uninitialized MetaTableSchema as this must be a recursive reference and it will be - // initialized later as the chain completes - return metaTableSchema.get(); - } - - // Otherwise: cache doesn't know about this class; create a new one from scratch - return create(ImmutableTableSchemaParams.builder(immutableClass).lookup(lookup).build(), metaTableSchemaCache); - + static ImmutableTableSchema createWithoutUsingCache(Class immutableClass, + MethodHandles.Lookup lookup, + MetaTableSchemaCache metaTableSchemaCache) { + return new ImmutableTableSchema<>(createStaticImmutableTableSchema(immutableClass, lookup, metaTableSchemaCache)); } private static StaticImmutableTableSchema createStaticImmutableTableSchema( @@ -326,25 +308,15 @@ private static EnhancedType convertTypeToEnhancedType(Type type, clazz = (Class) type; } - if (clazz != null) { + if (clazz != null && TableSchemaFactory.isDynamoDbAnnotatedClass(clazz)) { Consumer attrConfiguration = b -> b.preserveEmptyObject(attributeConfiguration.preserveEmptyObject()) .ignoreNulls(attributeConfiguration.ignoreNulls()); - if (clazz.getAnnotation(DynamoDbImmutable.class) != null) { - return EnhancedType.documentOf( - (Class) clazz, - (TableSchema) ImmutableTableSchema.recursiveCreate(clazz, - lookup, - metaTableSchemaCache), - attrConfiguration); - } else if (clazz.getAnnotation(DynamoDbBean.class) != null) { - return EnhancedType.documentOf( - (Class) clazz, - (TableSchema) BeanTableSchema.recursiveCreate(clazz, - lookup, - metaTableSchemaCache), - attrConfiguration); - } + + return EnhancedType.documentOf( + (Class) clazz, + (TableSchema) TableSchemaFactory.fromClass(clazz, lookup, metaTableSchemaCache), + attrConfiguration); } return EnhancedType.of(type); diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/PolymorphicTableSchema.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/PolymorphicTableSchema.java new file mode 100644 index 000000000000..258c68357309 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/PolymorphicTableSchema.java @@ -0,0 +1,115 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper; + +import java.util.Map; +import software.amazon.awssdk.annotations.NotThreadSafe; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * A polymorphic {@link TableSchema} that routes items to subtypes based on a discriminator attribute + * (see {@link DynamoDbSupertype}). + *

+ * Typically constructed automatically via {@link TableSchemaFactory#fromClass(Class)} + * when a class is annotated with {@link DynamoDbSupertype}. For manual assembly, use {@link #builder(Class)}. + */ +@SdkPublicApi +public final class PolymorphicTableSchema extends WrappedTableSchema> { + + private PolymorphicTableSchema(Builder builder) { + super(builder.delegate.build()); + } + + /** + * Returns a builder for manually creating a {@link PolymorphicTableSchema}. + * + * @param rootClass the root type that all subtypes must extend + */ + public static Builder builder(Class rootClass) { + return new Builder<>(rootClass); + } + + @Override + public TableSchema subtypeTableSchema(T itemContext) { + return delegateTableSchema().subtypeTableSchema(itemContext); + } + + @Override + public TableSchema subtypeTableSchema(Map itemContext) { + return delegateTableSchema().subtypeTableSchema(itemContext); + } + + @NotThreadSafe + public static final class Builder { + private final StaticPolymorphicTableSchema.Builder delegate; + + private Builder(Class rootClass) { + this.delegate = StaticPolymorphicTableSchema.builder(rootClass); + } + + /** + * Sets the schema for the root class. + */ + public Builder rootTableSchema(TableSchema root) { + delegate.rootTableSchema(root); + return this; + } + + /** + * Sets the discriminator attribute name (defaults to {@code "type"}). + */ + public Builder discriminatorAttributeName(String name) { + delegate.discriminatorAttributeName(name); + return this; + } + + /** + * Adds a fully constructed static subtype. + */ + public Builder addStaticSubtype(StaticSubtype subtype) { + delegate.addStaticSubtype(subtype); + return this; + } + + /** + * Convenience for adding a subtype with its schema and discriminator value. + * + * @param subtypeClass the Java class of the subtype + * @param tableSchema the schema for the subtype + * @param discriminatorValue the discriminator value used in DynamoDB + */ + public Builder addSubtype(Class subtypeClass, + TableSchema tableSchema, + String discriminatorValue) { + delegate.addStaticSubtype( + StaticSubtype.builder(subtypeClass) + .tableSchema(tableSchema) + .name(discriminatorValue) + .build()); + return this; + } + + /** + * Builds the {@link PolymorphicTableSchema}. + */ + public PolymorphicTableSchema build() { + return new PolymorphicTableSchema<>(this); + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/StaticPolymorphicTableSchema.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/StaticPolymorphicTableSchema.java new file mode 100644 index 000000000000..c386e16462f4 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/StaticPolymorphicTableSchema.java @@ -0,0 +1,288 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.utils.Validate; + +@SdkPublicApi +public final class StaticPolymorphicTableSchema implements TableSchema { + + private final TableSchema rootTableSchema; + private final String discriminatorAttributeName; + private final Map> subtypeByName; // discriminator -> subtype + private final List> subtypes; // ordered most-specific -> least-specific + private final boolean allowMissingDiscriminatorFallbackToRoot; + + private StaticPolymorphicTableSchema(TableSchema rootTableSchema, + String discriminatorAttributeName, + Map> subtypeByName, + List> subtypes, + boolean allowMissingDiscriminatorFallbackToRoot) { + this.rootTableSchema = rootTableSchema; + this.discriminatorAttributeName = discriminatorAttributeName; + this.subtypeByName = subtypeByName; + this.subtypes = subtypes; + this.allowMissingDiscriminatorFallbackToRoot = allowMissingDiscriminatorFallbackToRoot; + } + + public static Builder builder(Class itemClass) { + return new Builder<>(itemClass); + } + + // Serialization + @Override + public Map itemToMap(T item, boolean ignoreNulls) { + StaticSubtype subtype = cast(resolveByInstance(item)); + T castItem = subtype.tableSchema().itemType().rawClass().cast(item); + + Map result = new HashMap<>(subtype.tableSchema().itemToMap(castItem, ignoreNulls)); + + result.put(discriminatorAttributeName, AttributeValue.builder().s(subtype.name()).build()); + return result; + } + + @Override + public Map itemToMap(T item, Collection attributes) { + StaticSubtype subtype = cast(resolveByInstance(item)); + T castItem = subtype.tableSchema().itemType().rawClass().cast(item); + + Map result = + new HashMap<>(subtype.tableSchema().itemToMap(castItem, attributes)); + + if (attributes.contains(discriminatorAttributeName)) { + result.put(discriminatorAttributeName, AttributeValue.builder().s(subtype.name()).build()); + } + return result; + } + + // Deserialization + @Override + public T mapToItem(Map attributeMap) { + String discriminator = Optional.ofNullable(attributeMap.get(discriminatorAttributeName)) + .map(AttributeValue::s) + .orElse(null); + + if (discriminator == null) { + if (allowMissingDiscriminatorFallbackToRoot) { + // Legacy record (no discriminator) → use root schema + return rootTableSchema.mapToItem(attributeMap); + } + throw new IllegalArgumentException("Missing discriminator '" + discriminatorAttributeName + "' in item map"); + } + + StaticSubtype subtype = subtypeByName.get(discriminator); + if (subtype == null) { + throw new IllegalArgumentException("Unknown discriminator '" + discriminator + "'"); + } + + return returnWithSubtypeCast(subtype, ts -> ts.mapToItem(attributeMap)); + } + + @Override + public AttributeValue attributeValue(T item, String attributeName) { + if (discriminatorAttributeName.equals(attributeName)) { + StaticSubtype s = resolveByInstance(item); + return AttributeValue.builder().s(s.name()).build(); + } + + StaticSubtype subtype = cast(resolveByInstance(item)); + T castItem = subtype.tableSchema().itemType().rawClass().cast(item); + return subtype.tableSchema().attributeValue(castItem, attributeName); + } + + @Override + public TableMetadata tableMetadata() { + return rootTableSchema.tableMetadata(); + } + + @Override + public TableSchema subtypeTableSchema(T itemContext) { + return resolveByInstance(itemContext).tableSchema(); + } + + @Override + public TableSchema subtypeTableSchema(Map itemContext) { + String discriminator = Optional.ofNullable(itemContext.get(discriminatorAttributeName)) + .map(AttributeValue::s) + .orElse(null); + + if (discriminator == null) { + if (allowMissingDiscriminatorFallbackToRoot) { + return rootTableSchema; + } + throw new IllegalArgumentException("Missing discriminator '" + discriminatorAttributeName + "' in item map"); + } + + StaticSubtype subtype = subtypeByName.get(discriminator); + if (subtype == null) { + throw new IllegalArgumentException("Unknown discriminator '" + discriminator + "'"); + } + return subtype.tableSchema(); + } + + @Override + public EnhancedType itemType() { + return rootTableSchema.itemType(); + } + + @Override + public List attributeNames() { + return rootTableSchema.attributeNames(); + } + + @Override + public boolean isAbstract() { + return false; + } + + @Override + public AttributeConverter converterForAttribute(Object key) { + return rootTableSchema.converterForAttribute(key); + } + + private StaticSubtype resolveByInstance(T item) { + for (StaticSubtype s : subtypes) { + if (s.tableSchema().itemType().rawClass().isInstance(item)) { + return s; + } + } + throw new IllegalArgumentException("Cannot serialize item of type " + item.getClass().getName()); + } + + private static S returnWithSubtypeCast(StaticSubtype subtype, Function, S> fn) { + S r = fn.apply(subtype.tableSchema()); + return subtype.tableSchema().itemType().rawClass().cast(r); + } + + @SuppressWarnings("unchecked") + private static StaticSubtype cast(StaticSubtype s) { + return (StaticSubtype) s; + } + + public static final class Builder { + private TableSchema rootTableSchema; + private String discriminatorAttributeName; + private final List> staticSubtypes = new ArrayList<>(); + private boolean allowMissingDiscriminatorFallbackToRoot = false; + + private Builder(Class ignored) { + } + + /** + * Root (non-polymorphic) schema for the supertype. + */ + public Builder rootTableSchema(TableSchema root) { + this.rootTableSchema = root; + return this; + } + + /** + * Discriminator attribute name (defaults to "type"). + */ + public Builder discriminatorAttributeName(String name) { + this.discriminatorAttributeName = Validate.notEmpty(name, "discriminatorAttributeName"); + return this; + } + + /** + * Register one or more subtypes. Order is not required; we will sort most-specific first. + */ + @SafeVarargs + public final Builder addStaticSubtype(StaticSubtype... subs) { + Collections.addAll(this.staticSubtypes, subs); + return this; + } + + /** + * If true, legacy items without a discriminator are deserialized using the root schema. Defaults to false (strict mode). + */ + public Builder allowMissingDiscriminatorFallbackToRoot(boolean allow) { + this.allowMissingDiscriminatorFallbackToRoot = allow; + return this; + } + + public StaticPolymorphicTableSchema build() { + // Validate required fields + Validate.paramNotNull(rootTableSchema, "rootTableSchema"); + Validate.notEmpty(discriminatorAttributeName, "discriminatorAttributeName"); + Validate.notEmpty(staticSubtypes, "A polymorphic TableSchema must have at least one subtype"); + + // Each subtype must be assignable to root + Class root = rootTableSchema.itemType().rawClass(); + for (StaticSubtype s : staticSubtypes) { + Class sub = s.tableSchema().itemType().rawClass(); + if (!root.isAssignableFrom(sub)) { + throw new IllegalArgumentException( + "Subtype " + sub.getSimpleName() + " is not assignable to " + root.getSimpleName()); + } + } + + // Build discriminator map with uniqueness check + Map> byName = new LinkedHashMap<>(); + for (StaticSubtype s : staticSubtypes) { + String key = s.name(); + if (byName.putIfAbsent(key, s) != null) { + throw new IllegalArgumentException("Duplicate subtype discriminator: " + key); + } + } + + // Sort subtypes so that deeper subclasses (more specific) are checked first by resolveByInstance. + List> ordered = new ArrayList<>(staticSubtypes); + ordered.sort((first, second) -> Integer.compare( + inheritanceDepthFromRoot(second.tableSchema().itemType().rawClass(), root), + inheritanceDepthFromRoot(first.tableSchema().itemType().rawClass(), root) + )); + + return new StaticPolymorphicTableSchema<>( + rootTableSchema, + discriminatorAttributeName, + Collections.unmodifiableMap(byName), + Collections.unmodifiableList(ordered), + allowMissingDiscriminatorFallbackToRoot + ); + } + + /** + * Counts how many superclass steps it takes to reach the given root. + * Example: if Manager extends Employee extends Person (root), then: + * Manager → depth 2, Employee → depth 1, Person → depth 0. + */ + private static int inheritanceDepthFromRoot(Class type, Class root) { + int depth = 0; + Class current = type; + while (current != null && !current.equals(root)) { + current = current.getSuperclass(); + depth++; + } + return depth; + } + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/StaticSubtype.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/StaticSubtype.java new file mode 100644 index 000000000000..44a5442c531b --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/StaticSubtype.java @@ -0,0 +1,110 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper; + +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.utils.Validate; + +/** + * A structure that represents a mappable subtype to be used when constructing a {@link StaticPolymorphicTableSchema}. + * + * @param the subtype + */ +@SdkPublicApi +public class StaticSubtype { + private final TableSchema tableSchema; + private final String name; + + private StaticSubtype(Builder builder) { + this.tableSchema = Validate.notNull(builder.tableSchema, "A subtype must have a tableSchema associated with " + + "it. [subtypeClass = \"%s\"]", builder.subtypeClass.getName()); + + this.name = Validate.notEmpty(builder.name, + "A subtype must have one name associated with it. " + + "[subtypeClass = \"" + + builder.subtypeClass.getName() + "\"]"); + + if (this.tableSchema.isAbstract()) { + throw new IllegalArgumentException( + "A subtype may not be constructed with an abstract TableSchema. An abstract TableSchema is a " + + "TableSchema that does not know how to construct new objects of its type. " + + "[subtypeClass = \"" + builder.subtypeClass.getName() + "\"]"); + } + } + + /** + * Returns the {@link TableSchema} that can be used to map objects of this subtype. + */ + public TableSchema tableSchema() { + return this.tableSchema; + } + + /** + * Returns the name that would designate an object with a matching subtype name to be of this particular subtype. + */ + public String name() { + return this.name; + } + + /** + * Create a newly initialized builder for a {@link StaticSubtype}. + * + * @param subtypeClass The subtype class. + * @param The subtype. + */ + public static Builder builder(Class subtypeClass) { + return new Builder<>(subtypeClass); + } + + /** + * Builder class for a {@link StaticSubtype}. + * + * @param the subtype. + */ + public static class Builder { + private final Class subtypeClass; + private TableSchema tableSchema; + private String name; + + private Builder(Class subtypeClass) { + this.subtypeClass = subtypeClass; + } + + /** + * Sets the {@link TableSchema} that can be used to map objects of this subtype. + */ + public Builder tableSchema(TableSchema tableSchema) { + this.tableSchema = tableSchema; + return this; + } + + /** + * Sets the name that would designate an object with a matching subtype name to be of this particular subtype. + */ + public Builder name(String name) { + this.name = name; + return this; + } + + /** + * Builds a {@link StaticSubtype} based on the properties of this builder. + */ + public StaticSubtype build() { + return new StaticSubtype<>(this); + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/TableSchemaFactory.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/TableSchemaFactory.java new file mode 100644 index 000000000000..02eba4c1878c --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/TableSchemaFactory.java @@ -0,0 +1,183 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper; + +import java.lang.invoke.MethodHandles; +import java.util.Arrays; +import java.util.Optional; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.MetaTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.MetaTableSchemaCache; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype; + +/** + * Constructs {@link TableSchema} instances from annotated classes. + */ +@SdkPublicApi +public class TableSchemaFactory { + private TableSchemaFactory() { + } + + /** + * Build a {@link TableSchema} by inspecting annotations on the given class. + * + *

Supported top-level annotations: + *

    + *
  • {@link DynamoDbBean}
  • + *
  • {@link DynamoDbImmutable}
  • + *
  • {@link DynamoDbSupertype}
  • + *
+ * + * @param annotatedClass the annotated class + * @param item type + * @return initialized {@link TableSchema} + */ + public static TableSchema fromClass(Class annotatedClass) { + return fromClass(annotatedClass, MethodHandles.lookup(), new MetaTableSchemaCache()); + } + + static TableSchema fromMonomorphicClassWithoutUsingCache(Class annotatedClass, + MethodHandles.Lookup lookup, + MetaTableSchemaCache metaTableSchemaCache) { + if (isImmutableClass(annotatedClass)) { + return ImmutableTableSchema.createWithoutUsingCache(annotatedClass, lookup, metaTableSchemaCache); + } + if (isBeanClass(annotatedClass)) { + return BeanTableSchema.createWithoutUsingCache(annotatedClass, lookup, metaTableSchemaCache); + } + throw new IllegalArgumentException("Class does not appear to be a valid DynamoDb annotated class. " + + "[class = \"" + annotatedClass + "\"]"); + } + + static TableSchema fromClass(Class annotatedClass, + MethodHandles.Lookup lookup, + MetaTableSchemaCache metaTableSchemaCache) { + Optional> metaTableSchema = metaTableSchemaCache.get(annotatedClass); + + if (metaTableSchema.isPresent()) { + if (metaTableSchema.get().isInitialized()) { + return metaTableSchema.get().concreteTableSchema(); + } + return metaTableSchema.get(); + } + + if (isPolymorphicClass(annotatedClass)) { + return buildPolymorphicFromAnnotations(annotatedClass, lookup, metaTableSchemaCache); + } + + if (isImmutableClass(annotatedClass)) { + ImmutableTableSchemaParams immutableTableSchemaParams = + ImmutableTableSchemaParams.builder(annotatedClass).lookup(lookup).build(); + return ImmutableTableSchema.create(immutableTableSchemaParams, metaTableSchemaCache); + } + + if (isBeanClass(annotatedClass)) { + BeanTableSchemaParams beanTableSchemaParams = + BeanTableSchemaParams.builder(annotatedClass).lookup(lookup).build(); + return BeanTableSchema.create(beanTableSchemaParams, metaTableSchemaCache); + } + + throw new IllegalArgumentException("Class does not appear to be a valid DynamoDb annotated class. " + + "[class = \"" + annotatedClass + "\"]"); + } + + // ----------------------------- + // Polymorphic builder + // ----------------------------- + private static TableSchema buildPolymorphicFromAnnotations(Class polymorphicClass, + MethodHandles.Lookup lookup, + MetaTableSchemaCache cache) { + MetaTableSchema meta = cache.getOrCreate(polymorphicClass); + + // Root must be a valid bean/immutable schema (not polymorphic) + TableSchema root = fromMonomorphicClassWithoutUsingCache(polymorphicClass, lookup, cache); + + DynamoDbSupertype supertypeAnnotation = polymorphicClass.getAnnotation(DynamoDbSupertype.class); + validateSupertypeAnnotationUsage(polymorphicClass, supertypeAnnotation); + + PolymorphicTableSchema.Builder builder = + PolymorphicTableSchema.builder(polymorphicClass) + .rootTableSchema(root) + .discriminatorAttributeName(supertypeAnnotation.discriminatorAttributeName()); + + Arrays.stream(supertypeAnnotation.value()) + .forEach(sub -> builder.addStaticSubtype( + resolvePolymorphicSubtype(polymorphicClass, lookup, sub, cache))); + + PolymorphicTableSchema result = builder.build(); + meta.initialize(result); + return result; + } + + @SuppressWarnings("unchecked") + private static StaticSubtype resolvePolymorphicSubtype(Class rootClass, + MethodHandles.Lookup lookup, + DynamoDbSupertype.Subtype sub, + MetaTableSchemaCache cache) { + Class subtypeClass = sub.subtypeClass(); + + // VALIDATION: subtype must be assignable to root + if (!rootClass.isAssignableFrom(subtypeClass)) { + throw new IllegalArgumentException( + "A subtype class [" + subtypeClass.getSimpleName() + + "] listed in the @DynamoDbSupertype annotation is not extending the root class."); + } + + Class typed = (Class) subtypeClass; + + // The subtype may itself be bean/immutable or polymorphic; reuse the factory path. + TableSchema subtypeSchema = fromClass(typed, lookup, cache); + + return StaticSubtype.builder(typed) + .tableSchema(subtypeSchema) + .name(sub.discriminatorValue()) + .build(); + } + + private static void validateSupertypeAnnotationUsage(Class polymorphicClass, + DynamoDbSupertype supertypeAnnotation) { + if (supertypeAnnotation == null) { + throw new IllegalArgumentException("A DynamoDb polymorphic class [" + polymorphicClass.getSimpleName() + + "] must be annotated with @DynamoDbSupertype"); + } + if (supertypeAnnotation.value().length == 0) { + throw new IllegalArgumentException("A DynamoDb polymorphic class [" + polymorphicClass.getSimpleName() + + "] must declare at least one subtype in @DynamoDbSupertype"); + } + } + + // ----------------------------- + // Annotation detection helpers + // ----------------------------- + static boolean isDynamoDbAnnotatedClass(Class clazz) { + return isBeanClass(clazz) || isImmutableClass(clazz); + } + + private static boolean isPolymorphicClass(Class clazz) { + return clazz.getAnnotation(DynamoDbSupertype.class) != null; + } + + private static boolean isBeanClass(Class clazz) { + return clazz.getAnnotation(DynamoDbBean.class) != null; + } + + private static boolean isImmutableClass(Class clazz) { + return clazz.getAnnotation(DynamoDbImmutable.class) != null; + } +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/annotations/DynamoDbSupertype.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/annotations/DynamoDbSupertype.java new file mode 100644 index 000000000000..183fa7d85da9 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/annotations/DynamoDbSupertype.java @@ -0,0 +1,49 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import software.amazon.awssdk.annotations.SdkPublicApi; + +/** + * Denotes this class as mapping to multiple subtype classes. Determination of which subtype to use is based on a single + * attribute (the 'discriminator'). The attribute name is configured per-subtype, defaulting to "type". + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@SdkPublicApi +public @interface DynamoDbSupertype { + Subtype[] value(); + + /** DynamoDB attribute name which holds the discriminator value; defaults to "type" */ + String discriminatorAttributeName() default "type"; + + /** + * Declare one concrete subtype: its discriminator value, the attribute name (defaults to "type"), + * and the subtype’s Java class. + */ + @interface Subtype { + /** Value stored in the discriminator attribute for this subtype */ + String discriminatorValue() default ""; + + /** The concrete Java class for this subtype */ + Class subtypeClass(); + } +} + diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/PolymorphicItemWithVersionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/PolymorphicItemWithVersionTest.java new file mode 100644 index 000000000000..ff798240d50b --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/PolymorphicItemWithVersionTest.java @@ -0,0 +1,203 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.ReadModification; +import software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension; +import software.amazon.awssdk.enhanced.dynamodb.extensions.WriteModification; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.PolymorphicItemWithVersionSubtype; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.PolymorphicItemWithVersionSubtype.SubtypeWithVersion; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.PolymorphicItemWithVersionSubtype.SubtypeWithoutVersion; +import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; + +public class PolymorphicItemWithVersionTest extends LocalDynamoDbSyncTestBase { + + private static final String VERSION_ATTRIBUTE_METADATA_KEY = "VersionedRecordExtension:VersionAttribute"; + + private static final TableSchema TABLE_SCHEMA = + TableSchema.fromClass(PolymorphicItemWithVersionSubtype.class); + + private final FakeExtension fakeExtension = new FakeExtension(); + + private final DynamoDbEnhancedClient enhancedClient = + DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(VersionedRecordExtension.builder().build(), fakeExtension) + .build(); + + private final DynamoDbTable mappedTable = + enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + + private final class FakeExtension implements DynamoDbEnhancedClientExtension { + private DynamoDbExtensionContext.AfterRead afterReadContext; + private DynamoDbExtensionContext.BeforeWrite beforeWriteContext; + + public void reset() { + this.afterReadContext = null; + this.beforeWriteContext = null; + } + + public DynamoDbExtensionContext.AfterRead getAfterReadContext() { + return this.afterReadContext; + } + + public DynamoDbExtensionContext.BeforeWrite getBeforeWriteContext() { + return this.beforeWriteContext; + } + + @Override + public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) { + this.beforeWriteContext = context; + return DynamoDbEnhancedClientExtension.super.beforeWrite(context); + } + + @Override + public ReadModification afterRead(DynamoDbExtensionContext.AfterRead context) { + this.afterReadContext = context; + return DynamoDbEnhancedClientExtension.super.afterRead(context); + } + } + + @Before + public void createTable() { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + } + + @After + public void deleteTable() { + getDynamoDbClient().deleteTable(DeleteTableRequest.builder() + .tableName(getConcreteTableName("table-name")) + .build()); + } + + @Test + public void putItem_givenPolymorphicObjectWithVersion_shouldUpdateVersionInTheDatabase() { + SubtypeWithVersion record = new SubtypeWithVersion(); + record.setId("123"); + record.setAttributeTwo("value"); + + mappedTable.putItem(record); + + PolymorphicItemWithVersionSubtype result = mappedTable.getItem(Key.builder().partitionValue("123").build()); + + assertThat(result).isInstanceOf(SubtypeWithVersion.class); + assertThat((SubtypeWithVersion) result).satisfies(typedResult -> { + assertThat(typedResult.getId()).isEqualTo("123"); + assertThat(typedResult.getAttributeTwo()).isEqualTo("value"); + assertThat(typedResult.getVersion()).isEqualTo(1); + }); + } + + @Test + public void putItem_beforeWrite_providesCorrectSubtypeTableSchema() { + SubtypeWithVersion record = new SubtypeWithVersion(); + record.setId("123"); + record.setAttributeTwo("value"); + + mappedTable.putItem(record); + + assertThat(fakeExtension.getBeforeWriteContext().tableSchema().itemType()) + .isEqualTo(EnhancedType.of(SubtypeWithVersion.class)); + } + + @Test + public void updateItem_beforeWrite_providesCorrectSubtypeTableSchema() { + SubtypeWithVersion record = new SubtypeWithVersion(); + record.setId("123"); + record.setAttributeTwo("value"); + + mappedTable.updateItem(record); + + assertThat(fakeExtension.getBeforeWriteContext().tableSchema().itemType()) + .isEqualTo(EnhancedType.of(SubtypeWithVersion.class)); + } + + @Test + public void updateItem_subtypeWithVersion_updatesVersion() { + SubtypeWithVersion record = new SubtypeWithVersion(); + record.setId("123"); + record.setAttributeTwo("value"); + + mappedTable.updateItem(record); + + PolymorphicItemWithVersionSubtype result = mappedTable.getItem(Key.builder().partitionValue("123").build()); + + assertThat(result).isInstanceOf(SubtypeWithVersion.class); + assertThat((SubtypeWithVersion) result).satisfies(typedResult -> { + assertThat(typedResult.getId()).isEqualTo("123"); + assertThat(typedResult.getAttributeTwo()).isEqualTo("value"); + assertThat(typedResult.getVersion()).isEqualTo(1); + }); + } + + @Test + public void getItem_subtypeWithVersion_hasCorrectMetadataAfterReadContext() { + SubtypeWithVersion record = new SubtypeWithVersion(); + record.setId("123"); + record.setAttributeTwo("value"); + + mappedTable.putItem(record); + fakeExtension.reset(); + + mappedTable.getItem(Key.builder().partitionValue("123").build()); + + assertThat(fakeExtension.getAfterReadContext().tableMetadata().customMetadata()) + .containsEntry(VERSION_ATTRIBUTE_METADATA_KEY, "version"); + } + + /** + * If an enhanced write request reads data (such as 'returnValues' in PutItem) the afterRead hook is invoked in extensions. + * This test ensures that for a polymorphic table schema the correct TableMetadata for the subtype that was actually returned + * (and not the one written) is used. + */ + @Test + public void putItem_returnsExistingRecord_andHasCorrectMetadataAfterReadContext() { + SubtypeWithVersion record = new SubtypeWithVersion(); + record.setId("123"); + record.setAttributeTwo("value1"); + + mappedTable.putItem(record); + fakeExtension.reset(); + + SubtypeWithoutVersion newRecord = new SubtypeWithoutVersion(); + newRecord.setId("123"); + newRecord.setAttributeOne("value2"); + + PutItemEnhancedRequest enhancedRequest = + PutItemEnhancedRequest.builder(PolymorphicItemWithVersionSubtype.class) + .returnValues("ALL_OLD") + .item(newRecord) + .build(); + + mappedTable.putItem(enhancedRequest); + + assertThat(fakeExtension.getAfterReadContext().tableMetadata().customMetadata()) + .containsEntry(VERSION_ATTRIBUTE_METADATA_KEY, "version"); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/PolymorphicItemWithVersionSubtype.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/PolymorphicItemWithVersionSubtype.java new file mode 100644 index 000000000000..bcb992407fcf --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/PolymorphicItemWithVersionSubtype.java @@ -0,0 +1,136 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests.models; + +import java.util.Objects; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype.Subtype; + +@DynamoDbBean +@DynamoDbSupertype( { + @Subtype(discriminatorValue = "no_version", subtypeClass = PolymorphicItemWithVersionSubtype.SubtypeWithoutVersion.class), + @Subtype(discriminatorValue = "with_version", subtypeClass = PolymorphicItemWithVersionSubtype.SubtypeWithVersion.class)}) +public class PolymorphicItemWithVersionSubtype { + private String id; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbBean + public static class SubtypeWithoutVersion extends PolymorphicItemWithVersionSubtype { + private String attributeOne; + + public String getAttributeOne() { + return attributeOne; + } + + public void setAttributeOne(String attributeOne) { + this.attributeOne = attributeOne; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + SubtypeWithoutVersion that = (SubtypeWithoutVersion) o; + return Objects.equals(attributeOne, that.attributeOne); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), attributeOne); + } + } + + @DynamoDbBean + public static class SubtypeWithVersion extends PolymorphicItemWithVersionSubtype { + private String attributeTwo; + private Integer version; + + public String getAttributeTwo() { + return attributeTwo; + } + + public void setAttributeTwo(String attributeTwo) { + this.attributeTwo = attributeTwo; + } + + @DynamoDbVersionAttribute + public Integer getVersion() { + return version; + } + + public void setVersion(Integer version) { + this.version = version; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + + SubtypeWithVersion that = (SubtypeWithVersion) o; + + if (attributeTwo != null ? !attributeTwo.equals(that.attributeTwo) : that.attributeTwo != null) { + return false; + } + return version != null ? version.equals(that.version) : that.version == null; + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + (attributeTwo != null ? attributeTwo.hashCode() : 0); + result = 31 * result + (version != null ? version.hashCode() : 0); + return result; + } + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + PolymorphicItemWithVersionSubtype that = (PolymorphicItemWithVersionSubtype) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } +} + diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/PolymorphicTableSchemaTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/PolymorphicTableSchemaTest.java new file mode 100644 index 000000000000..ce8d2f7a10a1 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/PolymorphicTableSchemaTest.java @@ -0,0 +1,173 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype; +import software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.SimpleBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic.FlattenedPolymorphicChild; +import software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic.FlattenedPolymorphicParent; +import software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic.FlattenedPolymorphicParentComposite; +import software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic.NestedPolymorphicChild; +import software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic.NestedPolymorphicParent; +import software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic.RecursivePolymorphicChild; +import software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic.RecursivePolymorphicParent; +import software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic.SimplePolymorphicChildOne; +import software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic.SimplePolymorphicParent; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +public class PolymorphicTableSchemaTest { + + @Test + public void testSerialize_simplePolymorphicRecord() { + TableSchema tableSchema = + TableSchemaFactory.fromClass(SimplePolymorphicParent.class); + + SimplePolymorphicChildOne record = new SimplePolymorphicChildOne(); + record.setAttributeOne("attributeOneValue"); + + Map itemMap = tableSchema.itemToMap(record, false); + + assertThat(itemMap).containsEntry("type", AttributeValue.builder().s("one").build()); + assertThat(itemMap).containsEntry("attributeOne", AttributeValue.builder().s("attributeOneValue").build()); + + assertThat(tableSchema.mapToItem(itemMap)).isEqualTo(record); + } + + @Test + public void testSerialize_flattenedPolymorphicRecord() { + TableSchema tableSchema = + TableSchemaFactory.fromClass(FlattenedPolymorphicParent.class); + + FlattenedPolymorphicParentComposite parentComposite = new FlattenedPolymorphicParentComposite(); + parentComposite.setCompositeAttribute("compositeAttributeValue"); + + FlattenedPolymorphicChild record = new FlattenedPolymorphicChild(); + record.setFlattenedPolyParentComposite(parentComposite); + record.setAttributeOne("attributeOneValue"); + + Map itemMap = tableSchema.itemToMap(record, false); + + assertThat(itemMap).containsEntry("type", AttributeValue.builder().s("one").build()); + assertThat(itemMap).containsEntry("attributeOne", AttributeValue.builder().s("attributeOneValue").build()); + assertThat(itemMap).containsEntry("compositeAttribute", AttributeValue.builder().s("compositeAttributeValue").build()); + assertThat(tableSchema.mapToItem(itemMap)).isEqualTo(record); + } + + @Test + public void testSerialize_nestedPolymorphicRecord() { + TableSchema tableSchema = TableSchemaFactory.fromClass(NestedPolymorphicParent.class); + + SimplePolymorphicChildOne nestedRecord = new SimplePolymorphicChildOne(); + nestedRecord.setAttributeOne("attributeOneValue"); + nestedRecord.setParentAttribute("parentAttributeValue"); + + NestedPolymorphicChild record = new NestedPolymorphicChild(); + record.setSimplePolyParent(nestedRecord); + + Map itemMap = tableSchema.itemToMap(record, false); + + assertThat(itemMap).containsEntry("type", AttributeValue.builder().s("nested_one").build()); + assertThat(itemMap).hasEntrySatisfying("simplePolyParent", av -> + assertThat(av.m()).satisfies(nestedItemMap -> { + assertThat(nestedItemMap).containsEntry("type", AttributeValue.builder().s("one").build()); + assertThat(nestedItemMap).containsEntry( + "attributeOne", AttributeValue.builder().s("attributeOneValue").build()); + assertThat(nestedItemMap).containsEntry( + "parentAttribute", AttributeValue.builder().s("parentAttributeValue").build()); + })); + } + + @Test + public void testSerialize_recursivePolymorphicRecord() { + TableSchema tableSchema = TableSchemaFactory.fromClass(RecursivePolymorphicParent.class); + + RecursivePolymorphicChild recursiveRecord1 = new RecursivePolymorphicChild(); + recursiveRecord1.setAttributeOne("one"); + + RecursivePolymorphicChild recursiveRecord2 = new RecursivePolymorphicChild(); + recursiveRecord2.setAttributeOne("two"); + + RecursivePolymorphicChild record = new RecursivePolymorphicChild(); + record.setRecursivePolyParent(recursiveRecord1); + record.setRecursivePolyParentOne(recursiveRecord2); + record.setAttributeOne("parent"); + + Map itemMap = tableSchema.itemToMap(record, false); + + assertThat(itemMap).containsEntry("type", AttributeValue.builder().s("recursive_one").build()); + assertThat(itemMap).hasEntrySatisfying("recursivePolyParent", av -> + assertThat(av.m()).satisfies(nestedItemMap -> { + assertThat(nestedItemMap).containsEntry( + "type", AttributeValue.builder().s("recursive_one").build()); + assertThat(nestedItemMap).containsEntry( + "attributeOne", AttributeValue.builder().s("one").build()); + })); + assertThat(itemMap).hasEntrySatisfying("recursivePolyParentOne", av -> + assertThat(av.m()).satisfies(nestedItemMap -> { + assertThat(nestedItemMap).containsEntry( + "type", AttributeValue.builder().s("recursive_one").build()); + assertThat(nestedItemMap).containsEntry( + "attributeOne", AttributeValue.builder().s("two").build()); + })); + assertThat(tableSchema.mapToItem(itemMap)).isEqualTo(record); + } + + // ------------------------------ + // Negative validation tests + // ------------------------------ + @DynamoDbSupertype(@DynamoDbSupertype.Subtype(discriminatorValue = "one", subtypeClass = SimpleBean.class)) + public static class InvalidParentMissingDynamoDbBeanAnnotation extends SimpleBean { + } + + @Test + public void shouldThrowException_ifPolymorphicParentNotAnnotatedAsDynamoDbBean() { + assertThatThrownBy(() -> TableSchemaFactory.fromClass(InvalidParentMissingDynamoDbBeanAnnotation.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Class does not appear to be a valid DynamoDb annotated class"); + } + + @DynamoDbBean + @DynamoDbSupertype(@DynamoDbSupertype.Subtype(discriminatorValue = "one", subtypeClass = SimpleBean.class)) + public static class SubtypeNotExtendingDeclaredParent { + } + + @Test + public void shouldThrowException_ifSubtypeNotExtendingParent() { + assertThatThrownBy(() -> TableSchemaFactory.fromClass(SubtypeNotExtendingDeclaredParent.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("A subtype class [SimpleBean] listed in the @DynamoDbSupertype annotation " + + "is not extending the root class."); + } + + @DynamoDbBean + @DynamoDbSupertype( {}) + public static class PolymorphicParentWithNoSubtypes { + } + + @Test + public void shouldThrowException_ifNoSubtypeDeclared() { + assertThatThrownBy(() -> TableSchemaFactory.fromClass(PolymorphicParentWithNoSubtypes.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must declare at least one subtype in @DynamoDbSupertype"); + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/StaticPolymorphicTableSchemaTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/StaticPolymorphicTableSchemaTest.java new file mode 100644 index 000000000000..e2f1b28fdb38 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/StaticPolymorphicTableSchemaTest.java @@ -0,0 +1,598 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +public class StaticPolymorphicTableSchemaTest { + + // ============================================================ + // Root: Person (immutable) + // ============================================================ + private static final StaticImmutableTableSchema ROOT_PERSON_SCHEMA = + StaticImmutableTableSchema.builder(Person.class, Person.Builder.class) + .addAttribute(String.class, a -> a.name("id") + .getter(Person::id) + .setter(Person.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("type") + .getter(Person::type) + .setter(Person.Builder::type)) + .newItemBuilder(Person::builder, Person.Builder::build) + .build(); + + // ============================================================ + // Subtypes (Employee, Manager) + // ============================================================ + private static final TableSchema EMPLOYEE_SCHEMA = + StaticImmutableTableSchema.builder(Employee.class, Employee.Builder.class) + .addAttribute(String.class, a -> a.name("department") + .getter(Employee::department) + .setter(Employee.Builder::department)) + .newItemBuilder(Employee::builder, Employee.Builder::build) + .extend(ROOT_PERSON_SCHEMA) + .build(); + + private static final TableSchema MANAGER_SCHEMA = + StaticImmutableTableSchema.builder(Manager.class, Manager.Builder.class) + .addAttribute(Integer.class, a -> a.name("level") + .getter(Manager::level) + .setter(Manager.Builder::level)) + .newItemBuilder(Manager::builder, Manager.Builder::build) + .extend(ROOT_PERSON_SCHEMA) + .build(); + + // ============================================================ + // Polymorphic schema (Person) + // ============================================================ + private static final TableSchema PERSON_SCHEMA = + StaticPolymorphicTableSchema.builder(Person.class) + .rootTableSchema(ROOT_PERSON_SCHEMA) + .discriminatorAttributeName("type") + .addStaticSubtype( + StaticSubtype.builder(Employee.class).name("EMPLOYEE").tableSchema(EMPLOYEE_SCHEMA).build(), + StaticSubtype.builder(Manager.class).name("MANAGER").tableSchema(MANAGER_SCHEMA).build()) + .build(); + + // ============================================================ + // Sample items + // ============================================================ + private static final Employee EMPLOYEE = + Employee.builder() + .id("p:1") + .type("EMPLOYEE") + .department("engineering") + .build(); + + private static final Manager MANAGER = + Manager.builder() + .id("p:2") + .type("MANAGER") + .level(7) + .build(); + + // ============================================================ + // Sample maps + // ============================================================ + private static final Map EMPLOYEE_MAP; + private static final Map MANAGER_MAP; + + static { + Map employeeAttributes = new HashMap<>(); + employeeAttributes.put("id", AttributeValue.builder().s("p:1").build()); + employeeAttributes.put("type", AttributeValue.builder().s("EMPLOYEE").build()); + employeeAttributes.put("department", AttributeValue.builder().s("engineering").build()); + EMPLOYEE_MAP = Collections.unmodifiableMap(employeeAttributes); + + Map managerAttributes = new HashMap<>(); + managerAttributes.put("id", AttributeValue.builder().s("p:2").build()); + managerAttributes.put("type", AttributeValue.builder().s("MANAGER").build()); + managerAttributes.put("level", AttributeValue.builder().n("7").build()); + MANAGER_MAP = Collections.unmodifiableMap(managerAttributes); + } + + // ============================================================ + // Negative validations + // ============================================================ + @Test + public void shouldThrowWhenNoSubtypes() { + assertThatThrownBy(() -> + StaticPolymorphicTableSchema.builder(Person.class) + .rootTableSchema(ROOT_PERSON_SCHEMA) + .discriminatorAttributeName("type") + .addStaticSubtype() // none + .build() + ) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("A polymorphic TableSchema must have at least one subtype"); + } + + @Test + public void shouldThrowWhenNoRootSchema() { + assertThatThrownBy(() -> + StaticPolymorphicTableSchema.builder(Person.class) + .discriminatorAttributeName("type") + .addStaticSubtype( + StaticSubtype.builder(Employee.class) + .name("EMPLOYEE") + .tableSchema(EMPLOYEE_SCHEMA) + .build()) + .build() + ) + .isInstanceOf(NullPointerException.class) + .hasMessage("rootTableSchema must not be null."); + } + + @Test + public void shouldThrowOnDuplicateNames() { + assertThatThrownBy(() -> + StaticPolymorphicTableSchema.builder(Person.class) + .rootTableSchema(ROOT_PERSON_SCHEMA) + .discriminatorAttributeName("type") + .addStaticSubtype( + StaticSubtype.builder(Employee.class).name("EMPLOYEE").tableSchema(EMPLOYEE_SCHEMA).build(), + StaticSubtype.builder(Manager.class).name("EMPLOYEE").tableSchema(MANAGER_SCHEMA).build()) + .build() + ) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Duplicate subtype discriminator: EMPLOYEE"); + } + + // ============================================================ + // Serialization / Deserialization + // ============================================================ + @Test + public void shouldSerializeToMap() { + assertThat(PERSON_SCHEMA.itemToMap(EMPLOYEE, false)).isEqualTo(EMPLOYEE_MAP); + assertThat(PERSON_SCHEMA.itemToMap(MANAGER, false)).isEqualTo(MANAGER_MAP); + + // also when ignoreNulls == true + assertThat(PERSON_SCHEMA.itemToMap(EMPLOYEE, true)).isEqualTo(EMPLOYEE_MAP); + assertThat(PERSON_SCHEMA.itemToMap(MANAGER, true)).isEqualTo(MANAGER_MAP); + } + + @Test + public void shouldSerializePartialAttributes() { + Map result = + PERSON_SCHEMA.itemToMap(EMPLOYEE, Arrays.asList("id", "department")); + assertThat(result) + .containsOnlyKeys("id", "department") + .containsEntry("id", AttributeValue.builder().s("p:1").build()) + .containsEntry("department", AttributeValue.builder().s("engineering").build()); + } + + @Test + public void shouldDeserializeFromMap() { + assertThat(PERSON_SCHEMA.mapToItem(EMPLOYEE_MAP)) + .usingRecursiveComparison() + .isEqualTo(EMPLOYEE); + + assertThat(PERSON_SCHEMA.mapToItem(MANAGER_MAP)) + .usingRecursiveComparison() + .isEqualTo(MANAGER); + } + + @Test + public void shouldReturnCorrectAttributeValue() { + assertThat(PERSON_SCHEMA.attributeValue(EMPLOYEE, "department")) + .isEqualTo(AttributeValue.builder().s("engineering").build()); + } + + @Test + public void metadataAndTypeChecks() { + assertThat(PERSON_SCHEMA.itemType().rawClass()).isEqualTo(Person.class); + assertThat(PERSON_SCHEMA.attributeNames()).containsExactlyInAnyOrder("id", "type"); + assertThat(PERSON_SCHEMA.isAbstract()).isFalse(); + } + + @Test + public void polymorphicTableSchemaShouldTakeMetadataFromRoot() { + assertThat(PERSON_SCHEMA.tableMetadata()).isEqualTo(ROOT_PERSON_SCHEMA.tableMetadata()); + } + + // Even if subtypes are registered in the wrong order, the schema should still + // match the most specific subtype (Manager instead of Employee). + @Test + public void resolvesMostSpecificSubtype_evenIfRegisteredAfterParent() { + TableSchema schema = + StaticPolymorphicTableSchema.builder(Person.class) + .rootTableSchema(ROOT_PERSON_SCHEMA) + .discriminatorAttributeName("type") + .addStaticSubtype( + StaticSubtype.builder(Employee.class).name("EMPLOYEE").tableSchema(EMPLOYEE_SCHEMA).build(), + StaticSubtype.builder(Manager.class).name("MANAGER").tableSchema(MANAGER_SCHEMA).build()) + .build(); + + Map out = schema.itemToMap(MANAGER, false); + assertThat(out).isEqualTo(MANAGER_MAP); + } + + // Items created before polymorphism was introduced (without a discriminator) + // can still be deserialized using the root schema if fallback is enabled. + @Test + public void fallsBackToRootSchema_whenDiscriminatorIsMissing_andFallbackEnabled() { + TableSchema schema = + StaticPolymorphicTableSchema.builder(Person.class) + .rootTableSchema(ROOT_PERSON_SCHEMA) + .discriminatorAttributeName("type") + .allowMissingDiscriminatorFallbackToRoot(true) + .addStaticSubtype( + StaticSubtype.builder(Employee.class).name("EMPLOYEE").tableSchema(EMPLOYEE_SCHEMA).build(), + StaticSubtype.builder(Manager.class).name("MANAGER").tableSchema(MANAGER_SCHEMA).build()) + .build(); + + Map legacy = new HashMap<>(); + legacy.put("id", AttributeValue.builder().s("legacy:1").build()); // no "type" + + assertThat(schema.mapToItem(legacy)) + .usingRecursiveComparison() + .isEqualTo(Person.builder().id("legacy:1").type(null).build()); + } + + // ============================================================ + // NEW SCENARIO 1: “Diamond-like” with interfaces -> register concrete leaf + // ============================================================ + + /** + * Director is a concrete class implementing two interfaces “below” Manager. + */ + static class Director extends Manager { + private final String scope; + + Director(Builder b) { + super(b); + this.scope = b.scope; + } + + public String scope() { + return scope; + } + + static class Builder extends Manager.Builder { + private String scope; + + @Override + public Builder id(String v) { + super.id(v); + return this; + } + + @Override + public Builder type(String v) { + super.type(v); + return this; + } + + @Override + public Builder department(String v) { + super.department(v); + return this; + } + + @Override + public Builder level(Integer v) { + super.level(v); + return this; + } + + public Builder scope(String v) { + this.scope = v; + return this; + } + + @Override + public Director build() { + return new Director(this); + } + } + + static Builder builder() { + return new Builder(); + } + } + + private static final Director DIRECTOR = + Director.builder() + .id("p:3") + .type("DIRECTOR") + .department("engineering") + .level(9) + .scope("global") + .build(); + + private static final Map DIRECTOR_MAP; + + static { + Map d = new HashMap<>(); + d.put("id", AttributeValue.builder().s("p:3").build()); + d.put("type", AttributeValue.builder().s("DIRECTOR").build()); + d.put("department", AttributeValue.builder().s("engineering").build()); + d.put("level", AttributeValue.builder().n("9").build()); + d.put("scope", AttributeValue.builder().s("global").build()); + DIRECTOR_MAP = Collections.unmodifiableMap(d); + } + + /** + * When the hierarchy forms a “diamond” via interfaces, registering the *concrete* leaf (Director) is unambiguous and both + * serialization and deserialization work as expected. + */ + @Test + public void diamondLikeInterfaces_resolveByConcreteLeafSubtype() { + // Build a Director schema that includes inherited attributes as well. + StaticImmutableTableSchema directorSchema = + StaticImmutableTableSchema.builder(Director.class, Director.Builder.class) + .addAttribute(String.class, a -> a.name("department") + .getter(Director::department) // inherited + // from Employee + .setter(Director.Builder::department)) // inherited setter + .addAttribute(Integer.class, a -> a.name("level") + .getter(Director::level) // inherited + // from Manager + .setter(Director.Builder::level)) + .addAttribute(String.class, a -> a.name("scope") + .getter(Director::scope) + .setter(Director.Builder::scope)) + .newItemBuilder(Director::builder, Director.Builder::build) + .extend(ROOT_PERSON_SCHEMA) // still inherit id/type from Person + .build(); + + TableSchema schema = + StaticPolymorphicTableSchema.builder(Person.class) + .rootTableSchema(ROOT_PERSON_SCHEMA) + .discriminatorAttributeName("type") + .addStaticSubtype( + StaticSubtype.builder(Employee.class).name("EMPLOYEE").tableSchema(EMPLOYEE_SCHEMA).build(), + StaticSubtype.builder(Manager.class).name("MANAGER").tableSchema(MANAGER_SCHEMA).build(), + StaticSubtype.builder(Director.class).name("DIRECTOR").tableSchema(directorSchema).build() + ) + .build(); + + assertThat(schema.itemToMap(DIRECTOR, false)).isEqualTo(DIRECTOR_MAP); + assertThat(schema.mapToItem(DIRECTOR_MAP)) + .usingRecursiveComparison() + .isEqualTo(DIRECTOR); + } + + + // ============================================================ + // NEW SCENARIO 2: Incompatible subtype registration is rejected + // ============================================================ + + /** + * A concrete class not related to Person; used to assert validation. + */ + static class Unrelated { + final String id; + + Unrelated(String id) { + this.id = id; + } + + static class Builder { + String id; + + public Builder id(String v) { + this.id = v; + return this; + } + + public Unrelated build() { + return new Unrelated(id); + } + } + + static Builder builder() { + return new Builder(); + } + } + + private static final StaticImmutableTableSchema UNRELATED_SCHEMA = + StaticImmutableTableSchema.builder(Unrelated.class, Unrelated.Builder.class) + .addAttribute(String.class, a -> a.name("id") + .getter(u -> u.id) + .setter(Unrelated.Builder::id) + .tags(primaryPartitionKey())) + .newItemBuilder(Unrelated::builder, Unrelated.Builder::build) + .build(); + + /** + * Trying to register a subtype that does not extend/implement the root (Person) fails validation with a clear message. + */ + @SuppressWarnings( {"rawtypes", "unchecked"}) + @Test + public void registeringIncompatibleSubtype_isRejectedWithClearMessage() { + // Raw type on purpose to simulate a user mistake that bypasses generics. + StaticSubtype bad = + StaticSubtype.builder(Unrelated.class) + .name("X") + .tableSchema(UNRELATED_SCHEMA) + .build(); + + assertThatThrownBy(() -> + StaticPolymorphicTableSchema.builder(Person.class) + .rootTableSchema(ROOT_PERSON_SCHEMA) + .discriminatorAttributeName("type") + .addStaticSubtype(bad) + .build() + ) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("is not assignable to Person"); + } + + // ============================================================ + // NEW SCENARIO 3: Unknown discriminator is a hard error + // ============================================================ + @Test + public void unknownDiscriminator_isHardError_noSilentFallback() { + Map unknown = new HashMap<>(); + unknown.put("id", AttributeValue.builder().s("p:999").build()); + unknown.put("type", AttributeValue.builder().s("INTERN").build()); // not registered + + assertThatThrownBy(() -> PERSON_SCHEMA.mapToItem(unknown)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown discriminator 'INTERN'"); + } + + // ============================================================ + // Simple immutable beans for the tests (Person / Employee / Manager) + // ============================================================ + + // Base (Person) + static class Person { + private final String id; + private final String type; + + Person(Builder b) { + this.id = b.id; + this.type = b.type; + } + + static class Builder { + protected String id; + protected String type; + + public Builder id(String v) { + this.id = v; + return this; + } + + public Builder type(String v) { + this.type = v; + return this; + } + + public Person build() { + return new Person(this); + } + } + + static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public String type() { + return type; + } + } + + // Mid-level (Employee) + static class Employee extends Person { + private final String department; + + Employee(Builder b) { + super(b); + this.department = b.department; + } + + static class Builder extends Person.Builder { + private String department; + + @Override + public Builder id(String v) { + super.id(v); + return this; + } + + @Override + public Builder type(String v) { + super.type(v); + return this; + } + + public Builder department(String v) { + this.department = v; + return this; + } + + @Override + public Employee build() { + return new Employee(this); + } + } + + static Builder builder() { + return new Builder(); + } + + public String department() { + return department; + } + } + + // Bottom-level (Manager) + static class Manager extends Employee { + private final Integer level; + + Manager(Builder b) { + super(b); + this.level = b.level; + } + + static class Builder extends Employee.Builder { + private Integer level; + + @Override + public Builder id(String v) { + super.id(v); + return this; + } + + @Override + public Builder type(String v) { + super.type(v); + return this; + } + + @Override + public Builder department(String v) { + super.department(v); + return this; + } + + public Builder level(Integer v) { + this.level = v; + return this; + } + + @Override + public Manager build() { + return new Manager(this); + } + } + + static Builder builder() { + return new Builder(); + } + + public Integer level() { + return level; + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/StaticSubtypeTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/StaticSubtypeTest.java new file mode 100644 index 000000000000..b0061c827a62 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/StaticSubtypeTest.java @@ -0,0 +1,77 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.SimpleBean; + +@RunWith(MockitoJUnitRunner.class) +public class StaticSubtypeTest { + private static final TableSchema SIMPLE_BEAN_TABLE_SCHEMA = TableSchema.fromClass(SimpleBean.class); + + private abstract static class AbstractItem { + } + + @Test + public void testValidSubtype() { + StaticSubtype staticSubtype = + StaticSubtype.builder(SimpleBean.class) + .name("customer") + .tableSchema(SIMPLE_BEAN_TABLE_SCHEMA) + .build(); + + assertThat(staticSubtype.name()).isEqualTo("customer"); + assertThat(staticSubtype.tableSchema()).isEqualTo(SIMPLE_BEAN_TABLE_SCHEMA); + } + + @Test + public void testInvalidSubtype_withMissingNames_throwsException() { + assertThatThrownBy(StaticSubtype.builder(SimpleBean.class) + .tableSchema(SIMPLE_BEAN_TABLE_SCHEMA)::build) + .isInstanceOf(NullPointerException.class) + .hasMessage("A subtype must have one name associated with it. " + + "[subtypeClass = \"software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.SimpleBean\"]"); + } + + @Test + public void testInvalidSubtype_withMissingTableSchema_throwsException() { + assertThatThrownBy(StaticSubtype.builder(SimpleBean.class) + .name("customer")::build) + .isInstanceOf(NullPointerException.class) + .hasMessage("A subtype must have a tableSchema associated with it. " + + "[subtypeClass = \"software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.SimpleBean\"]"); + } + + + @Test + public void testInvalidSubtype_withAbstractTableSchema_throwsException() { + TableSchema tableSchema = StaticTableSchema.builder(AbstractItem.class).build(); + + assertThatThrownBy(StaticSubtype.builder(AbstractItem.class) + .tableSchema(tableSchema) + .name("customer")::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("A subtype may not be constructed with an abstract TableSchema. An abstract TableSchema is a TableSchema " + + "that does not know how to construct new objects of its type. " + + "[subtypeClass = \"software.amazon.awssdk.enhanced.dynamodb.mapper.StaticSubtypeTest$AbstractItem\"]"); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/FlattenedPolymorphicChild.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/FlattenedPolymorphicChild.java new file mode 100644 index 000000000000..25f35407873d --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/FlattenedPolymorphicChild.java @@ -0,0 +1,50 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic; + +import java.util.Objects; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +@DynamoDbBean +public class FlattenedPolymorphicChild extends FlattenedPolymorphicParent { + String attributeOne; + + public String getAttributeOne() { + return attributeOne; + } + + public FlattenedPolymorphicChild setAttributeOne(String attributeOne) { + this.attributeOne = attributeOne; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + FlattenedPolymorphicChild that = (FlattenedPolymorphicChild) o; + return Objects.equals(attributeOne, that.attributeOne); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), attributeOne); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/FlattenedPolymorphicParent.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/FlattenedPolymorphicParent.java new file mode 100644 index 000000000000..09d9f64913d3 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/FlattenedPolymorphicParent.java @@ -0,0 +1,51 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic; + +import java.util.Objects; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbFlatten; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype.Subtype; + +@DynamoDbBean +@DynamoDbSupertype(@Subtype(discriminatorValue = "one", subtypeClass = FlattenedPolymorphicChild.class)) +public class FlattenedPolymorphicParent { + FlattenedPolymorphicParentComposite flattenedPolymorphicParentComposite; + + @DynamoDbFlatten + public FlattenedPolymorphicParentComposite getFlattenedPolyParentComposite() { + return flattenedPolymorphicParentComposite; + } + + public void setFlattenedPolyParentComposite(FlattenedPolymorphicParentComposite flattenedPolymorphicParentComposite) { + this.flattenedPolymorphicParentComposite = flattenedPolymorphicParentComposite; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + FlattenedPolymorphicParent that = (FlattenedPolymorphicParent) o; + return Objects.equals(flattenedPolymorphicParentComposite, that.flattenedPolymorphicParentComposite); + } + + @Override + public int hashCode() { + return Objects.hashCode(flattenedPolymorphicParentComposite); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/FlattenedPolymorphicParentComposite.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/FlattenedPolymorphicParentComposite.java new file mode 100644 index 000000000000..f7813241c7cb --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/FlattenedPolymorphicParentComposite.java @@ -0,0 +1,47 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic; + +import java.util.Objects; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +@DynamoDbBean +public class FlattenedPolymorphicParentComposite { + String compositeAttribute; + + public String getCompositeAttribute() { + return compositeAttribute; + } + + public FlattenedPolymorphicParentComposite setCompositeAttribute(String compositeAttribute) { + this.compositeAttribute = compositeAttribute; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + FlattenedPolymorphicParentComposite that = (FlattenedPolymorphicParentComposite) o; + return Objects.equals(compositeAttribute, that.compositeAttribute); + } + + @Override + public int hashCode() { + return Objects.hashCode(compositeAttribute); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/NestedPolymorphicChild.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/NestedPolymorphicChild.java new file mode 100644 index 000000000000..ecf9eb8c8c0a --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/NestedPolymorphicChild.java @@ -0,0 +1,49 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic; + +import java.util.Objects; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +@DynamoDbBean +public class NestedPolymorphicChild extends NestedPolymorphicParent { + SimplePolymorphicParent simplePolymorphicParent; + + public SimplePolymorphicParent getSimplePolyParent() { + return simplePolymorphicParent; + } + + public void setSimplePolyParent(SimplePolymorphicParent simplePolymorphicParent) { + this.simplePolymorphicParent = simplePolymorphicParent; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + NestedPolymorphicChild that = (NestedPolymorphicChild) o; + return Objects.equals(simplePolymorphicParent, that.simplePolymorphicParent); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), simplePolymorphicParent); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/NestedPolymorphicParent.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/NestedPolymorphicParent.java new file mode 100644 index 000000000000..373295c4a0d5 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/NestedPolymorphicParent.java @@ -0,0 +1,25 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic; + +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype.Subtype; + +@DynamoDbBean +@DynamoDbSupertype(@Subtype(discriminatorValue = "nested_one", subtypeClass = NestedPolymorphicChild.class)) +public class NestedPolymorphicParent { +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/RecursivePolymorphicChild.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/RecursivePolymorphicChild.java new file mode 100644 index 000000000000..8dee9dd7e7da --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/RecursivePolymorphicChild.java @@ -0,0 +1,59 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic; + +import java.util.Objects; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +@DynamoDbBean +public class RecursivePolymorphicChild extends RecursivePolymorphicParent { + RecursivePolymorphicParent recursivePolymorphicParentOne; + String attributeOne; + + public RecursivePolymorphicParent getRecursivePolyParentOne() { + return recursivePolymorphicParentOne; + } + + public void setRecursivePolyParentOne(RecursivePolymorphicParent recursivePolymorphicParentOne) { + this.recursivePolymorphicParentOne = recursivePolymorphicParentOne; + } + + public String getAttributeOne() { + return attributeOne; + } + + public void setAttributeOne(String attributeOne) { + this.attributeOne = attributeOne; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + RecursivePolymorphicChild that = (RecursivePolymorphicChild) o; + return Objects.equals(recursivePolymorphicParentOne, that.recursivePolymorphicParentOne) && Objects.equals(attributeOne, + that.attributeOne); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), recursivePolymorphicParentOne, attributeOne); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/RecursivePolymorphicParent.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/RecursivePolymorphicParent.java new file mode 100644 index 000000000000..bd3bd09b30dc --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/RecursivePolymorphicParent.java @@ -0,0 +1,50 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic; + +import java.util.Objects; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype.Subtype; + +@DynamoDbBean +@DynamoDbSupertype(@Subtype(discriminatorValue = "recursive_one", subtypeClass = RecursivePolymorphicChild.class)) +public class RecursivePolymorphicParent { + RecursivePolymorphicParent recursivePolymorphicParent; + + public RecursivePolymorphicParent getRecursivePolyParent() { + return recursivePolymorphicParent; + } + + public void setRecursivePolyParent(RecursivePolymorphicParent recursivePolymorphicParent) { + this.recursivePolymorphicParent = recursivePolymorphicParent; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + RecursivePolymorphicParent that = (RecursivePolymorphicParent) o; + return Objects.equals(recursivePolymorphicParent, that.recursivePolymorphicParent); + } + + @Override + public int hashCode() { + return Objects.hashCode(recursivePolymorphicParent); + } +} + diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/SimplePolymorphicChildOne.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/SimplePolymorphicChildOne.java new file mode 100644 index 000000000000..a949528db0e6 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/SimplePolymorphicChildOne.java @@ -0,0 +1,49 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic; + +import java.util.Objects; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +@DynamoDbBean +public class SimplePolymorphicChildOne extends SimplePolymorphicParent { + String attributeOne; + + public String getAttributeOne() { + return attributeOne; + } + + public void setAttributeOne(String attributeOne) { + this.attributeOne = attributeOne; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + SimplePolymorphicChildOne that = (SimplePolymorphicChildOne) o; + return Objects.equals(attributeOne, that.attributeOne); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), attributeOne); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/SimplePolymorphicChildTwo.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/SimplePolymorphicChildTwo.java new file mode 100644 index 000000000000..ebea2f617e20 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/SimplePolymorphicChildTwo.java @@ -0,0 +1,49 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic; + +import java.util.Objects; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; + +@DynamoDbBean +public class SimplePolymorphicChildTwo extends SimplePolymorphicParent { + String attributeTwo; + + public String getAttributeTwo() { + return attributeTwo; + } + + public void setAttributeTwo(String attributeTwo) { + this.attributeTwo = attributeTwo; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + SimplePolymorphicChildTwo that = (SimplePolymorphicChildTwo) o; + return Objects.equals(attributeTwo, that.attributeTwo); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), attributeTwo); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/SimplePolymorphicParent.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/SimplePolymorphicParent.java new file mode 100644 index 000000000000..69fa43323e39 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/mapper/testbeans/polymorphic/SimplePolymorphicParent.java @@ -0,0 +1,54 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.polymorphic; + +import java.util.Objects; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSupertype.Subtype; + +@DynamoDbBean +@DynamoDbSupertype( { + @Subtype(discriminatorValue = "one", subtypeClass = SimplePolymorphicChildOne.class), + @Subtype(discriminatorValue = "two", subtypeClass = SimplePolymorphicChildTwo.class) +}) +public class SimplePolymorphicParent { + + private String parentAttribute; + + public String getParentAttribute() { + return parentAttribute; + } + + public SimplePolymorphicParent setParentAttribute(String parentAttribute) { + this.parentAttribute = parentAttribute; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + SimplePolymorphicParent that = (SimplePolymorphicParent) o; + return Objects.equals(parentAttribute, that.parentAttribute); + } + + @Override + public int hashCode() { + return Objects.hashCode(parentAttribute); + } +}