Skip to content

Commit 24808df

Browse files
committed
updated tests, made computeTTLFromBase() method private, added javadoc for DynamoDbTimeToLiveAttribute
1 parent 8d2d0aa commit 24808df

File tree

3 files changed

+267
-33
lines changed

3 files changed

+267
-33
lines changed

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/TimeToLiveExtension.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex
8787
return WriteModification.builder().build();
8888
}
8989

90-
public static Long computeTTLFromBase(Object baseValue, long duration, TemporalUnit unit) {
90+
private static Long computeTTLFromBase(Object baseValue, long duration, TemporalUnit unit) {
9191
if (baseValue instanceof Instant) {
9292
return ((Instant) baseValue).plus(duration, unit).getEpochSecond();
9393
}

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbTimeToLiveAttribute.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,45 @@
2424
import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.TimeToLiveAttributeTags;
2525
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.BeanTableSchemaAttributeTag;
2626

27+
/**
28+
* Annotation used to mark an attribute in a DynamoDB-enhanced client model as a Time-To-Live (TTL) field.
29+
* <p>
30+
* This annotation allows automatic computation and assignment of a TTL value based on another field (the {@code baseField})
31+
* and a time offset defined by {@code duration} and {@code unit}. The TTL value is stored in epoch seconds and
32+
* can be configured to expire items from the table automatically.
33+
* <p>
34+
* To use this, the annotated method should return a {@link Long} value, which will be populated by the SDK at write time.
35+
* The {@code baseField} can be a temporal type such as {@link java.time.Instant}, {@link java.time.LocalDate},
36+
* {@link java.time.LocalDateTime}, etc., or a {@link Long} representing epoch seconds directly, serving as the reference point
37+
* for TTL calculation.
38+
*/
2739
@Target(ElementType.METHOD)
2840
@Retention(RetentionPolicy.RUNTIME)
2941
@BeanTableSchemaAttributeTag(TimeToLiveAttributeTags.class)
3042
@SdkPublicApi
3143
public @interface DynamoDbTimeToLiveAttribute {
3244

45+
/**
46+
* The name of the attribute whose value will serve as the base for TTL computation.
47+
* This can be a temporal type (e.g., {@link java.time.Instant}, {@link java.time.LocalDateTime})
48+
* or a {@link Long} representing epoch seconds.
49+
*
50+
* @return the attribute name to use as the base timestamp for TTL
51+
*/
3352
String baseField() default "";
3453

54+
/**
55+
* The amount of time to add to the {@code baseField} when computing the TTL value.
56+
* The resulting time will be converted to epoch seconds.
57+
*
58+
* @return the time offset to apply to the base field
59+
*/
3560
long duration() default 0;
3661

62+
/**
63+
* The time unit associated with the {@code duration}. Defaults to {@link ChronoUnit#SECONDS}.
64+
*
65+
* @return the time unit to use with the duration
66+
*/
3767
ChronoUnit unit() default ChronoUnit.SECONDS;
3868
}

services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/TimeToLiveExtensionTest.java

Lines changed: 236 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import static org.junit.Assert.assertNull;
66
import static org.junit.Assert.assertThrows;
77
import static org.junit.Assert.assertTrue;
8+
import static org.mockito.Mockito.mock;
9+
import static org.mockito.Mockito.when;
810

911
import java.time.Instant;
1012
import java.time.LocalDate;
@@ -15,13 +17,21 @@
1517
import java.time.temporal.ChronoUnit;
1618
import java.util.HashMap;
1719
import java.util.Map;
20+
import java.util.Optional;
1821
import org.junit.Test;
22+
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext;
1923
import software.amazon.awssdk.enhanced.dynamodb.OperationContext;
2024
import software.amazon.awssdk.enhanced.dynamodb.TableMetadata;
2125
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
2226
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordWithSimpleTTL;
2327
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordWithTTL;
2428
import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.InstantAsStringAttributeConverter;
29+
import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.LocalDateAttributeConverter;
30+
import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.LocalDateTimeAttributeConverter;
31+
import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.LocalTimeAttributeConverter;
32+
import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.LongAttributeConverter;
33+
import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.StringAttributeConverter;
34+
import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ZonedDateTimeAsStringAttributeConverter;
2535
import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext;
2636
import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DefaultOperationContext;
2737
import software.amazon.awssdk.enhanced.dynamodb.internal.operations.OperationName;
@@ -106,54 +116,248 @@ public void beforeWrite_skipsIfTtlNotPresentAndBaseFieldEmpty() {
106116
}
107117

108118
@Test
109-
public void computesTTL_fromInstant() {
110-
Instant now = Instant.now();
111-
long result = TimeToLiveExtension.computeTTLFromBase(now, 60, ChronoUnit.SECONDS);
112-
assertEquals(now.plusSeconds(60).getEpochSecond(), result);
113-
}
119+
public void beforeWrite_computesTtlFromLocalDate() {
120+
String ttlAttrName = "ttl";
121+
String baseFieldName = "createdAt";
122+
long duration = 1L;
123+
ChronoUnit unit = ChronoUnit.DAYS;
114124

115-
@Test
116-
public void computesTTL_fromLocalDate() {
117-
LocalDate date = LocalDate.of(2024, 1, 1);
118-
long expected = date.atStartOfDay(ZoneOffset.UTC).plusDays(1).toEpochSecond();
119-
long result = TimeToLiveExtension.computeTTLFromBase(date, 1, ChronoUnit.DAYS);
120-
assertEquals(expected, result);
125+
LocalDate baseTime = LocalDate.of(2024, 1, 1);
126+
long expectedTtl = baseTime.atStartOfDay(ZoneOffset.UTC).plus(duration, unit).toEpochSecond();
127+
128+
Map<String, AttributeValue> item = new HashMap<>();
129+
item.put(baseFieldName, LocalDateAttributeConverter.create().transformFrom(baseTime));
130+
131+
Map<String, Object> customMetadata = new HashMap<>();
132+
customMetadata.put("attributeName", ttlAttrName);
133+
customMetadata.put("baseField", baseFieldName);
134+
customMetadata.put("duration", duration);
135+
customMetadata.put("unit", unit);
136+
137+
TableMetadata tableMetadata = mock(TableMetadata.class);
138+
when(tableMetadata.customMetadataObject(TimeToLiveExtension.CUSTOM_METADATA_KEY, Map.class))
139+
.thenReturn(Optional.of(customMetadata));
140+
141+
TableSchema schema = mock(TableSchema.class);
142+
when(schema.converterForAttribute(baseFieldName)).thenReturn(LocalDateAttributeConverter.create());
143+
144+
DynamoDbExtensionContext.BeforeWrite context = mock(DynamoDbExtensionContext.BeforeWrite.class);
145+
when(context.items()).thenReturn(item);
146+
when(context.tableMetadata()).thenReturn(tableMetadata);
147+
when(context.tableSchema()).thenReturn(schema);
148+
149+
TimeToLiveExtension extension = TimeToLiveExtension.create();
150+
WriteModification result = extension.beforeWrite(context);
151+
152+
Map<String, AttributeValue> transformed = result.transformedItem();
153+
assertNotNull(transformed);
154+
assertTrue(transformed.containsKey(ttlAttrName));
155+
156+
long actualTtl = Long.parseLong(transformed.get(ttlAttrName).n());
157+
assertEquals(expectedTtl, actualTtl);
121158
}
122159

123160
@Test
124-
public void computesTTL_fromLocalDateTime() {
125-
LocalDateTime dt = LocalDateTime.of(2024, 1, 1, 10, 0);
126-
long expected = dt.plusHours(2).toEpochSecond(ZoneOffset.UTC);
127-
long result = TimeToLiveExtension.computeTTLFromBase(dt, 2, ChronoUnit.HOURS);
128-
assertEquals(expected, result);
161+
public void beforeWrite_computesTtlFromLocalDateTime() {
162+
String ttlAttrName = "ttl";
163+
String baseFieldName = "createdAt";
164+
long duration = 2L;
165+
ChronoUnit unit = ChronoUnit.HOURS;
166+
167+
LocalDateTime baseTime = LocalDateTime.of(2024, 1, 1, 10, 0);
168+
long expectedTtl = baseTime.plusHours(2).toEpochSecond(ZoneOffset.UTC);
169+
170+
Map<String, AttributeValue> item = new HashMap<>();
171+
item.put(baseFieldName, LocalDateTimeAttributeConverter.create().transformFrom(baseTime));
172+
173+
Map<String, Object> customMetadata = new HashMap<>();
174+
customMetadata.put("attributeName", ttlAttrName);
175+
customMetadata.put("baseField", baseFieldName);
176+
customMetadata.put("duration", duration);
177+
customMetadata.put("unit", unit);
178+
179+
TableMetadata tableMetadata = mock(TableMetadata.class);
180+
when(tableMetadata.customMetadataObject(TimeToLiveExtension.CUSTOM_METADATA_KEY, Map.class))
181+
.thenReturn(Optional.of(customMetadata));
182+
183+
TableSchema schema = mock(TableSchema.class);
184+
when(schema.converterForAttribute(baseFieldName)).thenReturn(LocalDateTimeAttributeConverter.create());
185+
186+
DynamoDbExtensionContext.BeforeWrite context = mock(DynamoDbExtensionContext.BeforeWrite.class);
187+
when(context.items()).thenReturn(item);
188+
when(context.tableMetadata()).thenReturn(tableMetadata);
189+
when(context.tableSchema()).thenReturn(schema);
190+
191+
TimeToLiveExtension extension = TimeToLiveExtension.create();
192+
WriteModification result = extension.beforeWrite(context);
193+
194+
Map<String, AttributeValue> transformed = result.transformedItem();
195+
assertNotNull(transformed);
196+
assertTrue(transformed.containsKey(ttlAttrName));
197+
198+
long actualTtl = Long.parseLong(transformed.get(ttlAttrName).n());
199+
assertEquals(expectedTtl, actualTtl);
129200
}
130201

131202
@Test
132-
public void computesTTL_fromLocalTime() {
133-
LocalTime time = LocalTime.of(10, 0);
134-
LocalDateTime expected = LocalDate.now().atTime(time).plusMinutes(30);
135-
long result = TimeToLiveExtension.computeTTLFromBase(time, 30, ChronoUnit.MINUTES);
136-
assertEquals(expected.toEpochSecond(ZoneOffset.UTC), result);
203+
public void beforeWrite_computesTtlFromLocalTime() {
204+
String ttlAttrName = "ttl";
205+
String baseFieldName = "createdAt";
206+
long duration = 30L;
207+
ChronoUnit unit = ChronoUnit.MINUTES;
208+
209+
LocalTime baseTime = LocalTime.of(10, 0);
210+
long expectedTtl = LocalDate.now().atTime(baseTime).plusMinutes(30).toEpochSecond(ZoneOffset.UTC);
211+
212+
Map<String, AttributeValue> item = new HashMap<>();
213+
item.put(baseFieldName, LocalTimeAttributeConverter.create().transformFrom(baseTime));
214+
215+
Map<String, Object> customMetadata = new HashMap<>();
216+
customMetadata.put("attributeName", ttlAttrName);
217+
customMetadata.put("baseField", baseFieldName);
218+
customMetadata.put("duration", duration);
219+
customMetadata.put("unit", unit);
220+
221+
TableMetadata tableMetadata = mock(TableMetadata.class);
222+
when(tableMetadata.customMetadataObject(TimeToLiveExtension.CUSTOM_METADATA_KEY, Map.class))
223+
.thenReturn(Optional.of(customMetadata));
224+
225+
TableSchema schema = mock(TableSchema.class);
226+
when(schema.converterForAttribute(baseFieldName)).thenReturn(LocalTimeAttributeConverter.create());
227+
228+
DynamoDbExtensionContext.BeforeWrite context = mock(DynamoDbExtensionContext.BeforeWrite.class);
229+
when(context.items()).thenReturn(item);
230+
when(context.tableMetadata()).thenReturn(tableMetadata);
231+
when(context.tableSchema()).thenReturn(schema);
232+
233+
TimeToLiveExtension extension = TimeToLiveExtension.create();
234+
WriteModification result = extension.beforeWrite(context);
235+
236+
Map<String, AttributeValue> transformed = result.transformedItem();
237+
assertNotNull(transformed);
238+
assertTrue(transformed.containsKey(ttlAttrName));
239+
240+
long actualTtl = Long.parseLong(transformed.get(ttlAttrName).n());
241+
assertEquals(expectedTtl, actualTtl);
137242
}
138243

139244
@Test
140-
public void computesTTL_fromZonedDateTime() {
141-
ZonedDateTime zdt = ZonedDateTime.now(ZoneOffset.UTC);
142-
long expected = zdt.plusMinutes(15).toEpochSecond();
143-
long result = TimeToLiveExtension.computeTTLFromBase(zdt, 15, ChronoUnit.MINUTES);
144-
assertEquals(expected, result);
245+
public void beforeWrite_computesTtlFromZonedDateTime() {
246+
String ttlAttrName = "ttl";
247+
String baseFieldName = "createdAt";
248+
long duration = 15L;
249+
ChronoUnit unit = ChronoUnit.MINUTES;
250+
251+
ZonedDateTime baseTime = ZonedDateTime.now(ZoneOffset.UTC);
252+
long expectedTtl = baseTime.plusMinutes(15).toEpochSecond();
253+
254+
Map<String, AttributeValue> item = new HashMap<>();
255+
item.put(baseFieldName, ZonedDateTimeAsStringAttributeConverter.create().transformFrom(baseTime));
256+
257+
Map<String, Object> customMetadata = new HashMap<>();
258+
customMetadata.put("attributeName", ttlAttrName);
259+
customMetadata.put("baseField", baseFieldName);
260+
customMetadata.put("duration", duration);
261+
customMetadata.put("unit", unit);
262+
263+
TableMetadata tableMetadata = mock(TableMetadata.class);
264+
when(tableMetadata.customMetadataObject(TimeToLiveExtension.CUSTOM_METADATA_KEY, Map.class))
265+
.thenReturn(Optional.of(customMetadata));
266+
267+
TableSchema schema = mock(TableSchema.class);
268+
when(schema.converterForAttribute(baseFieldName)).thenReturn(ZonedDateTimeAsStringAttributeConverter.create());
269+
270+
DynamoDbExtensionContext.BeforeWrite context = mock(DynamoDbExtensionContext.BeforeWrite.class);
271+
when(context.items()).thenReturn(item);
272+
when(context.tableMetadata()).thenReturn(tableMetadata);
273+
when(context.tableSchema()).thenReturn(schema);
274+
275+
TimeToLiveExtension extension = TimeToLiveExtension.create();
276+
WriteModification result = extension.beforeWrite(context);
277+
278+
Map<String, AttributeValue> transformed = result.transformedItem();
279+
assertNotNull(transformed);
280+
assertTrue(transformed.containsKey(ttlAttrName));
281+
282+
long actualTtl = Long.parseLong(transformed.get(ttlAttrName).n());
283+
assertEquals(expectedTtl, actualTtl);
145284
}
146285

147286
@Test
148-
public void computesTTL_fromLong() {
149-
long now = Instant.now().getEpochSecond();
150-
long result = TimeToLiveExtension.computeTTLFromBase(now, 120, ChronoUnit.SECONDS);
151-
assertEquals(now + 120, result);
287+
public void beforeWrite_computesTtlFromLong() {
288+
String ttlAttrName = "ttl";
289+
String baseFieldName = "createdAt";
290+
long duration = 120L;
291+
ChronoUnit unit = ChronoUnit.SECONDS;
292+
293+
Long baseTime = Instant.now().getEpochSecond();
294+
long expectedTtl = baseTime + 120;
295+
296+
Map<String, AttributeValue> item = new HashMap<>();
297+
item.put(baseFieldName, LongAttributeConverter.create().transformFrom(baseTime));
298+
299+
Map<String, Object> customMetadata = new HashMap<>();
300+
customMetadata.put("attributeName", ttlAttrName);
301+
customMetadata.put("baseField", baseFieldName);
302+
customMetadata.put("duration", duration);
303+
customMetadata.put("unit", unit);
304+
305+
TableMetadata tableMetadata = mock(TableMetadata.class);
306+
when(tableMetadata.customMetadataObject(TimeToLiveExtension.CUSTOM_METADATA_KEY, Map.class))
307+
.thenReturn(Optional.of(customMetadata));
308+
309+
TableSchema schema = mock(TableSchema.class);
310+
when(schema.converterForAttribute(baseFieldName)).thenReturn(LongAttributeConverter.create());
311+
312+
DynamoDbExtensionContext.BeforeWrite context = mock(DynamoDbExtensionContext.BeforeWrite.class);
313+
when(context.items()).thenReturn(item);
314+
when(context.tableMetadata()).thenReturn(tableMetadata);
315+
when(context.tableSchema()).thenReturn(schema);
316+
317+
TimeToLiveExtension extension = TimeToLiveExtension.create();
318+
WriteModification result = extension.beforeWrite(context);
319+
320+
Map<String, AttributeValue> transformed = result.transformedItem();
321+
assertNotNull(transformed);
322+
assertTrue(transformed.containsKey(ttlAttrName));
323+
324+
long actualTtl = Long.parseLong(transformed.get(ttlAttrName).n());
325+
assertEquals(expectedTtl, actualTtl);
152326
}
153327

154328
@Test
155-
public void throwsException_forUnsupportedType() {
329+
public void beforeWrite_computesTtlThrowsExceptionForUnsupportedType() {
330+
String ttlAttrName = "ttl";
331+
String baseFieldName = "createdAt";
332+
long duration = 60L;
333+
ChronoUnit unit = ChronoUnit.SECONDS;
334+
335+
String baseTime = "invalidType";
336+
337+
Map<String, AttributeValue> item = new HashMap<>();
338+
item.put(baseFieldName, StringAttributeConverter.create().transformFrom(baseTime));
339+
340+
Map<String, Object> customMetadata = new HashMap<>();
341+
customMetadata.put("attributeName", ttlAttrName);
342+
customMetadata.put("baseField", baseFieldName);
343+
customMetadata.put("duration", duration);
344+
customMetadata.put("unit", unit);
345+
346+
TableMetadata tableMetadata = mock(TableMetadata.class);
347+
when(tableMetadata.customMetadataObject(TimeToLiveExtension.CUSTOM_METADATA_KEY, Map.class))
348+
.thenReturn(Optional.of(customMetadata));
349+
350+
TableSchema schema = mock(TableSchema.class);
351+
when(schema.converterForAttribute(baseFieldName)).thenReturn(StringAttributeConverter.create());
352+
353+
DynamoDbExtensionContext.BeforeWrite context = mock(DynamoDbExtensionContext.BeforeWrite.class);
354+
when(context.items()).thenReturn(item);
355+
when(context.tableMetadata()).thenReturn(tableMetadata);
356+
when(context.tableSchema()).thenReturn(schema);
357+
358+
TimeToLiveExtension extension = TimeToLiveExtension.create();
359+
156360
assertThrows(IllegalArgumentException.class, () ->
157-
TimeToLiveExtension.computeTTLFromBase("invalid", 60, ChronoUnit.SECONDS));
361+
extension.beforeWrite(context));
158362
}
159363
}

0 commit comments

Comments
 (0)