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..6bc3996e0031 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,46 @@ default void deleteTable() { default DescribeTableEnhancedResponse describeTable() { throw new UnsupportedOperationException(); } + + /** + * Describes the time to live (TTL) configuration of the table with the name defined for this + * {@link DynamoDbTable}. + *

+ * This operation calls the low-level DynamoDB API {@code DescribeTimeToLive} operation. + *

+ * Example: + *

+     * {@code
+     *
+     * DescribeTimeToLiveEnhancedResponse response = mappedTable.describeTimeToLive();
+     * }
+     * 
+ * + * @return The TTL description returned by DynamoDB. + */ + default DescribeTimeToLiveEnhancedResponse describeTimeToLive() { + throw new UnsupportedOperationException(); + } + + /** + * Updates the time to live (TTL) configuration of the table with the name defined for this + * {@link DynamoDbTable}. + *

+ * This operation calls the low-level DynamoDB API {@code UpdateTimeToLive} operation and uses the + * TTL attribute configured in this table's schema metadata. + *

+ * Example: + *

+     * {@code
+     *
+     * UpdateTimeToLiveEnhancedResponse response = mappedTable.updateTimeToLive(true);
+     * }
+     * 
+ * + * @param enabled Whether TTL should be enabled or disabled for the table. + * @return The TTL specification returned by DynamoDB after the update request is accepted. + */ + 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..0845d9e8ff21 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/TimeToLiveExtension.java @@ -0,0 +1,201 @@ +/* + * 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 = validateMetadataValue(customTTLMetadata, "attributeName", String.class); + String baseFieldName = validateMetadataValue(customTTLMetadata, "baseField", String.class); + long duration = validateMetadataValue(customTTLMetadata, "duration", Long.class); + TemporalUnit unit = validateMetadataValue(customTTLMetadata, "unit", TemporalUnit.class); + + Validate.isTrue(duration >= 0, "Custom TTL metadata key 'duration' must not be negative."); + + Map items = context.items(); + + if (!items.containsKey(ttlAttributeName) && StringUtils.isNotBlank(baseFieldName) + && items.containsKey(baseFieldName)) { + Object baseFieldValue = context.tableSchema().converterForAttribute(baseFieldName) + .transformTo(items.get(baseFieldName)); + Long ttlEpochSeconds = computeTTLFromBase(baseFieldValue, duration, unit); + Map itemToTransform = new HashMap<>(context.items()); + itemToTransform.put(ttlAttributeName, AttributeValue.builder().n(String.valueOf(ttlEpochSeconds)).build()); + + return WriteModification.builder().transformedItem(Collections.unmodifiableMap(itemToTransform)).build(); + } + } + + return WriteModification.builder().build(); + } + + private static T validateMetadataValue(Map metadata, String key, Class expectedType) { + Object value = Validate.notNull(metadata.get(key), "Custom TTL metadata is missing required key '%s'.", key); + + if (!expectedType.isInstance(value)) { + throw new IllegalArgumentException(String.format("Custom TTL metadata key '%s' must be of type %s, but was %s.", + key, expectedType.getName(), value.getClass().getName())); + } + + return expectedType.cast(value); + } + + 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 { + + private final String baseField; + private final long duration; + private final 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) { + Validate.isTrue(duration >= 0, "duration must not be negative"); + 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..284bbd247447 --- /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; + + private 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 90f8231cd26c..2cdb48daaa89 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.EnhancedTypeDocumentConfiguration; import software.amazon.awssdk.enhanced.dynamodb.ExecutionContext; 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; @@ -250,6 +252,7 @@ private static StaticTableSchema createStaticTableSchema(Class beanCla builder.attributeConverterProviders(createConverterProvidersFromAnnotation(beanClass, lookup, dynamoDbBean)); List> attributes = new ArrayList<>(); + AtomicInteger ttlAttributesCount = new AtomicInteger(); mappableProperties.forEach(propertyDescriptor -> { DynamoDbFlatten dynamoDbFlatten = getPropertyAnnotation(propertyDescriptor, DynamoDbFlatten.class); @@ -283,10 +286,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(context); } 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..8fab24115a26 --- /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 UpdateTimeToLive} 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 timeToLiveSpecification() { + 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..591ba0a4116f --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/TimeToLiveExtensionTest.java @@ -0,0 +1,447 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static software.amazon.awssdk.enhanced.dynamodb.extensions.TimeToLiveExtension.AttributeTags.timeToLiveAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; + +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.AttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +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.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.enhanced.dynamodb.mapper.StaticAttributeTag; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +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 TAGGED_TTL_SCHEMA = + StaticTableSchema.builder(TaggedTtlItem.class) + .newItemSupplier(TaggedTtlItem::new) + .addAttribute(String.class, a -> a.name("id") + .getter(TaggedTtlItem::getId) + .setter(TaggedTtlItem::setId) + .tags(primaryPartitionKey())) + .addAttribute(Instant.class, a -> a.name("baseTimestamp") + .getter(TaggedTtlItem::getBaseTimestamp) + .setter(TaggedTtlItem::setBaseTimestamp)) + .addAttribute(Long.class, a -> a.name("expiresAt") + .getter(TaggedTtlItem::getExpiresAt) + .setter(TaggedTtlItem::setExpiresAt) + .tags(timeToLiveAttribute("baseTimestamp", 5, ChronoUnit.MINUTES))) + .build(); + + private static final TableSchema NO_TTL_SCHEMA = + StaticTableSchema.builder(NoTtlItem.class) + .newItemSupplier(NoTtlItem::new) + .addAttribute(String.class, a -> a.name("id") + .getter(NoTtlItem::getId) + .setter(NoTtlItem::setId) + .tags(primaryPartitionKey())) + .addAttribute(Instant.class, a -> a.name("baseTimestamp") + .getter(NoTtlItem::getBaseTimestamp) + .setter(NoTtlItem::setBaseTimestamp)) + .build(); + + private final TimeToLiveExtension extension = TimeToLiveExtension.create(); + + @Test + public void builderAndCreate_returnUsableExtensionInstances() { + assertThat(TimeToLiveExtension.builder().build()).isNotNull().isInstanceOf(TimeToLiveExtension.class); + assertThat(TimeToLiveExtension.create()).isNotNull().isInstanceOf(TimeToLiveExtension.class); + } + + @Test + public void beforeWrite_withoutTtlMetadata_returnsNoTransformation() { + Map item = new HashMap<>(); + item.put("id", AttributeValue.builder().s("id-1").build()); + item.put("baseTimestamp", InstantAsStringAttributeConverter.create().transformFrom(Instant.parse("2024-01-01T00:00:00Z"))); + + WriteModification result = extension.beforeWrite(defaultContext(item, NO_TTL_SCHEMA)); + + assertThat(result.transformedItem()).isNull(); + } + + @Test + public void beforeWrite_withExistingTtlValue_returnsNoTransformation() { + Map item = new HashMap<>(); + item.put("id", AttributeValue.builder().s("id-1").build()); + item.put("baseTimestamp", InstantAsStringAttributeConverter.create().transformFrom(Instant.parse("2024-01-01T00:00:00Z"))); + item.put("expiresAt", AttributeValue.builder().n("123").build()); + + WriteModification result = extension.beforeWrite(defaultContext(item, TAGGED_TTL_SCHEMA)); + + assertThat(result.transformedItem()).isNull(); + } + + @Test + public void beforeWrite_withExistingTtlValue_doesNotCopyItemWhenNoTransformationIsNeeded() { + Map item = mock(Map.class); + when(item.containsKey("expiresAt")).thenReturn(true); + when(item.entrySet()).thenThrow(new AssertionError("No-op TTL paths should not iterate or copy the input item map")); + + WriteModification result = extension.beforeWrite(defaultContext(item, TAGGED_TTL_SCHEMA)); + + assertThat(result.transformedItem()).isNull(); + } + + @Test + public void beforeWrite_withoutBaseFieldValue_returnsNoTransformation() { + Map item = new HashMap<>(); + item.put("id", AttributeValue.builder().s("id-1").build()); + + WriteModification result = extension.beforeWrite(defaultContext(item, TAGGED_TTL_SCHEMA)); + + assertThat(result.transformedItem()).isNull(); + } + + @Test + public void beforeWrite_withBlankBaseField_returnsNoTransformation() { + WriteModification result = beforeWriteWithCustomMetadata("ttl", "", + Instant.parse("2024-01-01T00:00:00Z"), + InstantAsStringAttributeConverter.create(), 5, + ChronoUnit.MINUTES); + + assertThat(result.transformedItem()).isNull(); + } + + @Test + public void beforeWrite_withTaggedSchema_computesTtlFromInstant() { + Instant baseTime = Instant.parse("2024-01-01T00:00:00Z"); + long expectedTtl = baseTime.plus(5, ChronoUnit.MINUTES).getEpochSecond(); + + Map item = new HashMap<>(); + item.put("id", AttributeValue.builder().s("id-1").build()); + item.put("baseTimestamp", InstantAsStringAttributeConverter.create().transformFrom(baseTime)); + + WriteModification result = extension.beforeWrite(defaultContext(item, TAGGED_TTL_SCHEMA)); + + assertThat(item).doesNotContainKey("expiresAt"); + assertThat(result.transformedItem()).isNotNull(); + assertThat(result.transformedItem()).containsEntry("expiresAt", AttributeValue.builder().n(String.valueOf(expectedTtl)).build()); + } + + @Test + public void beforeWrite_computesTtlFromLocalDate() { + LocalDate baseDate = LocalDate.of(2024, 2, 1); + long expectedTtl = baseDate.atStartOfDay(ZoneOffset.UTC).plusDays(2).toEpochSecond(); + + WriteModification result = beforeWriteWithCustomMetadata("ttl", "baseField", baseDate, + LocalDateAttributeConverter.create(), 2, ChronoUnit.DAYS); + + assertThat(ttlFrom(result, "ttl")).isEqualTo(expectedTtl); + } + + @Test + public void beforeWrite_computesTtlFromLocalDateTime() { + LocalDateTime baseDateTime = LocalDateTime.of(2024, 2, 1, 12, 30, 15); + long expectedTtl = baseDateTime.plusHours(3).toEpochSecond(ZoneOffset.UTC); + + WriteModification result = beforeWriteWithCustomMetadata("ttl", "baseField", baseDateTime, + LocalDateTimeAttributeConverter.create(), 3, + ChronoUnit.HOURS); + + assertThat(ttlFrom(result, "ttl")).isEqualTo(expectedTtl); + } + + @Test + public void beforeWrite_computesTtlFromLocalTime() { + LocalTime baseTime = LocalTime.of(10, 15, 30); + long expectedBefore = LocalDate.now().atTime(baseTime).plusMinutes(45).toEpochSecond(ZoneOffset.UTC); + + WriteModification result = beforeWriteWithCustomMetadata("ttl", "baseField", baseTime, + LocalTimeAttributeConverter.create(), 45, + ChronoUnit.MINUTES); + + long expectedAfter = LocalDate.now().atTime(baseTime).plusMinutes(45).toEpochSecond(ZoneOffset.UTC); + assertThat(ttlFrom(result, "ttl")).isBetween(Math.min(expectedBefore, expectedAfter), + Math.max(expectedBefore, expectedAfter)); + } + + @Test + public void beforeWrite_computesTtlFromZonedDateTime() { + ZonedDateTime baseDateTime = ZonedDateTime.of(2024, 2, 1, 12, 30, 15, 0, ZoneOffset.UTC); + long expectedTtl = baseDateTime.plusSeconds(90).toEpochSecond(); + + WriteModification result = beforeWriteWithCustomMetadata("ttl", "baseField", baseDateTime, + ZonedDateTimeAsStringAttributeConverter.create(), 90, + ChronoUnit.SECONDS); + + assertThat(ttlFrom(result, "ttl")).isEqualTo(expectedTtl); + } + + @Test + public void beforeWrite_computesTtlFromEpochSecondsLong() { + long baseEpochSeconds = 1_707_123_456L; + long expectedTtl = baseEpochSeconds + 120L; + + WriteModification result = beforeWriteWithCustomMetadata("ttl", "baseField", baseEpochSeconds, + LongAttributeConverter.create(), 120, + ChronoUnit.SECONDS); + + assertThat(ttlFrom(result, "ttl")).isEqualTo(expectedTtl); + } + + @Test + public void beforeWrite_rejectsUnsupportedBaseFieldTypes() { + assertThatThrownBy(() -> beforeWriteWithCustomMetadata("ttl", "baseField", "not-supported", + StringAttributeConverter.create(), 1, + ChronoUnit.DAYS)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported base field type for TTL computation") + .hasMessageContaining(String.class.getName()); + } + + @Test + public void timeToLiveAttributeTag_validateType_acceptsLong() { + StaticAttributeTag ttlTag = timeToLiveAttribute("baseTimestamp", 10, ChronoUnit.SECONDS); + + ttlTag.validateType("expiresAt", EnhancedType.of(Long.class), AttributeValueType.N); + } + + @Test + public void timeToLiveAttributeTag_validateType_rejectsNonLongTypes() { + StaticAttributeTag ttlTag = timeToLiveAttribute("baseTimestamp", 10, ChronoUnit.SECONDS); + + assertThatThrownBy(() -> ttlTag.validateType("expiresAt", EnhancedType.of(String.class), AttributeValueType.S)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Attribute 'expiresAt'") + .hasMessageContaining("Only type Long is supported"); + } + + @Test + public void timeToLiveAttributeTag_validateType_rejectsNullArguments() { + StaticAttributeTag ttlTag = timeToLiveAttribute("baseTimestamp", 10, ChronoUnit.SECONDS); + + assertThatThrownBy(() -> ttlTag.validateType("expiresAt", null, AttributeValueType.N)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("type is null"); + + EnhancedType typeWithNullRawClass = mock(EnhancedType.class); + when(typeWithNullRawClass.rawClass()).thenReturn(null); + + assertThatThrownBy(() -> ttlTag.validateType("expiresAt", typeWithNullRawClass, AttributeValueType.N)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("rawClass is null"); + + assertThatThrownBy(() -> ttlTag.validateType("expiresAt", EnhancedType.of(Long.class), null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("attributeValueType is null"); + } + + @Test + public void timeToLiveAttributeTag_modifyMetadata_storesTtlConfiguration() { + StaticAttributeTag ttlTag = timeToLiveAttribute("baseTimestamp", 7, ChronoUnit.HOURS); + StaticTableMetadata.Builder metadataBuilder = StaticTableMetadata.builder(); + + ttlTag.modifyMetadata("expiresAt", AttributeValueType.N).accept(metadataBuilder); + + Map metadata = metadataBuilder.build() + .customMetadataObject(TimeToLiveExtension.CUSTOM_METADATA_KEY, Map.class) + .orElseThrow(IllegalStateException::new); + + assertThat(metadata.get("attributeName")).isEqualTo("expiresAt"); + assertThat(metadata.get("baseField")).isEqualTo("baseTimestamp"); + assertThat(metadata.get("duration")).isEqualTo(7L); + assertThat(metadata.get("unit")).isEqualTo(ChronoUnit.HOURS); + } + + @Test + public void beforeWrite_withMissingDurationMetadata_throwsDescriptiveException() { + Map customMetadata = ttlMetadataWithoutDuration("ttl", "baseField", ChronoUnit.DAYS); + + assertThatThrownBy(() -> beforeWriteWithInstantMetadata(customMetadata)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("Custom TTL metadata is missing required key 'duration'"); + } + + @Test + public void beforeWrite_withWrongMetadataType_throwsDescriptiveException() { + Map customMetadata = ttlMetadata("ttl", "baseField", 5L, ChronoUnit.DAYS); + customMetadata.put("duration", "5"); + + assertThatThrownBy(() -> beforeWriteWithInstantMetadata(customMetadata)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Custom TTL metadata key 'duration' must be of type " + Long.class.getName()) + .hasMessageContaining(String.class.getName()); + } + + @Test + public void beforeWrite_withNegativeDurationMetadata_throwsDescriptiveException() { + Map customMetadata = ttlMetadata("ttl", "baseField", -5L, ChronoUnit.DAYS); + + assertThatThrownBy(() -> beforeWriteWithInstantMetadata(customMetadata)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Custom TTL metadata key 'duration' must not be negative"); + } + + private WriteModification beforeWriteWithCustomMetadata(String ttlAttributeName, + String baseFieldName, + T baseFieldValue, + AttributeConverter converter, + long duration, + ChronoUnit unit) { + return beforeWriteWithMetadata(ttlMetadata(ttlAttributeName, baseFieldName, duration, unit), + baseFieldName, + baseFieldValue, + converter); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private WriteModification beforeWriteWithMetadata(Map customMetadata, + String baseFieldName, + T baseFieldValue, + AttributeConverter converter) { + Map item = new HashMap<>(); + item.put(baseFieldName, converter.transformFrom(baseFieldValue)); + + TableMetadata tableMetadata = mock(TableMetadata.class); + when(tableMetadata.customMetadataObject(TimeToLiveExtension.CUSTOM_METADATA_KEY, Map.class)) + .thenReturn(Optional.of(customMetadata)); + + TableSchema tableSchema = mock(TableSchema.class); + when(tableSchema.converterForAttribute(baseFieldName)).thenReturn(converter); + + DynamoDbExtensionContext.BeforeWrite context = mock(DynamoDbExtensionContext.BeforeWrite.class); + when(context.items()).thenReturn(item); + when(context.tableMetadata()).thenReturn(tableMetadata); + when(context.tableSchema()).thenReturn(tableSchema); + + return extension.beforeWrite(context); + } + + private Map ttlMetadata(String ttlAttributeName, String baseFieldName, long duration, ChronoUnit unit) { + Map customMetadata = new HashMap<>(); + customMetadata.put("attributeName", ttlAttributeName); + customMetadata.put("baseField", baseFieldName); + customMetadata.put("duration", duration); + customMetadata.put("unit", unit); + return customMetadata; + } + + private Map ttlMetadataWithoutDuration(String ttlAttributeName, String baseFieldName, ChronoUnit unit) { + Map customMetadata = new HashMap<>(); + customMetadata.put("attributeName", ttlAttributeName); + customMetadata.put("baseField", baseFieldName); + customMetadata.put("unit", unit); + return customMetadata; + } + + private long ttlFrom(WriteModification result, String attributeName) { + assertThat(result.transformedItem()).isNotNull().containsKey(attributeName); + return Long.parseLong(result.transformedItem().get(attributeName).n()); + } + + private DynamoDbExtensionContext.BeforeWrite defaultContext(Map item, TableSchema tableSchema) { + return DefaultDynamoDbExtensionContext.builder() + .items(item) + .tableSchema(tableSchema) + .tableMetadata(tableSchema.tableMetadata()) + .operationName(OperationName.UPDATE_ITEM) + .operationContext(PRIMARY_CONTEXT) + .build(); + } + + private static final class TaggedTtlItem { + private String id; + private Instant baseTimestamp; + private Long expiresAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Instant getBaseTimestamp() { + return baseTimestamp; + } + + public void setBaseTimestamp(Instant baseTimestamp) { + this.baseTimestamp = baseTimestamp; + } + + public Long getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Long expiresAt) { + this.expiresAt = expiresAt; + } + } + + private static final class NoTtlItem { + private String id; + private Instant baseTimestamp; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Instant getBaseTimestamp() { + return baseTimestamp; + } + + public void setBaseTimestamp(Instant baseTimestamp) { + this.baseTimestamp = baseTimestamp; + } + } + + private WriteModification beforeWriteWithInstantMetadata(Map customMetadata) { + return beforeWriteWithMetadata(customMetadata, + "baseField", + Instant.parse("2024-01-01T00:00:00Z"), + InstantAsStringAttributeConverter.create()); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AsyncTimeToLiveTableOperationSchemaVariantsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AsyncTimeToLiveTableOperationSchemaVariantsTest.java new file mode 100644 index 000000000000..c6c15d805be6 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AsyncTimeToLiveTableOperationSchemaVariantsTest.java @@ -0,0 +1,149 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Collection; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbAsyncTable; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedAsyncClient; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTimeToLiveEnhancedResponse; +import software.amazon.awssdk.enhanced.dynamodb.model.UpdateTimeToLiveEnhancedResponse; +import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; +import software.amazon.awssdk.services.dynamodb.model.TimeToLiveStatus; + +@RunWith(Parameterized.class) +public class AsyncTimeToLiveTableOperationSchemaVariantsTest extends LocalDynamoDbAsyncTestBase { + private static final String TABLE_NAME = "table-name"; + + private final DynamoDbEnhancedAsyncClient enhancedClient = DynamoDbEnhancedAsyncClient.builder() + .dynamoDbClient(getDynamoDbAsyncClient()) + .build(); + + private final TableSchema ttlSchema; + private final TableSchema schemaWithoutTtl; + private final String schemaType; + + private DynamoDbAsyncTable mappedTable; + + public AsyncTimeToLiveTableOperationSchemaVariantsTest(String schemaType, + TableSchema ttlSchema, + TableSchema schemaWithoutTtl) { + this.ttlSchema = ttlSchema; + this.schemaWithoutTtl = schemaWithoutTtl; + this.schemaType = schemaType; + } + + @Parameters(name = "{index}; {0}") + public static Collection parameters() { + return TimeToLiveSchemaVariants.data(); + } + + @Before + public void createTable() { + mappedTable = enhancedClient.table(getConcreteTableName(TABLE_NAME), ttlSchema); + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())).join(); + } + + @After + public void deleteTable() { + getDynamoDbAsyncClient().deleteTable(DeleteTableRequest.builder() + .tableName(getConcreteTableName(TABLE_NAME)) + .build()).join(); + } + + @Test + public void describeTimeToLive_returnsDisabledStatus_whenTableIsNew() { + DescribeTimeToLiveEnhancedResponse response = mappedTable.describeTimeToLive().join(); + + assertThat(response.timeToLiveDescription().timeToLiveStatus()) + .as(schemaType) + .isEqualTo(TimeToLiveStatus.DISABLED); + } + + @Test + public void describeTimeToLive_returnsDisabledStatus_whenSchemaHasNoTtlMetadata() { + DescribeTimeToLiveEnhancedResponse response = enhancedClient.table(getConcreteTableName(TABLE_NAME), schemaWithoutTtl) + .describeTimeToLive().join(); + + assertThat(response.timeToLiveDescription().timeToLiveStatus()) + .as(schemaType) + .isEqualTo(TimeToLiveStatus.DISABLED); + } + + @Test + public void describeTimeToLive_returnsExpirationAttribute_whenTtlWasEnabled() { + mappedTable.updateTimeToLive(true).join(); + + DescribeTimeToLiveEnhancedResponse response = mappedTable.describeTimeToLive().join(); + + assertThat(response.timeToLiveDescription().attributeName()) + .as(schemaType) + .isEqualTo("expirationDate"); + } + + @Test + public void updateTimeToLive_returnsEnabledSpecification_whenEnablingTtl() { + UpdateTimeToLiveEnhancedResponse response = mappedTable.updateTimeToLive(true).join(); + + assertThat(response.timeToLiveSpecification().enabled()).as(schemaType).isTrue(); + assertThat(response.timeToLiveSpecification().attributeName()).as(schemaType).isEqualTo("expirationDate"); + } + + @Test + public void updateTimeToLive_returnsDisabledSpecification_whenDisablingTtl() { + UpdateTimeToLiveEnhancedResponse enableResponse = mappedTable.updateTimeToLive(true).join(); + assertThat(enableResponse.timeToLiveSpecification().enabled()).as(schemaType).isTrue(); + + UpdateTimeToLiveEnhancedResponse response = mappedTable.updateTimeToLive(false).join(); + + assertThat(response.timeToLiveSpecification().enabled()).as(schemaType).isFalse(); + assertThat(response.timeToLiveSpecification().attributeName()).as(schemaType).isEqualTo("expirationDate"); + } + + @Test + public void updateTimeToLive_throwsException_whenTtlIsAlreadyEnabled() { + mappedTable.updateTimeToLive(true).join(); + + assertThatThrownBy(() -> mappedTable.updateTimeToLive(true).join()) + .as(schemaType) + .hasMessageContaining("TimeToLive is already enabled"); + } + + @Test + public void updateTimeToLive_throwsException_whenTtlIsAlreadyDisabled() { + assertThatThrownBy(() -> mappedTable.updateTimeToLive(false).join()) + .as(schemaType) + .hasMessageContaining("TimeToLive is already disabled"); + } + + @Test + public void updateTimeToLive_throwsException_whenSchemaHasNoTtlMetadata() { + assertThatThrownBy(() -> enhancedClient.table(getConcreteTableName(TABLE_NAME), schemaWithoutTtl) + .updateTimeToLive(true).join()) + .as(schemaType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Custom TTL metadata object is null"); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/SyncTimeToLiveTableOperationSchemaVariantsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/SyncTimeToLiveTableOperationSchemaVariantsTest.java new file mode 100644 index 000000000000..bfbface145ba --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/SyncTimeToLiveTableOperationSchemaVariantsTest.java @@ -0,0 +1,149 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Collection; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +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.model.DescribeTimeToLiveEnhancedResponse; +import software.amazon.awssdk.enhanced.dynamodb.model.UpdateTimeToLiveEnhancedResponse; +import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; +import software.amazon.awssdk.services.dynamodb.model.TimeToLiveStatus; + +@RunWith(Parameterized.class) +public class SyncTimeToLiveTableOperationSchemaVariantsTest extends LocalDynamoDbSyncTestBase { + private static final String TABLE_NAME = "table-name"; + + private final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .build(); + + private final TableSchema ttlSchema; + private final TableSchema schemaWithoutTtl; + private final String schemaType; + + private DynamoDbTable mappedTable; + + public SyncTimeToLiveTableOperationSchemaVariantsTest(String schemaType, + TableSchema ttlSchema, + TableSchema schemaWithoutTtl) { + this.ttlSchema = ttlSchema; + this.schemaWithoutTtl = schemaWithoutTtl; + this.schemaType = schemaType; + } + + @Parameters(name = "{index}; {0}") + public static Collection parameters() { + return TimeToLiveSchemaVariants.data(); + } + + @Before + public void createTable() { + mappedTable = enhancedClient.table(getConcreteTableName(TABLE_NAME), ttlSchema); + mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + } + + @After + public void deleteTable() { + getDynamoDbClient().deleteTable(DeleteTableRequest.builder() + .tableName(getConcreteTableName(TABLE_NAME)) + .build()); + } + + @Test + public void describeTimeToLive_returnsDisabledStatus_whenTableIsNew() { + DescribeTimeToLiveEnhancedResponse response = mappedTable.describeTimeToLive(); + + assertThat(response.timeToLiveDescription().timeToLiveStatus()) + .as(schemaType) + .isEqualTo(TimeToLiveStatus.DISABLED); + } + + @Test + public void describeTimeToLive_returnsDisabledStatus_whenSchemaHasNoTtlMetadata() { + DescribeTimeToLiveEnhancedResponse response = enhancedClient.table(getConcreteTableName(TABLE_NAME), schemaWithoutTtl) + .describeTimeToLive(); + + assertThat(response.timeToLiveDescription().timeToLiveStatus()) + .as(schemaType) + .isEqualTo(TimeToLiveStatus.DISABLED); + } + + @Test + public void describeTimeToLive_returnsExpirationAttribute_whenTtlWasEnabled() { + mappedTable.updateTimeToLive(true); + + DescribeTimeToLiveEnhancedResponse response = mappedTable.describeTimeToLive(); + + assertThat(response.timeToLiveDescription().attributeName()) + .as(schemaType) + .isEqualTo("expirationDate"); + } + + @Test + public void updateTimeToLive_returnsEnabledSpecification_whenEnablingTtl() { + UpdateTimeToLiveEnhancedResponse response = mappedTable.updateTimeToLive(true); + + assertThat(response.timeToLiveSpecification().enabled()).as(schemaType).isTrue(); + assertThat(response.timeToLiveSpecification().attributeName()).as(schemaType).isEqualTo("expirationDate"); + } + + @Test + public void updateTimeToLive_returnsDisabledSpecification_whenDisablingTtl() { + UpdateTimeToLiveEnhancedResponse enableResponse = mappedTable.updateTimeToLive(true); + assertThat(enableResponse.timeToLiveSpecification().enabled()).as(schemaType).isTrue(); + + UpdateTimeToLiveEnhancedResponse response = mappedTable.updateTimeToLive(false); + + assertThat(response.timeToLiveSpecification().enabled()).as(schemaType).isFalse(); + assertThat(response.timeToLiveSpecification().attributeName()).as(schemaType).isEqualTo("expirationDate"); + } + + @Test + public void updateTimeToLive_throwsException_whenTtlIsAlreadyEnabled() { + mappedTable.updateTimeToLive(true); + + assertThatThrownBy(() -> mappedTable.updateTimeToLive(true)) + .as(schemaType) + .hasMessageContaining("TimeToLive is already enabled"); + } + + @Test + public void updateTimeToLive_throwsException_whenTtlIsAlreadyDisabled() { + assertThatThrownBy(() -> mappedTable.updateTimeToLive(false)) + .as(schemaType) + .hasMessageContaining("TimeToLive is already disabled"); + } + + @Test + public void updateTimeToLive_throwsException_whenSchemaHasNoTtlMetadata() { + assertThatThrownBy(() -> enhancedClient.table(getConcreteTableName(TABLE_NAME), schemaWithoutTtl) + .updateTimeToLive(true)) + .as(schemaType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Custom TTL metadata object is null"); + } +} 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/TimeToLiveSchemaVariants.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/TimeToLiveSchemaVariants.java new file mode 100644 index 000000000000..d49e544422da --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/TimeToLiveSchemaVariants.java @@ -0,0 +1,104 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests; + +import static software.amazon.awssdk.enhanced.dynamodb.extensions.TimeToLiveExtension.AttributeTags.timeToLiveAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Collection; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordWithTTL; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordWithTTLImmutable; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticImmutableTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.SimpleBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.SimpleImmutable; + +final class TimeToLiveSchemaVariants { + private static final TableSchema STATIC_TTL_BEAN_SCHEMA = + StaticTableSchema.builder(RecordWithTTL.class) + .newItemSupplier(RecordWithTTL::new) + .addAttribute(String.class, a -> a.name("id") + .getter(RecordWithTTL::getId) + .setter(RecordWithTTL::setId) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("attribute") + .getter(RecordWithTTL::getAttribute) + .setter(RecordWithTTL::setAttribute)) + .addAttribute(Instant.class, a -> a.name("updatedDate") + .getter(RecordWithTTL::getUpdatedDate) + .setter(RecordWithTTL::setUpdatedDate)) + .addAttribute(Long.class, a -> a.name("expirationDate") + .getter(RecordWithTTL::getExpirationDate) + .setter(RecordWithTTL::setExpirationDate) + .tags(timeToLiveAttribute("updatedDate", 30, ChronoUnit.DAYS))) + .build(); + + private static final TableSchema STATIC_BEAN_SCHEMA_WITHOUT_TTL = + StaticTableSchema.builder(SimpleBean.class) + .newItemSupplier(SimpleBean::new) + .addAttribute(String.class, a -> a.name("id") + .getter(SimpleBean::getId) + .setter(SimpleBean::setId) + .tags(primaryPartitionKey())) + .addAttribute(Integer.class, a -> a.name("integerAttribute") + .getter(SimpleBean::getIntegerAttribute) + .setter(SimpleBean::setIntegerAttribute)) + .build(); + + private static final TableSchema STATIC_TTL_IMMUTABLE_SCHEMA = + StaticImmutableTableSchema.builder(RecordWithTTLImmutable.class, RecordWithTTLImmutable.Builder.class) + .newItemBuilder(RecordWithTTLImmutable::builder, + RecordWithTTLImmutable.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(RecordWithTTLImmutable::id) + .setter(RecordWithTTLImmutable.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(Long.class, a -> a.name("expirationDate") + .getter(RecordWithTTLImmutable::expirationDate) + .setter(RecordWithTTLImmutable.Builder::expirationDate) + .tags(timeToLiveAttribute("", 0, ChronoUnit.SECONDS))) + .build(); + + private static final TableSchema STATIC_IMMUTABLE_SCHEMA_WITHOUT_TTL = + StaticImmutableTableSchema.builder(SimpleImmutable.class, SimpleImmutable.Builder.class) + .newItemBuilder(SimpleImmutable::builder, SimpleImmutable.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(SimpleImmutable::id) + .setter(SimpleImmutable.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(Integer.class, a -> a.name("integerAttribute") + .getter(SimpleImmutable::integerAttribute) + .setter(SimpleImmutable.Builder::integerAttribute)) + .build(); + + private TimeToLiveSchemaVariants() { + } + + static Collection data() { + return Arrays.asList(new Object[][] { + {"@DynamoDbBean", TableSchema.fromBean(RecordWithTTL.class), TableSchema.fromBean(SimpleBean.class)}, + {"@DynamoDbImmutable", TableSchema.fromImmutableClass(RecordWithTTLImmutable.class), + TableSchema.fromImmutableClass(SimpleImmutable.class)}, + {"StaticTableSchema", STATIC_TTL_BEAN_SCHEMA, STATIC_BEAN_SCHEMA_WITHOUT_TTL}, + {"StaticImmutableTableSchema", STATIC_TTL_IMMUTABLE_SCHEMA, STATIC_IMMUTABLE_SCHEMA_WITHOUT_TTL} + }); + } +} + 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/functionaltests/models/RecordWithTTLImmutable.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithTTLImmutable.java new file mode 100644 index 000000000000..c5350c2ba482 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithTTLImmutable.java @@ -0,0 +1,64 @@ +/* + * 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.DynamoDbImmutable; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; + +@DynamoDbImmutable(builder = RecordWithTTLImmutable.Builder.class) +public class RecordWithTTLImmutable { + private final String id; + private final Long expirationDate; + + private RecordWithTTLImmutable(Builder b) { + this.id = b.id; + this.expirationDate = b.expirationDate; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + @DynamoDbTimeToLiveAttribute + public Long expirationDate() { + return expirationDate; + } + + public static final class Builder { + private String id; + private Long expirationDate; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder expirationDate(Long expirationDate) { + this.expirationDate = expirationDate; + return this; + } + + public RecordWithTTLImmutable build() { + return new RecordWithTTLImmutable(this); + } + } +} 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); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/DescribeTimeToLiveEnhancedResponseTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/DescribeTimeToLiveEnhancedResponseTest.java new file mode 100644 index 000000000000..1c452e904bf5 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/DescribeTimeToLiveEnhancedResponseTest.java @@ -0,0 +1,76 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import nl.jqno.equalsverifier.EqualsVerifier; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.dynamodb.model.DescribeTimeToLiveResponse; +import software.amazon.awssdk.services.dynamodb.model.TimeToLiveDescription; +import software.amazon.awssdk.services.dynamodb.model.TimeToLiveStatus; + +public class DescribeTimeToLiveEnhancedResponseTest { + @Test + public void builder_populatesTimeToLiveDescription() { + TimeToLiveDescription timeToLiveDescription = TimeToLiveDescription.builder() + .attributeName("expirationDate") + .timeToLiveStatus(TimeToLiveStatus.ENABLED) + .build(); + DescribeTimeToLiveResponse response = DescribeTimeToLiveResponse.builder() + .timeToLiveDescription(timeToLiveDescription) + .build(); + + DescribeTimeToLiveEnhancedResponse builtObject = DescribeTimeToLiveEnhancedResponse.builder() + .response(response) + .build(); + + assertThat(builtObject.timeToLiveDescription()).isEqualTo(timeToLiveDescription); + } + + @Test + public void responseNull_shouldThrowException() { + assertThatThrownBy(() -> DescribeTimeToLiveEnhancedResponse.builder().build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("response must not be null"); + } + + @Test + public void equalsHashcode() { + EqualsVerifier.forClass(DescribeTimeToLiveEnhancedResponse.class) + .withNonnullFields("response") + .verify(); + } + + @Test + public void toString_containsTimeToLiveDescription() { + DescribeTimeToLiveEnhancedResponse builtObject = DescribeTimeToLiveEnhancedResponse.builder() + .response(DescribeTimeToLiveResponse.builder() + .timeToLiveDescription(TimeToLiveDescription.builder() + .attributeName("expirationDate") + .timeToLiveStatus(TimeToLiveStatus.DISABLED) + .build()) + .build()) + .build(); + + assertThat(builtObject.toString()).contains("DescribeTimeToLiveEnhancedResponse") + .contains("timeToLiveDescription") + .contains("expirationDate") + .contains("DISABLED"); + } +} + diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateTimeToLiveEnhancedResponseTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateTimeToLiveEnhancedResponseTest.java new file mode 100644 index 000000000000..ef1990021af7 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateTimeToLiveEnhancedResponseTest.java @@ -0,0 +1,75 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import nl.jqno.equalsverifier.EqualsVerifier; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.dynamodb.model.TimeToLiveSpecification; +import software.amazon.awssdk.services.dynamodb.model.UpdateTimeToLiveResponse; + +public class UpdateTimeToLiveEnhancedResponseTest { + @Test + public void builder_populatesTimeToLiveSpecification() { + TimeToLiveSpecification timeToLiveSpecification = TimeToLiveSpecification.builder() + .attributeName("expirationDate") + .enabled(true) + .build(); + UpdateTimeToLiveResponse response = UpdateTimeToLiveResponse.builder() + .timeToLiveSpecification(timeToLiveSpecification) + .build(); + + UpdateTimeToLiveEnhancedResponse builtObject = UpdateTimeToLiveEnhancedResponse.builder() + .response(response) + .build(); + + assertThat(builtObject.timeToLiveSpecification()).isEqualTo(timeToLiveSpecification); + } + + @Test + public void responseNull_shouldThrowException() { + assertThatThrownBy(() -> UpdateTimeToLiveEnhancedResponse.builder().build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("response must not be null"); + } + + @Test + public void equalsHashcode() { + EqualsVerifier.forClass(UpdateTimeToLiveEnhancedResponse.class) + .withNonnullFields("response") + .verify(); + } + + @Test + public void toString_containsTimeToLiveSpecification() { + UpdateTimeToLiveEnhancedResponse builtObject = UpdateTimeToLiveEnhancedResponse.builder() + .response(UpdateTimeToLiveResponse.builder() + .timeToLiveSpecification(TimeToLiveSpecification.builder() + .attributeName("expirationDate") + .enabled(false) + .build()) + .build()) + .build(); + + assertThat(builtObject.toString()).contains("UpdateTimeToLiveEnhancedResponse") + .contains("timeToLiveSpecification") + .contains("expirationDate") + .contains("false"); + } +} +