Skip to content

DynamoDB Enhanced Client: Added support for attribute level custom up… #2076

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

Merged
merged 2 commits into from
Oct 3, 2020
Merged
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,5 @@
{
"type": "feature",
"category": "AWS DynamoDB Enhanced Client",
"description": "Added support for attribute level custom update behaviors such as 'write if not exists'."
}
41 changes: 41 additions & 0 deletions services-custom/dynamodb-enhanced/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,47 @@ private static final StaticTableSchema<Customer> CUSTOMER_TABLE_SCHEMA =
.build();
```

### Changing update behavior of attributes
It is possible to customize the update behavior as applicable to individual attributes when an 'update' operation is
performed (e.g. UpdateItem or an update within TransactWriteItems).

For example, say like you wanted to store a 'created on' timestamp on your record, but only wanted its value to be
written if there is no existing value for the attribute stored in the database then you would use the
WRITE_IF_NOT_EXISTS update behavior. Here is an example using a bean:

```java
@DynamoDbBean
public class Customer extends GenericRecord {
private String id;
private Instant createdOn;

@DynamoDbPartitionKey
public String getId() { return this.id; }
public void setId(String id) { this.name = id; }

@DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
public Instant getCreatedOn() { return this.createdOn; }
public void setCreatedOn(Instant createdOn) { this.createdOn = createdOn; }
}
```

Same example using a static table schema:

```java
static final TableSchema<Customer> CUSTOMER_TABLE_SCHEMA =
TableSchema.builder(Customer.class)
.newItemSupplier(Customer::new)
.addAttribute(String.class, a -> a.name("id")
.getter(Customer::getId)
.setter(Customer::setId)
.tags(primaryPartitionKey()))
.addAttribute(Instant.class, a -> a.name("createdOn")
.getter(Customer::getCreatedOn)
.setter(Customer::setCreatedOn)
.tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)))
.build();
```

### Flat map attributes from another class
If the attributes for your table record are spread across several
different Java objects, either through inheritance or composition, the
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.DynamoDbUpdateBehavior;

/**
* Static provider class for core {@link BeanTableSchema} attribute tags. Each of the implemented annotations has a
Expand Down Expand Up @@ -52,4 +53,8 @@ public static StaticAttributeTag attributeTagFor(DynamoDbSecondaryPartitionKey a
public static StaticAttributeTag attributeTagFor(DynamoDbSecondarySortKey annotation) {
return StaticAttributeTags.secondarySortKey(Arrays.asList(annotation.indexNames()));
}

public static StaticAttributeTag attributeTagFor(DynamoDbUpdateBehavior annotation) {
return StaticAttributeTags.updateBehavior(annotation.value());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* 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.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;
import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior;

@SdkInternalApi
public class UpdateBehaviorTag implements StaticAttributeTag {
private static final String CUSTOM_METADATA_KEY_PREFIX = "UpdateBehavior:";
private static final UpdateBehavior DEFAULT_UPDATE_BEHAVIOR = UpdateBehavior.WRITE_ALWAYS;
private static final UpdateBehaviorTag WRITE_ALWAYS_TAG = new UpdateBehaviorTag(UpdateBehavior.WRITE_ALWAYS);
private static final UpdateBehaviorTag WRITE_IF_NOT_EXISTS_TAG =
new UpdateBehaviorTag(UpdateBehavior.WRITE_IF_NOT_EXISTS);

private final UpdateBehavior updateBehavior;

private UpdateBehaviorTag(UpdateBehavior updateBehavior) {
this.updateBehavior = updateBehavior;
}

public static UpdateBehaviorTag fromUpdateBehavior(UpdateBehavior updateBehavior) {
switch (updateBehavior) {
case WRITE_ALWAYS:
return WRITE_ALWAYS_TAG;
case WRITE_IF_NOT_EXISTS:
return WRITE_IF_NOT_EXISTS_TAG;
default:
throw new IllegalArgumentException("Update behavior '" + updateBehavior + "' not supported");
}
}

public static UpdateBehavior resolveForAttribute(String attributeName, TableMetadata tableMetadata) {
String metadataKey = CUSTOM_METADATA_KEY_PREFIX + attributeName;
return tableMetadata.customMetadataObject(metadataKey, UpdateBehavior.class).orElse(DEFAULT_UPDATE_BEHAVIOR);
}

@Override
public Consumer<StaticTableMetadata.Builder> modifyMetadata(String attributeName,
AttributeValueType attributeValueType) {
return metadata ->
metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY_PREFIX + attributeName, this.updateBehavior);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
import software.amazon.awssdk.enhanced.dynamodb.extensions.WriteModification;
import software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils;
import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext;
import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.UpdateBehaviorTag;
import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior;
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
Expand All @@ -56,6 +58,9 @@ public class UpdateItemOperation<T>
private static final Function<String, String> EXPRESSION_KEY_MAPPER =
key -> "#AMZN_MAPPED_" + EnhancedClientUtils.cleanAttributeName(key);

private static final Function<String, String> CONDITIONAL_UPDATE_MAPPER =
key -> "if_not_exists(" + key + ", " + EXPRESSION_VALUE_KEY_MAPPER.apply(key) + ")";

private final UpdateItemEnhancedRequest<T> request;

private UpdateItemOperation(UpdateItemEnhancedRequest<T> request) {
Expand Down Expand Up @@ -104,7 +109,7 @@ public UpdateItemRequest generateRequest(TableSchema<T> tableSchema,
.filter(entry -> !primaryKeys.contains(entry.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

requestBuilder = addExpressionsIfExist(transformation, filteredAttributeValues, requestBuilder);
requestBuilder = addExpressionsIfExist(transformation, filteredAttributeValues, requestBuilder, tableMetadata);

return requestBuilder.build();
}
Expand Down Expand Up @@ -157,14 +162,17 @@ public TransactWriteItem generateTransactWriteItem(TableSchema<T> tableSchema, O
.build();
}

private static Expression generateUpdateExpression(Map<String, AttributeValue> attributeValuesToUpdate) {
private static Expression generateUpdateExpression(Map<String, AttributeValue> attributeValuesToUpdate,
TableMetadata tableMetadata) {
// Sort the updates into 'SET' or 'REMOVE' based on null value
List<String> updateSetActions = new ArrayList<>();
List<String> updateRemoveActions = new ArrayList<>();

attributeValuesToUpdate.forEach((key, value) -> {
if (!isNullAttributeValue(value)) {
updateSetActions.add(EXPRESSION_KEY_MAPPER.apply(key) + " = " + EXPRESSION_VALUE_KEY_MAPPER.apply(key));
UpdateBehavior updateBehavior = UpdateBehaviorTag.resolveForAttribute(key, tableMetadata);
updateSetActions.add(EXPRESSION_KEY_MAPPER.apply(key) + " = " +
updateExpressionMapperForBehavior(updateBehavior).apply(key));
} else {
updateRemoveActions.add(EXPRESSION_KEY_MAPPER.apply(key));
}
Expand Down Expand Up @@ -203,16 +211,28 @@ private static Expression generateUpdateExpression(Map<String, AttributeValue> a
.build();
}

private static Function<String, String> updateExpressionMapperForBehavior(UpdateBehavior updateBehavior) {
switch (updateBehavior) {
case WRITE_ALWAYS:
return EXPRESSION_VALUE_KEY_MAPPER;
case WRITE_IF_NOT_EXISTS:
return CONDITIONAL_UPDATE_MAPPER;
default:
throw new IllegalArgumentException("Unsupported update behavior '" + updateBehavior + "'");
}
}

private UpdateItemRequest.Builder addExpressionsIfExist(WriteModification transformation,
Map<String, AttributeValue> filteredAttributeValues,
UpdateItemRequest.Builder requestBuilder) {
UpdateItemRequest.Builder requestBuilder,
TableMetadata tableMetadata) {
Map<String, String> expressionNames = null;
Map<String, AttributeValue> expressionValues = null;
String conditionExpressionString = null;

/* Add update expression for transformed non-key attributes if applicable */
if (!filteredAttributeValues.isEmpty()) {
Expression fullUpdateExpression = generateUpdateExpression(filteredAttributeValues);
Expression fullUpdateExpression = generateUpdateExpression(filteredAttributeValues, tableMetadata);
expressionNames = fullUpdateExpression.expressionNames();
expressionValues = fullUpdateExpression.expressionValues();
requestBuilder = requestBuilder.updateExpression(fullUpdateExpression.expression());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import software.amazon.awssdk.annotations.SdkPublicApi;
import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType;
import software.amazon.awssdk.enhanced.dynamodb.TableMetadata;
import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.UpdateBehaviorTag;

/**
* Common implementations of {@link StaticAttributeTag}. These tags can be used to mark your attributes as primary or
Expand Down Expand Up @@ -106,6 +107,16 @@ public static StaticAttributeTag secondarySortKey(Collection<String> indexNames)
attribute.getAttributeValueType())));
}

/**
* Specifies the behavior when this attribute is updated as part of an 'update' operation such as UpdateItem. See
* documentation of {@link UpdateBehavior} for details on the different behaviors supported and the default
* behavior.
* @param updateBehavior The {@link UpdateBehavior} to be applied to this attribute
*/
public static StaticAttributeTag updateBehavior(UpdateBehavior updateBehavior) {
return UpdateBehaviorTag.fromUpdateBehavior(updateBehavior);
}

private static class KeyAttributeTag implements StaticAttributeTag {
private final BiConsumer<StaticTableMetadata.Builder, AttributeAndType> tableMetadataKeySetter;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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;

/**
* Update behaviors that can be applied to individual attributes. This behavior will only apply to 'update' operations
* such as UpdateItem, and not 'put' operations such as PutItem.
* <p>
* If an update behavior is not specified for an attribute, the default behavior of {@link #WRITE_ALWAYS} will be
* applied.
*/
@SdkPublicApi
public enum UpdateBehavior {
/**
* Always overwrite with the new value if one is provided, or remove any existing value if a null value is
* provided and 'ignoreNulls' is set to false.
* <p>
* This is the default behavior applied to all attributes unless otherwise specified.
*/
WRITE_ALWAYS,

/**
* Write the new value if there is no existing value in the persisted record or a new record is being written,
* otherwise leave the existing value.
* <p>
* IMPORTANT: If a null value is provided and 'ignoreNulls' is set to false, the attribute
* will always be removed from the persisted record as DynamoDb does not support conditional removal with this
* method.
*/
WRITE_IF_NOT_EXISTS
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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;
import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.BeanTableSchemaAttributeTags;
import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior;

/**
* Specifies the behavior when this attribute is updated as part of an 'update' operation such as UpdateItem. See
* documentation of {@link UpdateBehavior} for details on the different behaviors supported and the default behavior.
*/
@SdkPublicApi
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@BeanTableSchemaAttributeTag(BeanTableSchemaAttributeTags.class)
public @interface DynamoDbUpdateBehavior {
UpdateBehavior value();
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbAsyncTable;
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
import software.amazon.awssdk.enhanced.dynamodb.Expression;
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;

/**
* Defines parameters used to update an item to a DynamoDb table using the updateItem() operation (such as
Expand Down Expand Up @@ -123,9 +121,10 @@ private Builder() {
/**
* Sets if the update operation should ignore attributes with null values. By default, the value is false.
* <p>
* If set to true, any null values in the Java object will not be written to the table.
* If set to false, null values in the Java object will be written to the table (as an {@link AttributeValue} of type
* 'nul' in the output map, see {@link TableSchema#itemToMap(Object, boolean)}).
* If set to true, any null values in the Java object will be ignored and not be updated on the persisted
* record. This is commonly referred to as a 'partial update'.
* If set to false, null values in the Java object will cause those attributes to be removed from the persisted
* record on update.
* @param ignoreNulls the boolean value
* @return a builder of this type
*/
Expand Down
Loading