From 5206b59a15847e573a521c873efe61b6efe5f0ab Mon Sep 17 00:00:00 2001 From: ramariei Date: Tue, 3 Jun 2025 12:12:33 +0300 Subject: [PATCH] added support for timeToLive attributes + tests --- ...-AmazonDynamoDBEnhancedClient-c48b7b3.json | 6 + .../enhanced/dynamodb/DynamoDbAsyncTable.java | 10 + .../enhanced/dynamodb/DynamoDbTable.java | 10 + .../extensions/TimeToLiveExtension.java | 186 +++++++++ .../DynamoDbTimeToLiveAttribute.java | 68 +++ .../client/DefaultDynamoDbAsyncTable.java | 22 + .../internal/client/DefaultDynamoDbTable.java | 22 + .../internal/client/ExtensionResolver.java | 6 +- .../extensions/TimeToLiveAttributeTags.java | 34 ++ .../DescribeTimeToLiveOperation.java | 73 ++++ .../internal/operations/OperationName.java | 4 +- .../operations/UpdateTimeToLiveOperation.java | 92 ++++ .../dynamodb/mapper/BeanTableSchema.java | 14 + .../DescribeTimeToLiveEnhancedResponse.java | 94 +++++ .../UpdateTimeToLiveEnhancedResponse.java | 94 +++++ .../extensions/TimeToLiveExtensionTest.java | 394 ++++++++++++++++++ .../functionaltests/TimeToLiveRecordTest.java | 92 ++++ .../models/RecordWithDefaultTTL.java | 61 +++ .../models/RecordWithSimpleTTL.java | 53 +++ .../functionaltests/models/RecordWithTTL.java | 62 +++ .../DescribeTimeToLiveOperationTest.java | 62 +++ .../UpdateTimeToLiveOperationTest.java | 87 ++++ 22 files changed, 1543 insertions(+), 3 deletions(-) create mode 100644 .changes/next-release/feature-AmazonDynamoDBEnhancedClient-c48b7b3.json create mode 100644 services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/TimeToLiveExtension.java create mode 100644 services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbTimeToLiveAttribute.java create mode 100644 services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/TimeToLiveAttributeTags.java create mode 100644 services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DescribeTimeToLiveOperation.java create mode 100644 services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateTimeToLiveOperation.java create mode 100644 services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DescribeTimeToLiveEnhancedResponse.java create mode 100644 services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateTimeToLiveEnhancedResponse.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/TimeToLiveExtensionTest.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/TimeToLiveRecordTest.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithDefaultTTL.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithSimpleTTL.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithTTL.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DescribeTimeToLiveOperationTest.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateTimeToLiveOperationTest.java diff --git a/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-c48b7b3.json b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-c48b7b3.json new file mode 100644 index 000000000000..be3770312eb5 --- /dev/null +++ b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-c48b7b3.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Amazon DynamoDB Enhanced Client", + "contributor": "", + "description": "Added support for TimeToLive attributes in DynamoDB Enhanced Client" +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbAsyncTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbAsyncTable.java index e193fe681df8..34113bcb6e2f 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbAsyncTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbAsyncTable.java @@ -23,6 +23,7 @@ import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTableEnhancedResponse; +import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTimeToLiveEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.Page; @@ -34,6 +35,7 @@ import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse; +import software.amazon.awssdk.enhanced.dynamodb.model.UpdateTimeToLiveEnhancedResponse; import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; import software.amazon.awssdk.services.dynamodb.model.ConsumedCapacity; import software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest; @@ -947,4 +949,12 @@ default CompletableFuture deleteTable() { default CompletableFuture describeTable() { throw new UnsupportedOperationException(); } + + default CompletableFuture describeTimeToLive() { + throw new UnsupportedOperationException(); + } + + default CompletableFuture updateTimeToLive(boolean enabled) { + throw new UnsupportedOperationException(); + } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbTable.java index 6e94e6726c2f..b9d4b2f5680b 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DynamoDbTable.java @@ -23,6 +23,7 @@ import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTableEnhancedResponse; +import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTimeToLiveEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.Page; @@ -34,6 +35,7 @@ import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse; +import software.amazon.awssdk.enhanced.dynamodb.model.UpdateTimeToLiveEnhancedResponse; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.ConsumedCapacity; import software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest; @@ -926,4 +928,12 @@ default void deleteTable() { default DescribeTableEnhancedResponse describeTable() { throw new UnsupportedOperationException(); } + + default DescribeTimeToLiveEnhancedResponse describeTimeToLive() { + throw new UnsupportedOperationException(); + } + + default UpdateTimeToLiveEnhancedResponse updateTimeToLive(boolean enabled) { + throw new UnsupportedOperationException(); + } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/TimeToLiveExtension.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/TimeToLiveExtension.java new file mode 100644 index 000000000000..9c1a61d1d7de --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/TimeToLiveExtension.java @@ -0,0 +1,186 @@ +/* + * 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.extensions; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.annotations.ThreadSafe; +import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.utils.StringUtils; +import software.amazon.awssdk.utils.Validate; + +@SdkPublicApi +@ThreadSafe +public final class TimeToLiveExtension implements DynamoDbEnhancedClientExtension { + + public static final String CUSTOM_METADATA_KEY = "TimeToLiveExtension:TimeToLiveAttribute"; + + private TimeToLiveExtension() { + } + + public static TimeToLiveExtension.Builder builder() { + return new TimeToLiveExtension.Builder(); + } + + /** + * @return an Instance of {@link TimeToLiveExtension} + */ + public static TimeToLiveExtension create() { + return new TimeToLiveExtension(); + } + + @Override + public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) { + Map customTTLMetadata = context.tableMetadata() + .customMetadataObject(CUSTOM_METADATA_KEY, Map.class).orElse(null); + + if (customTTLMetadata != null) { + String ttlAttributeName = (String) customTTLMetadata.get("attributeName"); + String baseFieldName = (String) customTTLMetadata.get("baseField"); + Long duration = (Long) customTTLMetadata.get("duration"); + TemporalUnit unit = (TemporalUnit) customTTLMetadata.get("unit"); + + Map itemToTransform = new HashMap<>(context.items()); + + if (!itemToTransform.containsKey(ttlAttributeName) && StringUtils.isNotBlank(baseFieldName) + && itemToTransform.containsKey(baseFieldName)) { + Object baseFieldValue = context.tableSchema().converterForAttribute(baseFieldName) + .transformTo(itemToTransform.get(baseFieldName)); + Long ttlEpochSeconds = computeTTLFromBase(baseFieldValue, duration, unit); + itemToTransform.put(ttlAttributeName, AttributeValue.builder().n(String.valueOf(ttlEpochSeconds)).build()); + + return WriteModification.builder().transformedItem(Collections.unmodifiableMap(itemToTransform)).build(); + } + } + + return WriteModification.builder().build(); + } + + private static Long computeTTLFromBase(Object baseValue, long duration, TemporalUnit unit) { + if (baseValue instanceof Instant) { + return ((Instant) baseValue).plus(duration, unit).getEpochSecond(); + } + if (baseValue instanceof LocalDate) { + return ((LocalDate) baseValue).atStartOfDay(ZoneOffset.UTC).plus(duration, unit).toEpochSecond(); + } + if (baseValue instanceof LocalDateTime) { + return ((LocalDateTime) baseValue).plus(duration, unit).toEpochSecond(ZoneOffset.UTC); + } + if (baseValue instanceof LocalTime) { + return LocalDate.now().atTime((LocalTime) baseValue).plus(duration, unit).toEpochSecond(ZoneOffset.UTC); + } + if (baseValue instanceof ZonedDateTime) { + return ((ZonedDateTime) baseValue).plus(duration, unit).toEpochSecond(); + } + if (baseValue instanceof Long) { + return (Long) baseValue + Duration.of(duration, unit).getSeconds(); + } + + throw new IllegalArgumentException("Unsupported base field type for TTL computation: " + baseValue.getClass().getName()); + } + + public static final class Builder { + private Builder() { + } + + public TimeToLiveExtension build() { + return new TimeToLiveExtension(); + } + } + + public static final class AttributeTags { + private AttributeTags() { + } + + /** + * Used to explicitly designate an attribute to determine the TTL on the table. + * + *

How this works

+ *
    + *
  • If a TTL attribute is set, it takes precedence over baseField.
  • + *
  • If no TTL attribute is set, it checks for baseField.
  • + *
  • If baseField is present, the TTL is calculated using its value, duration, and unit.
  • + *
  • The final TTL value is converted to epoch seconds before storing in DynamoDB.
  • + *
+ * + * @param baseField Optional attribute name used to determine the TTL value. + * @param duration Additional long value used for TTL calculation. + * @param unit {@link ChronoUnit} value specifying the TTL duration unit. + */ + public static StaticAttributeTag timeToLiveAttribute(String baseField, long duration, ChronoUnit unit) { + return new TimeToLiveAttribute(baseField, duration, unit); + } + } + + private static final class TimeToLiveAttribute implements StaticAttributeTag { + + public String baseField; + public long duration; + public ChronoUnit unit; + + private TimeToLiveAttribute(String baseField, long duration, ChronoUnit unit) { + this.baseField = baseField; + this.duration = duration; + this.unit = unit; + } + + @Override + public void validateType(String attributeName, EnhancedType type, + AttributeValueType attributeValueType) { + + Validate.notNull(type, "type is null"); + Validate.notNull(type.rawClass(), "rawClass is null"); + Validate.notNull(attributeValueType, "attributeValueType is null"); + + if (!type.rawClass().equals(Long.class)) { + throw new IllegalArgumentException(String.format( + "Attribute '%s' of type %s is not a suitable type to be used as a TTL attribute. Only type Long " + + "is supported.", attributeName, type.rawClass())); + } + } + + @Override + public Consumer modifyMetadata(String attributeName, + AttributeValueType attributeValueType) { + Map customMetadataMap = new HashMap<>(); + customMetadataMap.put("attributeName", attributeName); + customMetadataMap.put("baseField", baseField); + customMetadataMap.put("duration", duration); + customMetadataMap.put("unit", unit); + + return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, + Collections.unmodifiableMap(customMetadataMap)); + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbTimeToLiveAttribute.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbTimeToLiveAttribute.java new file mode 100644 index 000000000000..9026ed41bb63 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbTimeToLiveAttribute.java @@ -0,0 +1,68 @@ +/* + * 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.extensions.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.time.temporal.ChronoUnit; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.TimeToLiveAttributeTags; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.BeanTableSchemaAttributeTag; + +/** + * Annotation used to mark an attribute in a DynamoDB-enhanced client model as a Time-To-Live (TTL) field. + *

+ * This annotation allows automatic computation and assignment of a TTL value based on another field (the {@code baseField}) + * and a time offset defined by {@code duration} and {@code unit}. The TTL value is stored in epoch seconds and + * can be configured to expire items from the table automatically. + *

+ * To use this, the annotated method should return a {@link Long} value, which will be populated by the SDK at write time. + * The {@code baseField} can be a temporal type such as {@link java.time.Instant}, {@link java.time.LocalDate}, + * {@link java.time.LocalDateTime}, etc., or a {@link Long} representing epoch seconds directly, serving as the reference point + * for TTL calculation. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@BeanTableSchemaAttributeTag(TimeToLiveAttributeTags.class) +@SdkPublicApi +public @interface DynamoDbTimeToLiveAttribute { + + /** + * The name of the attribute whose value will serve as the base for TTL computation. + * This can be a temporal type (e.g., {@link java.time.Instant}, {@link java.time.LocalDateTime}) + * or a {@link Long} representing epoch seconds. + * + * @return the attribute name to use as the base timestamp for TTL + */ + String baseField() default ""; + + /** + * The amount of time to add to the {@code baseField} when computing the TTL value. + * The resulting time will be converted to epoch seconds. + * + * @return the time offset to apply to the base field + */ + long duration() default 0; + + /** + * The time unit associated with the {@code duration}. Defaults to {@link ChronoUnit#SECONDS}. + * + * @return the time unit to use with the duration + */ + ChronoUnit unit() default ChronoUnit.SECONDS; +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java index cd281dec3d24..0a98c62969e1 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbAsyncTable.java @@ -31,6 +31,7 @@ import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DeleteItemOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DeleteTableOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DescribeTableOperation; +import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DescribeTimeToLiveOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.GetItemOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.PaginatedTableOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.PutItemOperation; @@ -38,10 +39,12 @@ import software.amazon.awssdk.enhanced.dynamodb.internal.operations.ScanOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.TableOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation; +import software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateTimeToLiveOperation; import software.amazon.awssdk.enhanced.dynamodb.model.CreateTableEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTableEnhancedResponse; +import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTimeToLiveEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.PagePublisher; @@ -52,9 +55,14 @@ import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse; +import software.amazon.awssdk.enhanced.dynamodb.model.UpdateTimeToLiveEnhancedResponse; import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; import software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest; import software.amazon.awssdk.services.dynamodb.model.DescribeTableResponse; +import software.amazon.awssdk.services.dynamodb.model.DescribeTimeToLiveRequest; +import software.amazon.awssdk.services.dynamodb.model.DescribeTimeToLiveResponse; +import software.amazon.awssdk.services.dynamodb.model.UpdateTimeToLiveRequest; +import software.amazon.awssdk.services.dynamodb.model.UpdateTimeToLiveResponse; @SdkInternalApi public final class DefaultDynamoDbAsyncTable implements DynamoDbAsyncTable { @@ -326,6 +334,20 @@ public CompletableFuture describeTable() { return operation.executeOnPrimaryIndexAsync(tableSchema, tableName, extension, dynamoDbClient); } + @Override + public CompletableFuture describeTimeToLive() { + TableOperation operation = + DescribeTimeToLiveOperation.create(); + return operation.executeOnPrimaryIndexAsync(tableSchema, tableName, extension, dynamoDbClient); + } + + @Override + public CompletableFuture updateTimeToLive(boolean enabled) { + TableOperation operation = + UpdateTimeToLiveOperation.create(enabled); + return operation.executeOnPrimaryIndexAsync(tableSchema, tableName, extension, dynamoDbClient); + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java index 31ce811b3483..1b0e4b5f64f3 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/DefaultDynamoDbTable.java @@ -30,6 +30,7 @@ import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DeleteItemOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DeleteTableOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DescribeTableOperation; +import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DescribeTimeToLiveOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.GetItemOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.PaginatedTableOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.PutItemOperation; @@ -37,10 +38,12 @@ import software.amazon.awssdk.enhanced.dynamodb.internal.operations.ScanOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.TableOperation; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation; +import software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateTimeToLiveOperation; import software.amazon.awssdk.enhanced.dynamodb.model.CreateTableEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTableEnhancedResponse; +import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTimeToLiveEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.model.PageIterable; @@ -51,9 +54,14 @@ import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse; +import software.amazon.awssdk.enhanced.dynamodb.model.UpdateTimeToLiveEnhancedResponse; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest; import software.amazon.awssdk.services.dynamodb.model.DescribeTableResponse; +import software.amazon.awssdk.services.dynamodb.model.DescribeTimeToLiveRequest; +import software.amazon.awssdk.services.dynamodb.model.DescribeTimeToLiveResponse; +import software.amazon.awssdk.services.dynamodb.model.UpdateTimeToLiveRequest; +import software.amazon.awssdk.services.dynamodb.model.UpdateTimeToLiveResponse; @SdkInternalApi public class DefaultDynamoDbTable implements DynamoDbTable { @@ -318,6 +326,20 @@ public DescribeTableEnhancedResponse describeTable() { return operation.executeOnPrimaryIndex(tableSchema, tableName, extension, dynamoDbClient); } + @Override + public DescribeTimeToLiveEnhancedResponse describeTimeToLive() { + TableOperation operation = + DescribeTimeToLiveOperation.create(); + return operation.executeOnPrimaryIndex(tableSchema, tableName, extension, dynamoDbClient); + } + + @Override + public UpdateTimeToLiveEnhancedResponse updateTimeToLive(boolean enabled) { + TableOperation operation = + UpdateTimeToLiveOperation.create(enabled); + return operation.executeOnPrimaryIndex(tableSchema, tableName, extension, dynamoDbClient); + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/ExtensionResolver.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/ExtensionResolver.java index 5940da63a7e2..64ca44e68d9f 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/ExtensionResolver.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/client/ExtensionResolver.java @@ -20,6 +20,7 @@ import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; import software.amazon.awssdk.enhanced.dynamodb.extensions.AtomicCounterExtension; +import software.amazon.awssdk.enhanced.dynamodb.extensions.TimeToLiveExtension; import software.amazon.awssdk.enhanced.dynamodb.extensions.VersionedRecordExtension; import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.ChainExtension; @@ -33,9 +34,10 @@ public final class ExtensionResolver { VersionedRecordExtension.builder().build(); private static final DynamoDbEnhancedClientExtension DEFAULT_ATOMIC_COUNTER_EXTENSION = AtomicCounterExtension.builder().build(); - + private static final DynamoDbEnhancedClientExtension DEFAULT_TIME_TO_LIVE_EXTENSION = + TimeToLiveExtension.builder().build(); private static final List DEFAULT_EXTENSIONS = - Arrays.asList(DEFAULT_VERSIONED_RECORD_EXTENSION, DEFAULT_ATOMIC_COUNTER_EXTENSION); + Arrays.asList(DEFAULT_VERSIONED_RECORD_EXTENSION, DEFAULT_ATOMIC_COUNTER_EXTENSION, DEFAULT_TIME_TO_LIVE_EXTENSION); private ExtensionResolver() { } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/TimeToLiveAttributeTags.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/TimeToLiveAttributeTags.java new file mode 100644 index 000000000000..1e01ada5bc16 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/TimeToLiveAttributeTags.java @@ -0,0 +1,34 @@ +/* + * 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.extensions; + +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.enhanced.dynamodb.extensions.TimeToLiveExtension; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbTimeToLiveAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; + +@SdkInternalApi +public final class TimeToLiveAttributeTags { + + private TimeToLiveAttributeTags() { + + } + + public static StaticAttributeTag attributeTagFor(DynamoDbTimeToLiveAttribute annotation) { + return TimeToLiveExtension.AttributeTags.timeToLiveAttribute(annotation.baseField(), annotation.duration(), + annotation.unit()); + } +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DescribeTimeToLiveOperation.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DescribeTimeToLiveOperation.java new file mode 100644 index 000000000000..870bae420f8e --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DescribeTimeToLiveOperation.java @@ -0,0 +1,73 @@ +/* + * 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.operations; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; +import software.amazon.awssdk.enhanced.dynamodb.OperationContext; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTimeToLiveEnhancedResponse; +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.DescribeTimeToLiveRequest; +import software.amazon.awssdk.services.dynamodb.model.DescribeTimeToLiveResponse; + +@SdkInternalApi +public class DescribeTimeToLiveOperation implements TableOperation { + + public static DescribeTimeToLiveOperation create() { + return new DescribeTimeToLiveOperation<>(); + } + + @Override + public OperationName operationName() { + return OperationName.DESCRIBE_TIME_TO_LIVE; + } + + @Override + public DescribeTimeToLiveRequest generateRequest(TableSchema tableSchema, + OperationContext operationContext, + DynamoDbEnhancedClientExtension extension) { + return DescribeTimeToLiveRequest.builder() + .tableName(operationContext.tableName()) + .build(); + } + + @Override + public Function serviceCall(DynamoDbClient dynamoDbClient) { + return dynamoDbClient::describeTimeToLive; + } + + @Override + public Function> asyncServiceCall( + DynamoDbAsyncClient dynamoDbAsyncClient) { + + return dynamoDbAsyncClient::describeTimeToLive; + } + + @Override + public DescribeTimeToLiveEnhancedResponse transformResponse(DescribeTimeToLiveResponse response, + TableSchema tableSchema, + OperationContext operationContext, + DynamoDbEnhancedClientExtension extension) { + return DescribeTimeToLiveEnhancedResponse.builder() + .response(response) + .build(); + } +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/OperationName.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/OperationName.java index cc3fec48e084..af38045f93da 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/OperationName.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/OperationName.java @@ -32,7 +32,9 @@ public enum OperationName { SCAN("Scan"), TRANSACT_GET_ITEMS("TransactGetItems"), TRANSACT_WRITE_ITEMS("TransactWriteItems"), - UPDATE_ITEM("UpdateItem"); + UPDATE_ITEM("UpdateItem"), + DESCRIBE_TIME_TO_LIVE("DescribeTimeToLive"), + UPDATE_TIME_TO_LIVE("UpdateTimeToLive"); private final String label; diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateTimeToLiveOperation.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateTimeToLiveOperation.java new file mode 100644 index 000000000000..bf3508401fb1 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateTimeToLiveOperation.java @@ -0,0 +1,92 @@ +/* + * 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.operations; + +import static software.amazon.awssdk.enhanced.dynamodb.extensions.TimeToLiveExtension.CUSTOM_METADATA_KEY; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; +import software.amazon.awssdk.enhanced.dynamodb.OperationContext; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.UpdateTimeToLiveEnhancedResponse; +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.TimeToLiveSpecification; +import software.amazon.awssdk.services.dynamodb.model.UpdateTimeToLiveRequest; +import software.amazon.awssdk.services.dynamodb.model.UpdateTimeToLiveResponse; + +@SdkInternalApi +public class UpdateTimeToLiveOperation implements TableOperation { + + private final boolean enabled; + + public UpdateTimeToLiveOperation(boolean enabled) { + this.enabled = enabled; + } + + public static UpdateTimeToLiveOperation create(boolean enabled) { + return new UpdateTimeToLiveOperation<>(enabled); + } + + @Override + public OperationName operationName() { + return OperationName.UPDATE_TIME_TO_LIVE; + } + + @Override + public UpdateTimeToLiveRequest generateRequest(TableSchema tableSchema, + OperationContext operationContext, + DynamoDbEnhancedClientExtension extension) { + Map customTTLMetadata = tableSchema.tableMetadata() + .customMetadataObject(CUSTOM_METADATA_KEY, Map.class).orElse(null); + if (customTTLMetadata == null) { + throw new IllegalArgumentException("Custom TTL metadata object is null"); + } + String ttlAttributeName = (String) customTTLMetadata.get("attributeName"); + + return UpdateTimeToLiveRequest.builder() + .tableName(operationContext.tableName()) + .timeToLiveSpecification(TimeToLiveSpecification.builder() + .attributeName(ttlAttributeName) + .enabled(enabled).build()) + .build(); + } + + @Override + public Function serviceCall(DynamoDbClient dynamoDbClient) { + return dynamoDbClient::updateTimeToLive; + } + + @Override + public Function> asyncServiceCall( + DynamoDbAsyncClient dynamoDbAsyncClient) { + return dynamoDbAsyncClient::updateTimeToLive; + } + + @Override + public UpdateTimeToLiveEnhancedResponse transformResponse(UpdateTimeToLiveResponse response, + TableSchema tableSchema, + OperationContext operationContext, + DynamoDbEnhancedClientExtension extension) { + return UpdateTimeToLiveEnhancedResponse.builder() + .response(response) + .build(); + } +} 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..4717e2d5e249 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 @@ -37,6 +37,7 @@ import java.util.Map; import java.util.Optional; import java.util.WeakHashMap; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Function; @@ -52,6 +53,7 @@ import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; import software.amazon.awssdk.enhanced.dynamodb.EnhancedTypeDocumentConfiguration; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbTimeToLiveAttribute; import software.amazon.awssdk.enhanced.dynamodb.internal.AttributeConfiguration; import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.BeanAttributeGetter; import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.BeanAttributeSetter; @@ -230,6 +232,7 @@ private static StaticTableSchema createStaticTableSchema(Class beanCla builder.attributeConverterProviders(createConverterProvidersFromAnnotation(beanClass, lookup, dynamoDbBean)); List> attributes = new ArrayList<>(); + AtomicInteger ttlAttributesCount = new AtomicInteger(); Arrays.stream(beanInfo.getPropertyDescriptors()) .filter(p -> isMappableProperty(beanClass, p)) @@ -255,10 +258,21 @@ private static StaticTableSchema createStaticTableSchema(Class beanCla addTagsToAttribute(attributeBuilder, propertyDescriptor); attributes.add(attributeBuilder.build()); } + + DynamoDbTimeToLiveAttribute ttlAnnotation = getPropertyAnnotation(propertyDescriptor, + DynamoDbTimeToLiveAttribute.class); + if (ttlAnnotation != null) { + ttlAttributesCount.getAndIncrement(); + } }); builder.attributes(attributes); + if (ttlAttributesCount.intValue() > 1) { + throw new IllegalArgumentException( + "A @DynamoDbBean class could have maximum one @DynamoDbTimeToLiveAttribute."); + } + return builder.build(); } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DescribeTimeToLiveEnhancedResponse.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DescribeTimeToLiveEnhancedResponse.java new file mode 100644 index 000000000000..be6eb4877cdd --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/DescribeTimeToLiveEnhancedResponse.java @@ -0,0 +1,94 @@ +/* + * 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.model; + +import java.util.Objects; +import software.amazon.awssdk.annotations.NotThreadSafe; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.annotations.ThreadSafe; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbAsyncTable; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.services.dynamodb.model.DescribeTimeToLiveResponse; +import software.amazon.awssdk.services.dynamodb.model.TimeToLiveDescription; +import software.amazon.awssdk.utils.ToString; +import software.amazon.awssdk.utils.Validate; + +/** + * Defines the elements returned by DynamoDB from a {@code DescribeTimeToLive} operation, such as + * {@link DynamoDbTable#describeTimeToLive()} and {@link DynamoDbAsyncTable#describeTimeToLive()} + */ +@SdkPublicApi +@ThreadSafe +public final class DescribeTimeToLiveEnhancedResponse { + private final DescribeTimeToLiveResponse response; + + private DescribeTimeToLiveEnhancedResponse(Builder builder) { + this.response = Validate.paramNotNull(builder.response, "response"); + } + + /** + * The properties of the timeToLive configuration of the table. + * + * @return The properties of the timeToLive configuration. + */ + public TimeToLiveDescription timeToLiveDescription() { + return response.timeToLiveDescription(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + DescribeTimeToLiveEnhancedResponse that = (DescribeTimeToLiveEnhancedResponse) o; + + return Objects.equals(response, that.response); + } + + @Override + public int hashCode() { + return response != null ? response.hashCode() : 0; + } + + @Override + public String toString() { + return ToString.builder("DescribeTimeToLiveEnhancedResponse") + .add("timeToLiveDescription", response.timeToLiveDescription()) + .build(); + } + + public static Builder builder() { + return new Builder(); + } + + @NotThreadSafe + public static final class Builder { + private DescribeTimeToLiveResponse response; + + public Builder response(DescribeTimeToLiveResponse response) { + this.response = response; + return this; + } + + public DescribeTimeToLiveEnhancedResponse build() { + return new DescribeTimeToLiveEnhancedResponse(this); + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateTimeToLiveEnhancedResponse.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateTimeToLiveEnhancedResponse.java new file mode 100644 index 000000000000..c77f97e37def --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateTimeToLiveEnhancedResponse.java @@ -0,0 +1,94 @@ +/* + * 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.model; + +import java.util.Objects; +import software.amazon.awssdk.annotations.NotThreadSafe; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.annotations.ThreadSafe; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbAsyncTable; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.services.dynamodb.model.TimeToLiveSpecification; +import software.amazon.awssdk.services.dynamodb.model.UpdateTimeToLiveResponse; +import software.amazon.awssdk.utils.ToString; +import software.amazon.awssdk.utils.Validate; + +/** + * Defines the elements returned by DynamoDB from a {@code DescribeTimeToLive} operation, such as + * {@link DynamoDbTable#updateTimeToLive(boolean)} and {@link DynamoDbAsyncTable#updateTimeToLive(boolean)} + */ +@SdkPublicApi +@ThreadSafe +public final class UpdateTimeToLiveEnhancedResponse { + private final UpdateTimeToLiveResponse response; + + private UpdateTimeToLiveEnhancedResponse(Builder builder) { + this.response = Validate.paramNotNull(builder.response, "response"); + } + + /** + * The properties of the timeToLive specification of the table. + * + * @return The properties of the timeToLive specification. + */ + public TimeToLiveSpecification table() { + return response.timeToLiveSpecification(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + UpdateTimeToLiveEnhancedResponse that = (UpdateTimeToLiveEnhancedResponse) o; + + return Objects.equals(response, that.response); + } + + @Override + public int hashCode() { + return response != null ? response.hashCode() : 0; + } + + @Override + public String toString() { + return ToString.builder("UpdateTimeToLiveEnhancedResponse") + .add("timeToLiveSpecification", response.timeToLiveSpecification()) + .build(); + } + + public static Builder builder() { + return new Builder(); + } + + @NotThreadSafe + public static final class Builder { + private UpdateTimeToLiveResponse response; + + public Builder response(UpdateTimeToLiveResponse response) { + this.response = response; + return this; + } + + public UpdateTimeToLiveEnhancedResponse build() { + return new UpdateTimeToLiveEnhancedResponse(this); + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/TimeToLiveExtensionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/TimeToLiveExtensionTest.java new file mode 100644 index 000000000000..3070e120e915 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/TimeToLiveExtensionTest.java @@ -0,0 +1,394 @@ +package software.amazon.awssdk.enhanced.dynamodb.extensions; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext; +import software.amazon.awssdk.enhanced.dynamodb.OperationContext; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordWithDefaultTTL; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordWithSimpleTTL; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordWithTTL; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.InstantAsStringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.LocalDateAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.LocalDateTimeAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.LocalTimeAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.LongAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.StringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ZonedDateTimeAsStringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext; +import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DefaultOperationContext; +import software.amazon.awssdk.enhanced.dynamodb.internal.operations.OperationName; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +public class TimeToLiveExtensionTest { + + private static final String TABLE_NAME = "table-name"; + private static final OperationContext PRIMARY_CONTEXT = + DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + private static final TableSchema TABLE_SCHEMA = + TableSchema.fromClass(RecordWithTTL.class); + + private final TimeToLiveExtension timeToLiveExtension = TimeToLiveExtension.create(); + + @Test + public void beforeWrite_addsTtlAttributeIfNotPresent() { + String ttlAttrName = "expirationDate"; + String baseFieldName = "updatedDate"; + long duration = 30L; + ChronoUnit unit = ChronoUnit.DAYS; + + Instant baseTime = Instant.now(); + long expectedTtl = baseTime.plus(duration, unit).getEpochSecond(); + + Map items = new HashMap<>(); + items.put(baseFieldName, InstantAsStringAttributeConverter.create().transformFrom(baseTime)); + + WriteModification result = + timeToLiveExtension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableSchema(TABLE_SCHEMA) + .tableMetadata(TABLE_SCHEMA.tableMetadata()) + .operationName(OperationName.UPDATE_ITEM) + .operationContext(PRIMARY_CONTEXT).build()); + + Map transformed = result.transformedItem(); + assertNotNull(transformed); + assertTrue(transformed.containsKey(ttlAttrName)); + + long actualTtl = Long.parseLong(transformed.get(ttlAttrName).n()); + assertEquals(expectedTtl, actualTtl); + } + + @Test + public void beforeWrite_skipsIfTtlAlreadyPresent() { + String ttlAttrName = "expirationDate"; + + Map items = new HashMap<>(); + items.put(ttlAttrName, AttributeValue.fromN("12345")); // already present + + WriteModification result = + timeToLiveExtension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableSchema(TABLE_SCHEMA) + .tableMetadata(TABLE_SCHEMA.tableMetadata()) + .operationName(OperationName.UPDATE_ITEM) + .operationContext(PRIMARY_CONTEXT).build()); + + // TTL was already present, nothing modified + assertNull(result.transformedItem()); + } + + @Test + public void beforeWrite_skipsIfTtlNotPresentAndBaseFieldEmpty() { + Map items = new HashMap<>(); + items.put("attribute", AttributeValue.fromN("attributeValue")); + + TableSchema simpleTTLTableSchema = TableSchema.fromClass(RecordWithSimpleTTL.class); + + WriteModification result = + timeToLiveExtension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableSchema(simpleTTLTableSchema) + .tableMetadata(simpleTTLTableSchema.tableMetadata()) + .operationName(OperationName.UPDATE_ITEM) + .operationContext(PRIMARY_CONTEXT).build()); + + // TTL not present, but no baseField to compute the TTL value + assertNull(result.transformedItem()); + } + + @Test + public void beforeWrite_addsTtlAttributeWithDefaults() { + String ttlAttrName = "expirationDate"; + String baseFieldName = "updatedDate"; + + Map items = new HashMap<>(); + items.put("id", AttributeValue.fromN("id123")); + items.put("attribute", AttributeValue.fromN("attributeValue")); + items.put(baseFieldName, InstantAsStringAttributeConverter.create().transformFrom(Instant.now())); + + TableSchema defaultTTLTableSchema = TableSchema.fromClass(RecordWithDefaultTTL.class); + + WriteModification result = + timeToLiveExtension.beforeWrite(DefaultDynamoDbExtensionContext.builder() + .items(items) + .tableSchema(defaultTTLTableSchema) + .tableMetadata(defaultTTLTableSchema.tableMetadata()) + .operationName(OperationName.UPDATE_ITEM) + .operationContext(PRIMARY_CONTEXT).build()); + + Map transformed = result.transformedItem(); + assertNotNull(transformed); + assertTrue(transformed.containsKey(ttlAttrName)); + + // TTL equals baseField, since duration and unit are not specified (default 0 and SECONDS) + long updatedDate = Instant.parse(transformed.get(baseFieldName).s()).getEpochSecond(); + long actualTtl = Long.parseLong(transformed.get(ttlAttrName).n()); + assertEquals(updatedDate, actualTtl); + } + + @Test + public void beforeWrite_computesTtlFromLocalDate() { + String ttlAttrName = "ttl"; + String baseFieldName = "createdAt"; + long duration = 1L; + ChronoUnit unit = ChronoUnit.DAYS; + + LocalDate baseTime = LocalDate.of(2024, 1, 1); + long expectedTtl = baseTime.atStartOfDay(ZoneOffset.UTC).plus(duration, unit).toEpochSecond(); + + Map item = new HashMap<>(); + item.put(baseFieldName, LocalDateAttributeConverter.create().transformFrom(baseTime)); + + Map customMetadata = new HashMap<>(); + customMetadata.put("attributeName", ttlAttrName); + customMetadata.put("baseField", baseFieldName); + customMetadata.put("duration", duration); + customMetadata.put("unit", unit); + + TableMetadata tableMetadata = mock(TableMetadata.class); + when(tableMetadata.customMetadataObject(TimeToLiveExtension.CUSTOM_METADATA_KEY, Map.class)) + .thenReturn(Optional.of(customMetadata)); + + TableSchema schema = mock(TableSchema.class); + when(schema.converterForAttribute(baseFieldName)).thenReturn(LocalDateAttributeConverter.create()); + + DynamoDbExtensionContext.BeforeWrite context = mock(DynamoDbExtensionContext.BeforeWrite.class); + when(context.items()).thenReturn(item); + when(context.tableMetadata()).thenReturn(tableMetadata); + when(context.tableSchema()).thenReturn(schema); + + TimeToLiveExtension extension = TimeToLiveExtension.create(); + WriteModification result = extension.beforeWrite(context); + + Map transformed = result.transformedItem(); + assertNotNull(transformed); + assertTrue(transformed.containsKey(ttlAttrName)); + + long actualTtl = Long.parseLong(transformed.get(ttlAttrName).n()); + assertEquals(expectedTtl, actualTtl); + } + + @Test + public void beforeWrite_computesTtlFromLocalDateTime() { + String ttlAttrName = "ttl"; + String baseFieldName = "createdAt"; + long duration = 2L; + ChronoUnit unit = ChronoUnit.HOURS; + + LocalDateTime baseTime = LocalDateTime.of(2024, 1, 1, 10, 0); + long expectedTtl = baseTime.plusHours(2).toEpochSecond(ZoneOffset.UTC); + + Map item = new HashMap<>(); + item.put(baseFieldName, LocalDateTimeAttributeConverter.create().transformFrom(baseTime)); + + Map customMetadata = new HashMap<>(); + customMetadata.put("attributeName", ttlAttrName); + customMetadata.put("baseField", baseFieldName); + customMetadata.put("duration", duration); + customMetadata.put("unit", unit); + + TableMetadata tableMetadata = mock(TableMetadata.class); + when(tableMetadata.customMetadataObject(TimeToLiveExtension.CUSTOM_METADATA_KEY, Map.class)) + .thenReturn(Optional.of(customMetadata)); + + TableSchema schema = mock(TableSchema.class); + when(schema.converterForAttribute(baseFieldName)).thenReturn(LocalDateTimeAttributeConverter.create()); + + DynamoDbExtensionContext.BeforeWrite context = mock(DynamoDbExtensionContext.BeforeWrite.class); + when(context.items()).thenReturn(item); + when(context.tableMetadata()).thenReturn(tableMetadata); + when(context.tableSchema()).thenReturn(schema); + + TimeToLiveExtension extension = TimeToLiveExtension.create(); + WriteModification result = extension.beforeWrite(context); + + Map transformed = result.transformedItem(); + assertNotNull(transformed); + assertTrue(transformed.containsKey(ttlAttrName)); + + long actualTtl = Long.parseLong(transformed.get(ttlAttrName).n()); + assertEquals(expectedTtl, actualTtl); + } + + @Test + public void beforeWrite_computesTtlFromLocalTime() { + String ttlAttrName = "ttl"; + String baseFieldName = "createdAt"; + long duration = 30L; + ChronoUnit unit = ChronoUnit.MINUTES; + + LocalTime baseTime = LocalTime.of(10, 0); + long expectedTtl = LocalDate.now().atTime(baseTime).plusMinutes(30).toEpochSecond(ZoneOffset.UTC); + + Map item = new HashMap<>(); + item.put(baseFieldName, LocalTimeAttributeConverter.create().transformFrom(baseTime)); + + Map customMetadata = new HashMap<>(); + customMetadata.put("attributeName", ttlAttrName); + customMetadata.put("baseField", baseFieldName); + customMetadata.put("duration", duration); + customMetadata.put("unit", unit); + + TableMetadata tableMetadata = mock(TableMetadata.class); + when(tableMetadata.customMetadataObject(TimeToLiveExtension.CUSTOM_METADATA_KEY, Map.class)) + .thenReturn(Optional.of(customMetadata)); + + TableSchema schema = mock(TableSchema.class); + when(schema.converterForAttribute(baseFieldName)).thenReturn(LocalTimeAttributeConverter.create()); + + DynamoDbExtensionContext.BeforeWrite context = mock(DynamoDbExtensionContext.BeforeWrite.class); + when(context.items()).thenReturn(item); + when(context.tableMetadata()).thenReturn(tableMetadata); + when(context.tableSchema()).thenReturn(schema); + + TimeToLiveExtension extension = TimeToLiveExtension.create(); + WriteModification result = extension.beforeWrite(context); + + Map transformed = result.transformedItem(); + assertNotNull(transformed); + assertTrue(transformed.containsKey(ttlAttrName)); + + long actualTtl = Long.parseLong(transformed.get(ttlAttrName).n()); + assertEquals(expectedTtl, actualTtl); + } + + @Test + public void beforeWrite_computesTtlFromZonedDateTime() { + String ttlAttrName = "ttl"; + String baseFieldName = "createdAt"; + long duration = 15L; + ChronoUnit unit = ChronoUnit.MINUTES; + + ZonedDateTime baseTime = ZonedDateTime.now(ZoneOffset.UTC); + long expectedTtl = baseTime.plusMinutes(15).toEpochSecond(); + + Map item = new HashMap<>(); + item.put(baseFieldName, ZonedDateTimeAsStringAttributeConverter.create().transformFrom(baseTime)); + + Map customMetadata = new HashMap<>(); + customMetadata.put("attributeName", ttlAttrName); + customMetadata.put("baseField", baseFieldName); + customMetadata.put("duration", duration); + customMetadata.put("unit", unit); + + TableMetadata tableMetadata = mock(TableMetadata.class); + when(tableMetadata.customMetadataObject(TimeToLiveExtension.CUSTOM_METADATA_KEY, Map.class)) + .thenReturn(Optional.of(customMetadata)); + + TableSchema schema = mock(TableSchema.class); + when(schema.converterForAttribute(baseFieldName)).thenReturn(ZonedDateTimeAsStringAttributeConverter.create()); + + DynamoDbExtensionContext.BeforeWrite context = mock(DynamoDbExtensionContext.BeforeWrite.class); + when(context.items()).thenReturn(item); + when(context.tableMetadata()).thenReturn(tableMetadata); + when(context.tableSchema()).thenReturn(schema); + + TimeToLiveExtension extension = TimeToLiveExtension.create(); + WriteModification result = extension.beforeWrite(context); + + Map transformed = result.transformedItem(); + assertNotNull(transformed); + assertTrue(transformed.containsKey(ttlAttrName)); + + long actualTtl = Long.parseLong(transformed.get(ttlAttrName).n()); + assertEquals(expectedTtl, actualTtl); + } + + @Test + public void beforeWrite_computesTtlFromLong() { + String ttlAttrName = "ttl"; + String baseFieldName = "createdAt"; + long duration = 120L; + ChronoUnit unit = ChronoUnit.SECONDS; + + Long baseTime = Instant.now().getEpochSecond(); + long expectedTtl = baseTime + 120; + + Map item = new HashMap<>(); + item.put(baseFieldName, LongAttributeConverter.create().transformFrom(baseTime)); + + Map customMetadata = new HashMap<>(); + customMetadata.put("attributeName", ttlAttrName); + customMetadata.put("baseField", baseFieldName); + customMetadata.put("duration", duration); + customMetadata.put("unit", unit); + + TableMetadata tableMetadata = mock(TableMetadata.class); + when(tableMetadata.customMetadataObject(TimeToLiveExtension.CUSTOM_METADATA_KEY, Map.class)) + .thenReturn(Optional.of(customMetadata)); + + TableSchema schema = mock(TableSchema.class); + when(schema.converterForAttribute(baseFieldName)).thenReturn(LongAttributeConverter.create()); + + DynamoDbExtensionContext.BeforeWrite context = mock(DynamoDbExtensionContext.BeforeWrite.class); + when(context.items()).thenReturn(item); + when(context.tableMetadata()).thenReturn(tableMetadata); + when(context.tableSchema()).thenReturn(schema); + + TimeToLiveExtension extension = TimeToLiveExtension.create(); + WriteModification result = extension.beforeWrite(context); + + Map transformed = result.transformedItem(); + assertNotNull(transformed); + assertTrue(transformed.containsKey(ttlAttrName)); + + long actualTtl = Long.parseLong(transformed.get(ttlAttrName).n()); + assertEquals(expectedTtl, actualTtl); + } + + @Test + public void beforeWrite_computesTtlThrowsExceptionForUnsupportedType() { + String ttlAttrName = "ttl"; + String baseFieldName = "createdAt"; + long duration = 60L; + ChronoUnit unit = ChronoUnit.SECONDS; + + String baseTime = "invalidType"; + + Map item = new HashMap<>(); + item.put(baseFieldName, StringAttributeConverter.create().transformFrom(baseTime)); + + Map customMetadata = new HashMap<>(); + customMetadata.put("attributeName", ttlAttrName); + customMetadata.put("baseField", baseFieldName); + customMetadata.put("duration", duration); + customMetadata.put("unit", unit); + + TableMetadata tableMetadata = mock(TableMetadata.class); + when(tableMetadata.customMetadataObject(TimeToLiveExtension.CUSTOM_METADATA_KEY, Map.class)) + .thenReturn(Optional.of(customMetadata)); + + TableSchema schema = mock(TableSchema.class); + when(schema.converterForAttribute(baseFieldName)).thenReturn(StringAttributeConverter.create()); + + DynamoDbExtensionContext.BeforeWrite context = mock(DynamoDbExtensionContext.BeforeWrite.class); + when(context.items()).thenReturn(item); + when(context.tableMetadata()).thenReturn(tableMetadata); + when(context.tableSchema()).thenReturn(schema); + + TimeToLiveExtension extension = TimeToLiveExtension.create(); + + assertThrows(IllegalArgumentException.class, () -> + extension.beforeWrite(context)); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/TimeToLiveRecordTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/TimeToLiveRecordTest.java new file mode 100644 index 000000000000..826bc414567b --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/TimeToLiveRecordTest.java @@ -0,0 +1,92 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, AutoTimestamp 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 java.time.temporal.ChronoUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.assertj.core.api.AssertionsForClassTypes; +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.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedTimestampRecordExtension; +import software.amazon.awssdk.enhanced.dynamodb.extensions.TimeToLiveExtension; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordWithTTL; +import software.amazon.awssdk.enhanced.dynamodb.internal.client.ExtensionResolver; +import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTimeToLiveEnhancedResponse; +import software.amazon.awssdk.services.dynamodb.model.TimeToLiveStatus; + +public class TimeToLiveRecordTest extends LocalDynamoDbSyncTestBase { + + private static final TableSchema TABLE_SCHEMA = TableSchema.fromClass(RecordWithTTL.class); + + private final TimeToLiveExtension timeToLiveExtension = TimeToLiveExtension.create(); + + private final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()).extensions( + Stream.concat(ExtensionResolver.defaultExtensions().stream(), + Stream.of(AutoGeneratedTimestampRecordExtension.create(), timeToLiveExtension)).collect(Collectors.toList())) + .build(); + + private final DynamoDbTable mappedTable = + enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + + @Before + public void createTable() { + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + } + + @After + public void deleteTable() { + getDynamoDbClient().deleteTable(r -> r.tableName(getConcreteTableName("table-name"))); + } + + @Test + public void updateTimeToLive_multipleUpdates() { + RecordWithTTL record = new RecordWithTTL(); + record.setId("id123"); + record.setAttribute("attribute"); + mappedTable.putItem(record); + + RecordWithTTL persistedRecord = mappedTable.getItem(record); + + assertThat(persistedRecord.getUpdatedDate()).isNotNull(); + assertThat(persistedRecord.getExpirationDate()).isEqualTo(persistedRecord.getUpdatedDate().plus(30, ChronoUnit.DAYS).getEpochSecond()); + + DescribeTimeToLiveEnhancedResponse ttlResponseBeforeUpdate = mappedTable.describeTimeToLive(); + AssertionsForClassTypes.assertThat(ttlResponseBeforeUpdate.timeToLiveDescription().timeToLiveStatus()).isEqualTo(TimeToLiveStatus.DISABLED); + AssertionsForClassTypes.assertThat(ttlResponseBeforeUpdate.timeToLiveDescription().attributeName()).isNull(); + + mappedTable.updateTimeToLive(true); + + DescribeTimeToLiveEnhancedResponse ttlResponseAfterEnable = mappedTable.describeTimeToLive(); + AssertionsForClassTypes.assertThat(ttlResponseAfterEnable.timeToLiveDescription().timeToLiveStatus()).isEqualTo(TimeToLiveStatus.ENABLED); + AssertionsForClassTypes.assertThat(ttlResponseAfterEnable.timeToLiveDescription().attributeName()).isEqualTo( + "expirationDate"); + + mappedTable.updateTimeToLive(false); + + DescribeTimeToLiveEnhancedResponse ttlResponseAfterDisable = mappedTable.describeTimeToLive(); + + AssertionsForClassTypes.assertThat(ttlResponseAfterDisable.timeToLiveDescription().timeToLiveStatus()).isEqualTo(TimeToLiveStatus.DISABLED); + AssertionsForClassTypes.assertThat(ttlResponseAfterDisable.timeToLiveDescription().attributeName()).isNull(); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithDefaultTTL.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithDefaultTTL.java new file mode 100644 index 000000000000..5947c79b9dca --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithDefaultTTL.java @@ -0,0 +1,61 @@ +/* + * 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.time.Instant; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedTimestampAttribute; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbTimeToLiveAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; + +@DynamoDbBean +public class RecordWithDefaultTTL { + private String id; + private String attribute; + private Instant updatedDate; + private Long expirationDate; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAttribute() { return attribute; } + + public void setAttribute(String attribute) { this.attribute = attribute; } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getUpdatedDate() { + return updatedDate; + } + + public void setUpdatedDate(Instant updatedDate) { + this.updatedDate = updatedDate; + } + + @DynamoDbTimeToLiveAttribute(baseField="updatedDate") + public Long getExpirationDate() { + return expirationDate; + } + + public void setExpirationDate(Long expirationDate) { + this.expirationDate = expirationDate; + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithSimpleTTL.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithSimpleTTL.java new file mode 100644 index 000000000000..ace378ffd38a --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithSimpleTTL.java @@ -0,0 +1,53 @@ +/* + * 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 software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbTimeToLiveAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; + +@DynamoDbBean +public class RecordWithSimpleTTL { + private String id; + private String attribute; + private Long expirationDate; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAttribute() { + return attribute; + } + + public void setAttribute(String attribute) { + this.attribute = attribute; + } + + @DynamoDbTimeToLiveAttribute + public Long getExpirationDate() { + return expirationDate; + } + + public void setExpirationDate(Long expirationDate) { + this.expirationDate = expirationDate; + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithTTL.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithTTL.java new file mode 100644 index 000000000000..a879fe36d0af --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithTTL.java @@ -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.functionaltests.models; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedTimestampAttribute; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbTimeToLiveAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; + +@DynamoDbBean +public class RecordWithTTL { + private String id; + private String attribute; + private Instant updatedDate; + private Long expirationDate; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAttribute() { return attribute; } + + public void setAttribute(String attribute) { this.attribute = attribute; } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getUpdatedDate() { + return updatedDate; + } + + public void setUpdatedDate(Instant updatedDate) { + this.updatedDate = updatedDate; + } + + @DynamoDbTimeToLiveAttribute(baseField="updatedDate", duration=30, unit= ChronoUnit.DAYS) + public Long getExpirationDate() { + return expirationDate; + } + + public void setExpirationDate(Long expirationDate) { + this.expirationDate = expirationDate; + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DescribeTimeToLiveOperationTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DescribeTimeToLiveOperationTest.java new file mode 100644 index 000000000000..1eef324791f5 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/DescribeTimeToLiveOperationTest.java @@ -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.operations; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.verify; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import software.amazon.awssdk.enhanced.dynamodb.OperationContext; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItemWithSort; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.DescribeTimeToLiveRequest; + +@RunWith(MockitoJUnitRunner.class) +public class DescribeTimeToLiveOperationTest { + + private static final String TABLE_NAME = "table-name"; + private static final OperationContext PRIMARY_CONTEXT = + DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + @Mock + private DynamoDbClient mockDynamoDbClient; + + @Test + public void getServiceCall_makesTheRightCall() { + DescribeTimeToLiveOperation operation = DescribeTimeToLiveOperation.create(); + DescribeTimeToLiveRequest describeTimeToLiveRequest = DescribeTimeToLiveRequest.builder().build(); + operation.serviceCall(mockDynamoDbClient).apply(describeTimeToLiveRequest); + verify(mockDynamoDbClient).describeTimeToLive(same(describeTimeToLiveRequest)); + } + + + @Test + public void generateRequest_from_DescribeTimeToLiveOperation() { + DescribeTimeToLiveOperation describeTimeToLiveOperation = DescribeTimeToLiveOperation.create(); + DescribeTimeToLiveRequest describeTimeToLiveRequest = describeTimeToLiveOperation + .generateRequest(FakeItemWithSort.getTableSchema(), + PRIMARY_CONTEXT, + null); + assertThat(describeTimeToLiveRequest, is(DescribeTimeToLiveRequest.builder().tableName(TABLE_NAME).build())); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateTimeToLiveOperationTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateTimeToLiveOperationTest.java new file mode 100644 index 000000000000..87ee270fa32c --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateTimeToLiveOperationTest.java @@ -0,0 +1,87 @@ +/* + * 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.operations; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.sameInstance; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import software.amazon.awssdk.enhanced.dynamodb.OperationContext; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordWithTTL; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.TimeToLiveSpecification; +import software.amazon.awssdk.services.dynamodb.model.UpdateTimeToLiveRequest; +import software.amazon.awssdk.services.dynamodb.model.UpdateTimeToLiveResponse; + +@RunWith(MockitoJUnitRunner.class) +public class UpdateTimeToLiveOperationTest { + + private static final String TABLE_NAME = "table-name"; + private static final OperationContext PRIMARY_CONTEXT = + DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + private static final TableSchema TABLE_SCHEMA = TableSchema.fromClass(RecordWithTTL.class); + + @Mock + private DynamoDbClient mockDynamoDbClient; + + @Test + public void getServiceCall_makesTheRightCallAndReturnsResponse() { + UpdateTimeToLiveOperation operation = UpdateTimeToLiveOperation.create(true); + UpdateTimeToLiveRequest updateTimeToLiveRequest = UpdateTimeToLiveRequest.builder().build(); + UpdateTimeToLiveResponse expectedResponse = UpdateTimeToLiveResponse.builder().build(); + when(mockDynamoDbClient.updateTimeToLive(any(UpdateTimeToLiveRequest.class))).thenReturn(expectedResponse); + + UpdateTimeToLiveResponse response = operation.serviceCall(mockDynamoDbClient).apply(updateTimeToLiveRequest); + + assertThat(response, sameInstance(expectedResponse)); + verify(mockDynamoDbClient).updateTimeToLive(same(updateTimeToLiveRequest)); + } + + @Test + public void generateRequest_from_UpdateTimeToLiveOperation() { + UpdateTimeToLiveOperation updateTimeToLiveOperation = UpdateTimeToLiveOperation.create(true); + UpdateTimeToLiveRequest updateTimeToLiveRequest = updateTimeToLiveOperation.generateRequest(TABLE_SCHEMA, + PRIMARY_CONTEXT, + null); + assertThat(updateTimeToLiveRequest, is(UpdateTimeToLiveRequest.builder() + .tableName(TABLE_NAME) + .timeToLiveSpecification(TimeToLiveSpecification.builder() + .enabled(true) + .attributeName( + "expirationDate") + .build()) + .build())); + } + + @Test(expected = IllegalArgumentException.class) + public void generateRequest_withoutTtlAnnotation_throwsIllegalArgumentException() { + UpdateTimeToLiveOperation updateTimeToLiveOperation = UpdateTimeToLiveOperation.create(true); + + updateTimeToLiveOperation.generateRequest(FakeItem.getTableSchema(), PRIMARY_CONTEXT, null); + } +}