Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DynamoDB-enhanced: Add support for polymorphic subtypes to mapper #2861

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"category": "DynamoDB Enhanced Client",
"contributor": "bmaizels",
"type": "feature",
"description": "Added support for polymorphic mapping of subtypes to better support single-table design."
}
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,12 @@
<binaryCompatible>true</binaryCompatible>
<sourceCompatible>true</sourceCompatible>
</overrideCompatibilityChangeParameter>
<overrideCompatibilityChangeParameter>
<!-- Rule is unstable between versions 14.4 and 15.4 and has flagged false positives -->
<compatibilityChange>METHOD_ABSTRACT_ADDED_IN_IMPLEMENTED_INTERFACE</compatibilityChange>
<binaryCompatible>true</binaryCompatible>
<sourceCompatible>true</sourceCompatible>
</overrideCompatibilityChangeParameter>
</overrideCompatibilityChangeParameters>
</parameter>
</configuration>
Expand Down
82 changes: 81 additions & 1 deletion services-custom/dynamodb-enhanced/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## Overview

Mid-level DynamoDB mapper/abstraction for Java using the v2 AWS SDK.
A library that enhances DynamoDB operations by directly mapping your Java data objects to and from records in your
DynamoDB tables.

## Getting Started
All the examples below use a fictional Customer class. This class is
Expand Down Expand Up @@ -253,6 +254,85 @@ how Lombok's 'onMethod' feature is leveraged to copy the attribute based DynamoD
}
```

### Using subtypes to assist with single-table design

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be useful to highlight here a sample multi-schema table for matching the proposed annotations

pk sk ...
customer Marta ...
order pizza ...

It's considered a best practice in some situations to combine entities of various types into a single table in DynamoDb
to enable the querying of multiple related entities without the need to actually join data across multiple tables. The
enhanced client assists with this by supporting polymorphic mapping into distinct subtypes.

Let's say you have a customer:

```java
public class Customer {
String getCustomerId();
void setId(String id);

String getName();
void setName(String name);
}
```

And an order that's associated with a customer:

```java
public class Order {
String getOrderId();
void setOrderId();

String getCustomerId();
void setCustomerId();
}
```

You could choose to store both of these in a single table that is indexed by customer ID, and create a TableSchema that
is capable of mapping both types of entities into a common supertype:

```java
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.DynamoDbSubtypeName;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSubtypes;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSubtypes.Subtype;

@DynamoDbBean
@DynamoDbSubtypes({
@Subtype(name = "CUSTOMER", subtypeClass = Customer.class),
@Subtype(name = "ORDER", subtypeClass = Order.class)})
public class CustomerRelatedEntity {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be abstract and/or the annotation enforce that ( I see its abstract in code below)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Java code would be way neater if this could be an interface and classes implementing it. Is that a feasible option?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dvlato could you elaborate on exactly what you are proposing? Maybe with an example?

@DynamoDbSubtypeName
String getEntityType();
void setEntityType();

@DynamoDbPartitionKey
String getCustomerId();
void setCustomerId();
}

@DynamoDbBean
public class Customer extends CustomerRelatedEntity {
String getName();
void setName(String name);
}

@DynamoDbBean
public class Order extends CustomerRelatedEntity {
String getOrderId();
void setOrderId();
}
```

Now all you have to do is create a TableSchema that maps the supertype class:
```java
TableSchema<CustomerRelatedEntity> tableSchema = TableSchema.fromClass(CustomerRelatedEntity.class);
```
Now you have a `TableSchema` that can map any objects of both `Customer` and `Order` and write them to the table,
and can also read any record from the table and correctly instantiate it using the subtype class. So it's now possible
to write a single query that will return both the customer record and all order records associated with a specific
customer ID.

As with all the other `TableSchema` implementations, a static version is provided that allows reflective introspection
to be skipped entirely and is recommended for applications where cold-start latency is critical. See the javadocs for
`StaticPolymorphicTableSchema` for an example of how to use this.

### Non-blocking asynchronous operations
If your application requires non-blocking asynchronous calls to
DynamoDb, then you can use the asynchronous implementation of the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@
import software.amazon.awssdk.enhanced.dynamodb.mapper.ImmutableTableSchema;
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticImmutableTableSchema;
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;

Expand Down Expand Up @@ -113,16 +112,7 @@ static <T> ImmutableTableSchema<T> fromImmutableClass(Class<T> immutableClass) {
* @return An initialized {@link TableSchema}
*/
static <T> TableSchema<T> fromClass(Class<T> 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);
}

/**
Expand All @@ -137,6 +127,9 @@ static <T> TableSchema<T> fromClass(Class<T> annotatedClass) {
* instead if you need to preserve empty object.
*
* <p>
* If the implementation supports polymorphic mapping, the context of the attribute map will be used to determine
* the correct subtype of the object returned.
* <p>
* API Implementors Note:
* <p>
* {@link #mapToItem(Map, boolean)} must be implemented if {@code preserveEmptyObject} behavior is desired.
Expand Down Expand Up @@ -164,6 +157,10 @@ static <T> TableSchema<T> fromClass(Class<T> annotatedClass) {
* will be mapped as null. You can use {@link DynamoDbPreserveEmptyObject} to configure this behavior for nested objects.
*
* <p>
* If the implementation supports polymorphic mapping, the context of the attribute map will be used to determine
* the correct subtype of the object returned.
*
* <p>
* API Implementors Note:
* <p>
* This method must be implemented if {@code preserveEmptyObject} behavior is to be supported
Expand All @@ -188,6 +185,9 @@ default T mapToItem(Map<String, AttributeValue> attributeMap, boolean preserveEm
/**
* Takes a modelled object and converts it into a raw map of {@link AttributeValue} that the DynamoDb low-level
* SDK can work with.
* <p>
* If the implementation supports polymorphic mapping, the context of the item will be used to determine the correct
* subtype schema of the returned map.
*
* @param item The modelled Java object to convert into a map of attributes.
* @param ignoreNulls If set to true; any null values in the Java object will not be added to the output map.
Expand All @@ -201,6 +201,9 @@ default T mapToItem(Map<String, AttributeValue> attributeMap, boolean preserveEm
* Takes a modelled object and extracts a specific set of attributes which are then returned as a map of
* {@link AttributeValue} that the DynamoDb low-level SDK can work with. This method is typically used to extract
* just the key attributes of a modelled item and will not ignore nulls on the modelled object.
* <p>
* If the implementation supports polymorphic mapping, the context of the item will be used to determine the correct
* subtype schema of the returned map.
*
* @param item The modelled Java object to extract the map of attributes from.
* @param attributes A collection of attribute names to extract into the output map.
Expand Down Expand Up @@ -257,4 +260,30 @@ default T mapToItem(Map<String, AttributeValue> attributeMap, boolean preserveEm
default AttributeConverter<T> 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<? extends T> 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<? extends T> subtypeTableSchema(Map<String, AttributeValue> itemContext) {
return this;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,14 @@ public static <T> T readAndTransformSingleItem(Map<String, AttributeValue> itemM
}

if (dynamoDbEnhancedClientExtension != null) {
TableSchema<? extends T> 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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSubtypeName;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbUpdateBehavior;

/**
Expand Down Expand Up @@ -57,4 +58,8 @@ public static StaticAttributeTag attributeTagFor(DynamoDbSecondarySortKey annota
public static StaticAttributeTag attributeTagFor(DynamoDbUpdateBehavior annotation) {
return StaticAttributeTags.updateBehavior(annotation.value());
}

public static StaticAttributeTag attributeTagFor(DynamoDbSubtypeName annotation) {
return StaticAttributeTags.subtypeName();
}
}
Original file line number Diff line number Diff line change
@@ -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.internal.mapper;

import java.util.Optional;
import java.util.function.Consumer;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType;
import software.amazon.awssdk.enhanced.dynamodb.TableMetadata;
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag;
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata;

@SdkInternalApi
public class SubtypeNameTag implements StaticAttributeTag {
private static final SubtypeNameTag INSTANCE = new SubtypeNameTag();
private static final String CUSTOM_METADATA_KEY = "SubtypeName";

private SubtypeNameTag() {
}

public static Optional<String> resolve(TableMetadata tableMetadata) {
return tableMetadata.customMetadataObject(CUSTOM_METADATA_KEY, String.class);
}

@Override
public Consumer<StaticTableMetadata.Builder> modifyMetadata(String attributeName,
AttributeValueType attributeValueType) {
if (!AttributeValueType.S.equals(attributeValueType)) {
throw new IllegalArgumentException(
String.format("Attribute '%s' of type %s is not a suitable type to be used as a subtype name. Only string is "
+ "supported for this purpose.", attributeName, attributeValueType.name()));
}

return metadata ->
metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, attributeName);
}

public static SubtypeNameTag create() {
return INSTANCE;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,14 @@ public PutItemRequest generateRequest(TableSchema<T> 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<? extends T> 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<String, AttributeValue> itemMap = tableSchema.itemToMap(item, alwaysIgnoreNulls);

WriteModification transformation =
Expand All @@ -95,7 +96,7 @@ public PutItemRequest generateRequest(TableSchema<T> tableSchema,
.items(itemMap)
.operationContext(operationContext)
.tableMetadata(tableMetadata)
.tableSchema(tableSchema)
.tableSchema(subtypeTableSchema)
.operationName(operationName())
.build())
: null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,16 @@ public UpdateItemRequest generateRequest(TableSchema<T> tableSchema,
.orElse(null);

Map<String, AttributeValue> itemMap = tableSchema.itemToMap(item, Boolean.TRUE.equals(ignoreNulls));
TableMetadata tableMetadata = tableSchema.tableMetadata();
TableSchema<? extends T> subtypeTableSchema = tableSchema.subtypeTableSchema(item);
TableMetadata tableMetadata = subtypeTableSchema.tableMetadata();

WriteModification transformation =
extension != null
? extension.beforeWrite(DefaultDynamoDbExtensionContext.builder()
.items(itemMap)
.operationContext(operationContext)
.tableMetadata(tableMetadata)
.tableSchema(tableSchema)
.tableSchema(subtypeTableSchema)
.operationName(operationName())
.build())
: null;
Expand Down
Loading