diff --git a/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-381e9b3.json b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-381e9b3.json new file mode 100644 index 000000000000..a20d7d973a69 --- /dev/null +++ b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-381e9b3.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Amazon DynamoDB Enhanced Client", + "contributor": "", + "description": "Added support for DynamoDbAutoGeneratedTimestampAttribute annotation in nested objects." +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java index 2ac27d918202..6ef66335a3af 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java @@ -20,8 +20,11 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Consumer; +import java.util.stream.Collectors; import software.amazon.awssdk.annotations.NotThreadSafe; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.annotations.ThreadSafe; @@ -30,6 +33,7 @@ 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.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @@ -126,28 +130,70 @@ public static AutoGeneratedTimestampRecordExtension create() { */ @Override public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) { + Collection customMetadataObject = context.tableMetadata() + .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null); - Collection customMetadataObject = context.tableMetadata() - .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null); + Map itemToTransform = new HashMap<>(context.items()); + if (customMetadataObject != null) { + customMetadataObject.forEach( + key -> insertTimestampInItemToTransform(itemToTransform, key.toString(), + context.tableSchema().converterForAttribute(key))); + } + itemToTransform.forEach((key, value) -> { + if (value.hasM() && value.m() != null) { + Optional> nestedSchema = getNestedSchema(context.tableSchema(), key); + if (nestedSchema != null && nestedSchema.isPresent()) { + itemToTransform.put(key, AttributeValue.builder().m(processNestedObject(value.m(), nestedSchema.get())).build()); + } + } + }); - if (customMetadataObject == null) { + if (itemToTransform.isEmpty()) { return WriteModification.builder().build(); } - Map itemToTransform = new HashMap<>(context.items()); - customMetadataObject.forEach( - key -> insertTimestampInItemToTransform(itemToTransform, key, - context.tableSchema().converterForAttribute(key))); return WriteModification.builder() .transformedItem(Collections.unmodifiableMap(itemToTransform)) .build(); } + private Map processNestedObject(Map nestedMap, TableSchema nestedSchema) { + Map updatedNestedMap = new HashMap<>(nestedMap); + Collection customMetadataObject = nestedSchema.tableMetadata() + .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null); + for (Map.Entry entry : nestedMap.entrySet()) { + String nestedKey = entry.getKey(); + AttributeValue nestedValue = entry.getValue(); + + if (nestedValue.hasM()) { + updatedNestedMap.put(nestedKey, + AttributeValue.builder().m(processNestedObject(nestedValue.m(), nestedSchema)).build()); + } else if (nestedValue.hasL()) { + List updatedList = nestedValue.l().stream() + .map(listItem -> listItem.hasM() ? + AttributeValue.builder().m(processNestedObject(listItem.m(), nestedSchema)).build() : listItem) + .collect(Collectors.toList()); + updatedNestedMap.put(nestedKey, AttributeValue.builder().l(updatedList).build()); + } else { + AttributeConverter converter = nestedSchema.converterForAttribute(nestedKey); + if (converter != null && customMetadataObject != null && customMetadataObject.contains(nestedKey)) { + insertTimestampInItemToTransform(updatedNestedMap, nestedKey, converter); + } + } + } + return updatedNestedMap; + } + + private void insertTimestampInItemToTransform(Map itemToTransform, String key, AttributeConverter converter) { itemToTransform.put(key, converter.transformFrom(clock.instant())); } + private Optional> getNestedSchema(TableSchema parentSchema, String attributeName) { + return parentSchema.converterForAttribute(attributeName).type().tableSchema(); + } + /** * Builder for a {@link AutoGeneratedTimestampRecordExtension} */ diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java index 196d38282277..27bc23408651 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java @@ -3,8 +3,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.time.Clock; +import java.time.Duration; import java.time.Instant; +import java.time.ZoneOffset; import java.util.Collections; +import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; import org.junit.After; @@ -31,22 +35,25 @@ public class UpdateBehaviorTest extends LocalDynamoDbSyncTestBase { private static final Instant FAR_FUTURE_INSTANT = Instant.parse("9999-05-03T10:05:00Z"); private static final String TEST_BEHAVIOUR_ATTRIBUTE = "testBehaviourAttribute"; private static final String TEST_ATTRIBUTE = "testAttribute"; + private static final Duration tolerance = Duration.ofSeconds(2); + public static final Instant MOCKED_INSTANT_NOW = Instant.now(Clock.fixed(Instant.parse("2025-01-13T14:00:00Z"), + ZoneOffset.UTC)); private static final TableSchema TABLE_SCHEMA = - TableSchema.fromClass(RecordWithUpdateBehaviors.class); - + TableSchema.fromClass(RecordWithUpdateBehaviors.class); + private static final TableSchema TABLE_SCHEMA_FLATTEN_RECORD = TableSchema.fromClass(FlattenRecord.class); private final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(getDynamoDbClient()).extensions( + .dynamoDbClient(getDynamoDbClient()).extensions( Stream.concat(ExtensionResolver.defaultExtensions().stream(), Stream.of(AutoGeneratedTimestampRecordExtension.create())).collect(Collectors.toList())) - .build(); + .build(); private final DynamoDbTable mappedTable = - enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA); - + enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + private final DynamoDbTable flattenedMappedTable = enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA_FLATTEN_RECORD); @@ -63,10 +70,16 @@ public void deleteTable() { @Test public void updateBehaviors_firstUpdate() { Instant currentTime = Instant.now(); + NestedRecordWithUpdateBehavior nestedRecord = new NestedRecordWithUpdateBehavior(); + nestedRecord.setId("id123"); + nestedRecord.setNestedTimeAttribute(INSTANT_1); + nestedRecord.setNestedUpdateBehaviorAttribute(TEST_BEHAVIOUR_ATTRIBUTE); + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); record.setId("id123"); record.setCreatedOn(INSTANT_1); record.setLastUpdatedOn(INSTANT_2); + record.setNestedRecord(nestedRecord); mappedTable.updateItem(record); RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(record); @@ -81,28 +94,55 @@ public void updateBehaviors_firstUpdate() { assertThat(persistedRecord.getLastAutoUpdatedOnMillis().getEpochSecond()).isGreaterThanOrEqualTo(currentTime.getEpochSecond()); assertThat(persistedRecord.getCreatedAutoUpdateOn()).isAfterOrEqualTo(currentTime); + assertThat(persistedRecord.getNestedRecord().getNestedTimeAttribute()).isAfterOrEqualTo(currentTime); + assertThat(persistedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isEqualTo(TEST_BEHAVIOUR_ATTRIBUTE); } + @Test public void updateBehaviors_secondUpdate() { Instant beforeUpdateInstant = Instant.now(); + + NestedRecordWithUpdateBehavior secondNestedRecord = new NestedRecordWithUpdateBehavior(); + secondNestedRecord.setId("id123"); + secondNestedRecord.setNestedUpdateBehaviorAttribute(TEST_BEHAVIOUR_ATTRIBUTE); + + NestedRecordWithUpdateBehavior nestedRecord = new NestedRecordWithUpdateBehavior(); + nestedRecord.setId("id123"); + nestedRecord.setNestedUpdateBehaviorAttribute(TEST_BEHAVIOUR_ATTRIBUTE); + nestedRecord.setNestedRecord(secondNestedRecord); + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); record.setId("id123"); record.setCreatedOn(INSTANT_1); record.setLastUpdatedOn(INSTANT_2); + record.setNestedRecord(nestedRecord); + mappedTable.updateItem(record); + RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(record); assertThat(persistedRecord.getVersion()).isEqualTo(1L); + Instant firstUpdatedTime = persistedRecord.getLastAutoUpdatedOn(); Instant createdAutoUpdateOn = persistedRecord.getCreatedAutoUpdateOn(); + Instant nestedTimestamp = persistedRecord.getNestedRecord().getNestedTimeAttribute(); + Instant secondNestedTimestamp = persistedRecord.getNestedRecord().getNestedRecord().getNestedTimeAttribute(); + assertThat(firstUpdatedTime).isAfterOrEqualTo(beforeUpdateInstant); assertThat(persistedRecord.getFormattedLastAutoUpdatedOn().getEpochSecond()) .isGreaterThanOrEqualTo(beforeUpdateInstant.getEpochSecond()); + assertThat(persistedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isNotNull(); + assertThat(persistedRecord.getNestedRecord().getNestedTimeAttribute().getEpochSecond()) + .isGreaterThanOrEqualTo(beforeUpdateInstant.getEpochSecond()); + assertThat(persistedRecord.getNestedRecord().getNestedRecord().getNestedTimeAttribute().getEpochSecond()) + .isGreaterThanOrEqualTo(beforeUpdateInstant.getEpochSecond()); + record.setVersion(1L); record.setCreatedOn(INSTANT_2); record.setLastUpdatedOn(INSTANT_2); + record.setNestedRecord(nestedRecord); mappedTable.updateItem(record); persistedRecord = mappedTable.getItem(record); @@ -113,7 +153,13 @@ public void updateBehaviors_secondUpdate() { Instant secondUpdatedTime = persistedRecord.getLastAutoUpdatedOn(); assertThat(secondUpdatedTime).isAfterOrEqualTo(firstUpdatedTime); assertThat(persistedRecord.getCreatedAutoUpdateOn()).isEqualTo(createdAutoUpdateOn); - } + + Instant nestedFirstUpdatedTime = persistedRecord.getNestedRecord().getNestedTimeAttribute(); + assertThat(nestedFirstUpdatedTime).isAfterOrEqualTo(nestedTimestamp); + + Instant secondNestedFirstUpdatedTime = persistedRecord.getNestedRecord().getNestedRecord().getNestedTimeAttribute(); + assertThat(secondNestedFirstUpdatedTime).isAfterOrEqualTo(secondNestedTimestamp); + }; @Test public void updateBehaviors_removal() { @@ -187,7 +233,7 @@ public void when_updatingNestedObjectWithSingleLevel_existingInformationIsPreser RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, - TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); + TEST_BEHAVIOUR_ATTRIBUTE, Instant.now()); } @Test @@ -214,7 +260,7 @@ public void when_updatingNestedObjectWithSingleLevel_default_mode_update_newMapC RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); - verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, null); + verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, Instant.now()); } @Test @@ -241,7 +287,7 @@ public void when_updatingNestedObjectWithSingleLevel_with_no_mode_update_newMapC RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); - verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, null); + verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, Instant.now()); } @Test @@ -266,7 +312,7 @@ public void when_updatingNestedObjectToEmptyWithSingleLevel_existingInformationI mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); - assertThat(persistedRecord.getNestedRecord()).isNull(); + assertThat(persistedRecord.getNestedRecord()).isNotNull(); } private NestedRecordWithUpdateBehavior createNestedWithDefaults(String id, Long counter) { @@ -292,16 +338,18 @@ private void verifyMultipleLevelNestingTargetedUpdateBehavior(NestedRecordWithUp assertThat(nestedRecord.getNestedRecord().getNestedCounter()).isEqualTo(updatedInnerNestedCounter); assertThat(nestedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isEqualTo( test_behav_attribute); - assertThat(nestedRecord.getNestedRecord().getNestedTimeAttribute()).isEqualTo(expected_time); + assertThat(areInstantsAlmostEqual(nestedRecord.getNestedTimeAttribute(), expected_time, tolerance)).isTrue(); } private void verifySingleLevelNestingTargetedUpdateBehavior(NestedRecordWithUpdateBehavior nestedRecord, - long updatedNestedCounter, String expected_behav_attr, + long updatedNestedCounter, String expected_behav_attr, Instant expected_time) { assertThat(nestedRecord).isNotNull(); assertThat(nestedRecord.getNestedCounter()).isEqualTo(updatedNestedCounter); assertThat(nestedRecord.getNestedUpdateBehaviorAttribute()).isEqualTo(expected_behav_attr); - assertThat(nestedRecord.getNestedTimeAttribute()).isEqualTo(expected_time); + assertThat(nestedRecord.getNestedTimeAttribute()).isNotNull(); + assertThat(areInstantsAlmostEqual(nestedRecord.getNestedTimeAttribute(), expected_time, + tolerance)).isTrue(); } @Test @@ -337,7 +385,7 @@ public void when_updatingNestedObjectWithMultipleLevels_inScalarOnlyMode_existin RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); verifyMultipleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), outerNestedCounter, - innerNestedCounter, TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); + innerNestedCounter, TEST_BEHAVIOUR_ATTRIBUTE, Instant.now()); } @Test @@ -368,7 +416,7 @@ public void when_updatingNestedObjectWithMultipleLevels_inMapsOnlyMode_existingI RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); verifyMultipleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), outerNestedCounter, - 50L, TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); + 50L, TEST_BEHAVIOUR_ATTRIBUTE, Instant.now()); } @Test @@ -403,8 +451,9 @@ public void when_updatingNestedObjectWithMultipleLevels_default_mode_existingInf RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); - verifyMultipleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), outerNestedCounter, innerNestedCounter, null, - null); + verifyMultipleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), outerNestedCounter, + innerNestedCounter, null, + Instant.now()); } @Test @@ -449,7 +498,7 @@ public void when_updatingNestedMap_mapsOnlyMode_newMapIsCreatedAndStored() { mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.MAPS_ONLY)); verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), 5L, TEST_BEHAVIOUR_ATTRIBUTE, - INSTANT_1); + Instant.now()); assertThat(persistedRecord.getNestedRecord().getAttribute()).isEqualTo(TEST_ATTRIBUTE); } @@ -470,15 +519,16 @@ public void when_emptyNestedRecordIsSet_emptyMapIsStoredInTable() { .build()); assertThat(getItemResponse.item().get("nestedRecord")).isNotNull(); - assertThat(getItemResponse.item().get("nestedRecord").toString()).isEqualTo("AttributeValue(M={nestedTimeAttribute" - + "=AttributeValue(NUL=true), " - + "nestedRecord=AttributeValue(NUL=true), " - + "attribute=AttributeValue(NUL=true), " - + "id=AttributeValue(NUL=true), " - + "nestedUpdateBehaviorAttribute=AttributeValue" - + "(NUL=true), nestedCounter=AttributeValue" - + "(NUL=true), nestedVersionedAttribute" - + "=AttributeValue(NUL=true)})"); + + Map item = getItemResponse.item().get("nestedRecord").m(); + Instant nestedTime = Instant.parse(item.get("nestedTimeAttribute").s()); + assertThat(areInstantsAlmostEqual(nestedTime, Instant.now(), tolerance)).isTrue(); + assertThat(item.get("nestedUpdateBehaviorAttribute").s()).isNull(); + assertThat(item.get("nestedCounter").n()).isNull(); + assertThat(item.get("nestedVersionedAttribute").n()).isNull(); + assertThat(item.get("nestedRecord").m()).isEmpty(); + assertThat(item.get("attribute").s()).isNull(); + assertThat(item.get("id").s()).isNull(); } @@ -493,15 +543,15 @@ public void when_updatingNestedObjectWithSingleLevelFlattened_existingInformatio FlattenRecord flattenRecord = new FlattenRecord(); flattenRecord.setCompositeRecord(compositeRecord); flattenRecord.setId("id456"); - + flattenedMappedTable.putItem(r -> r.item(flattenRecord)); - + NestedRecordWithUpdateBehavior updateNestedRecord = new NestedRecordWithUpdateBehavior(); updateNestedRecord.setNestedCounter(100L); - + CompositeRecord updateCompositeRecord = new CompositeRecord(); updateCompositeRecord.setNestedRecord(updateNestedRecord); - + FlattenRecord updatedFlattenRecord = new FlattenRecord(); updatedFlattenRecord.setId("id456"); updatedFlattenRecord.setCompositeRecord(updateCompositeRecord); @@ -511,11 +561,11 @@ public void when_updatingNestedObjectWithSingleLevelFlattened_existingInformatio assertThat(persistedFlattenedRecord.getCompositeRecord()).isNotNull(); verifySingleLevelNestingTargetedUpdateBehavior(persistedFlattenedRecord.getCompositeRecord().getNestedRecord(), 100L, - TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); + TEST_BEHAVIOUR_ATTRIBUTE, Instant.now()); } - + @Test public void when_updatingNestedObjectWithMultipleLevelFlattened_existingInformationIsPreserved_scalar_only_update() { @@ -529,30 +579,30 @@ public void when_updatingNestedObjectWithMultipleLevelFlattened_existingInformat FlattenRecord flattenRecord = new FlattenRecord(); flattenRecord.setCompositeRecord(compositeRecord); flattenRecord.setId("id789"); - + flattenedMappedTable.putItem(r -> r.item(flattenRecord)); - + NestedRecordWithUpdateBehavior updateOuterNestedRecord = new NestedRecordWithUpdateBehavior(); updateOuterNestedRecord.setNestedCounter(100L); - + NestedRecordWithUpdateBehavior updateInnerNestedRecord = new NestedRecordWithUpdateBehavior(); updateInnerNestedRecord.setNestedCounter(50L); - + updateOuterNestedRecord.setNestedRecord(updateInnerNestedRecord); - + CompositeRecord updateCompositeRecord = new CompositeRecord(); updateCompositeRecord.setNestedRecord(updateOuterNestedRecord); - + FlattenRecord updateFlattenRecord = new FlattenRecord(); updateFlattenRecord.setCompositeRecord(updateCompositeRecord); updateFlattenRecord.setId("id789"); - + FlattenRecord persistedFlattenedRecord = flattenedMappedTable.updateItem(r -> r.item(updateFlattenRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); - + assertThat(persistedFlattenedRecord.getCompositeRecord()).isNotNull(); verifyMultipleLevelNestingTargetedUpdateBehavior(persistedFlattenedRecord.getCompositeRecord().getNestedRecord(), 100L, - 50L, TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); + 50L, TEST_BEHAVIOUR_ATTRIBUTE, Instant.now()); assertThat(persistedFlattenedRecord.getCompositeRecord().getNestedRecord().getNestedCounter()).isEqualTo(100L); assertThat(persistedFlattenedRecord.getCompositeRecord().getNestedRecord().getNestedRecord().getNestedCounter()).isEqualTo(50L); } @@ -579,6 +629,12 @@ public void updateBehaviors_nested() { assertThat(persistedRecord.getNestedRecord().getNestedVersionedAttribute()).isNull(); assertThat(persistedRecord.getNestedRecord().getNestedCounter()).isNull(); assertThat(persistedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isNull(); - assertThat(persistedRecord.getNestedRecord().getNestedTimeAttribute()).isNull(); + assertThat(areInstantsAlmostEqual(persistedRecord.getNestedRecord().getNestedTimeAttribute(), Instant.now(), + tolerance)).isTrue(); + } + + public static boolean areInstantsAlmostEqual(Instant instant1, Instant instant2, Duration tolerance) { + Duration difference = Duration.between(instant1, instant2).abs(); + return difference.compareTo(tolerance) <= 0; } }