From 6ac0c34482c88dbc0b462d5ec0799369f8774d37 Mon Sep 17 00:00:00 2001 From: Artur Kalimullin Date: Wed, 12 Aug 2026 08:55:16 +0200 Subject: [PATCH 1/2] Fix auditing immutable entities Signed-off-by: Artur Kalimullin --- .../mapping/event/AuditingEntityCallback.java | 5 +- .../auditing/CouchbaseAuditingRegistrar.java | 14 +-- .../event/AuditingEntityCallbackTests.java | 48 ++++++++++ .../domain/AuditedImmutableEntity.java | 64 +++++++++++++ .../AuditedImmutableEntityRepository.java | 23 +++++ .../data/couchbase/domain/AuditedRecord.java | 34 +++++++ .../domain/AuditedRecordRepository.java | 23 +++++ .../data/couchbase/domain/Config.java | 7 +- .../domain/time/AuditingDateTimeProvider.java | 7 ++ ...aseRepositoryKeyValueIntegrationTests.java | 90 +++++++++++++++++++ 10 files changed, 296 insertions(+), 19 deletions(-) create mode 100644 src/test/java/org/springframework/data/couchbase/core/mapping/event/AuditingEntityCallbackTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/domain/AuditedImmutableEntity.java create mode 100644 src/test/java/org/springframework/data/couchbase/domain/AuditedImmutableEntityRepository.java create mode 100644 src/test/java/org/springframework/data/couchbase/domain/AuditedRecord.java create mode 100644 src/test/java/org/springframework/data/couchbase/domain/AuditedRecordRepository.java diff --git a/src/main/java/org/springframework/data/couchbase/core/mapping/event/AuditingEntityCallback.java b/src/main/java/org/springframework/data/couchbase/core/mapping/event/AuditingEntityCallback.java index e2d7c0c62..58089b2e7 100644 --- a/src/main/java/org/springframework/data/couchbase/core/mapping/event/AuditingEntityCallback.java +++ b/src/main/java/org/springframework/data/couchbase/core/mapping/event/AuditingEntityCallback.java @@ -30,6 +30,7 @@ * {@link EntityCallback} to populate auditing related fields on an entity about to be saved. * * @author Jorge Rodríguez Martín + * @author Artur Kalimullin * @since 4.2 */ public class AuditingEntityCallback implements BeforeConvertCallback, AfterConvertCallback, Ordered { @@ -54,9 +55,7 @@ public AuditingEntityCallback(ObjectFactory auditingH */ @Override public Object onBeforeConvert(Object entity, String collection) { - // LOG.trace("onBeforeConvert " + entity); - return entity; // markAudited called in AuditingEventListener.onApplicationEvent() - // auditingHandlerFactory.getObject().markAudited(entity); + return auditingHandlerFactory.getObject().markAudited(entity); } /* diff --git a/src/main/java/org/springframework/data/couchbase/repository/auditing/CouchbaseAuditingRegistrar.java b/src/main/java/org/springframework/data/couchbase/repository/auditing/CouchbaseAuditingRegistrar.java index 1c37f6a51..442db7d52 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/auditing/CouchbaseAuditingRegistrar.java +++ b/src/main/java/org/springframework/data/couchbase/repository/auditing/CouchbaseAuditingRegistrar.java @@ -32,7 +32,6 @@ import org.springframework.data.couchbase.config.BeanNames; import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; import org.springframework.data.couchbase.core.mapping.event.AuditingEntityCallback; -import org.springframework.data.couchbase.core.mapping.event.AuditingEventListener; import org.springframework.util.Assert; /** @@ -44,6 +43,7 @@ * @author Simon Baslé * @author Michael Reiche * @author Jorge Rodríguez Martín + * @author Artur Kalimullin */ public class CouchbaseAuditingRegistrar extends AuditingBeanDefinitionRegistrarSupport { @@ -85,8 +85,6 @@ protected void registerAuditListenerBeanDefinition(BeanDefinition auditingHandle Assert.notNull(auditingHandlerDefinition, "BeanDefinition must not be null!"); Assert.notNull(registry, "BeanDefinitionRegistry must not be null!"); - // Register the AuditEntityCallback - BeanDefinitionBuilder listenerBeanDefinitionBuilder = BeanDefinitionBuilder .rootBeanDefinition(AuditingEntityCallback.class); listenerBeanDefinitionBuilder @@ -95,16 +93,6 @@ protected void registerAuditListenerBeanDefinition(BeanDefinition auditingHandle registerInfrastructureBeanWithId(listenerBeanDefinitionBuilder.getBeanDefinition(), AuditingEntityCallback.class.getName(), registry); - // Register the AuditingEventListener - - BeanDefinitionBuilder listenerBeanDefinitionBuilder2 = BeanDefinitionBuilder - .rootBeanDefinition(AuditingEventListener.class); - listenerBeanDefinitionBuilder2 - .addConstructorArgValue(ParsingUtils.getObjectFactoryBeanDefinition(getAuditingHandlerBeanName(), registry)); - - registerInfrastructureBeanWithId(listenerBeanDefinitionBuilder2.getBeanDefinition(), - AuditingEventListener.class.getName(), registry); - } private void ensureMappingContext(BeanDefinitionRegistry registry, Object source) { diff --git a/src/test/java/org/springframework/data/couchbase/core/mapping/event/AuditingEntityCallbackTests.java b/src/test/java/org/springframework/data/couchbase/core/mapping/event/AuditingEntityCallbackTests.java new file mode 100644 index 000000000..dbab47981 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/core/mapping/event/AuditingEntityCallbackTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.springframework.data.couchbase.core.mapping.event; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +import java.time.Instant; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.springframework.data.auditing.IsNewAwareAuditingHandler; +import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; +import org.springframework.data.couchbase.domain.AuditedRecord; + +/** + * @author Artur Kalimullin + */ +class AuditingEntityCallbackTests { + + @Test + void returnsAuditedImmutableEntity() { + Instant now = Instant.parse("2026-08-12T09:00:00Z"); + IsNewAwareAuditingHandler auditingHandler = IsNewAwareAuditingHandler.from(new CouchbaseMappingContext()); + auditingHandler.setDateTimeProvider(() -> Optional.of(now)); + + AuditedRecord original = new AuditedRecord("id", 0, null, null, "value"); + AuditingEntityCallback callback = new AuditingEntityCallback(() -> auditingHandler); + AuditedRecord audited = (AuditedRecord) callback.onBeforeConvert(original, "collection"); + + assertNotSame(original, audited); + assertEquals(now, audited.createdDate()); + assertEquals(now, audited.lastModifiedDate()); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/domain/AuditedImmutableEntity.java b/src/test/java/org/springframework/data/couchbase/domain/AuditedImmutableEntity.java new file mode 100644 index 000000000..10852ee18 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/domain/AuditedImmutableEntity.java @@ -0,0 +1,64 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.springframework.data.couchbase.domain; + +import java.time.Instant; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.Version; +import org.springframework.data.couchbase.core.mapping.Document; +import org.springframework.data.couchbase.core.mapping.id.GeneratedValue; +import org.springframework.data.couchbase.core.mapping.id.GenerationStrategy; + +/** + * @author Artur Kalimullin + */ +@Document +public class AuditedImmutableEntity { + + @Id + @GeneratedValue(strategy = GenerationStrategy.UNIQUE) private final String id; + @Version private final long version; + @CreatedDate private final Instant createdDate; + @LastModifiedDate private final Instant lastModifiedDate; + private final String value; + + public AuditedImmutableEntity(String id, long version, Instant createdDate, Instant lastModifiedDate, String value) { + this.id = id; + this.version = version; + this.createdDate = createdDate; + this.lastModifiedDate = lastModifiedDate; + this.value = value; + } + + public String getId() { + return id; + } + + public long getVersion() { + return version; + } + + public Instant getCreatedDate() { + return createdDate; + } + + public Instant getLastModifiedDate() { + return lastModifiedDate; + } +} diff --git a/src/test/java/org/springframework/data/couchbase/domain/AuditedImmutableEntityRepository.java b/src/test/java/org/springframework/data/couchbase/domain/AuditedImmutableEntityRepository.java new file mode 100644 index 000000000..b822a28d1 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/domain/AuditedImmutableEntityRepository.java @@ -0,0 +1,23 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.springframework.data.couchbase.domain; + +import org.springframework.data.repository.CrudRepository; + +/** + * @author Artur Kalimullin + */ +public interface AuditedImmutableEntityRepository extends CrudRepository {} diff --git a/src/test/java/org/springframework/data/couchbase/domain/AuditedRecord.java b/src/test/java/org/springframework/data/couchbase/domain/AuditedRecord.java new file mode 100644 index 000000000..5d09adaf9 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/domain/AuditedRecord.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.springframework.data.couchbase.domain; + +import java.time.Instant; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.Version; +import org.springframework.data.couchbase.core.mapping.Document; +import org.springframework.data.couchbase.core.mapping.id.GeneratedValue; +import org.springframework.data.couchbase.core.mapping.id.GenerationStrategy; + +/** + * @author Artur Kalimullin + */ +@Document +public record AuditedRecord(@Id @GeneratedValue(strategy = GenerationStrategy.UNIQUE) String id, @Version long version, + @CreatedDate Instant createdDate, @LastModifiedDate Instant lastModifiedDate, String value) { +} diff --git a/src/test/java/org/springframework/data/couchbase/domain/AuditedRecordRepository.java b/src/test/java/org/springframework/data/couchbase/domain/AuditedRecordRepository.java new file mode 100644 index 000000000..2ca598f29 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/domain/AuditedRecordRepository.java @@ -0,0 +1,23 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.springframework.data.couchbase.domain; + +import org.springframework.data.repository.CrudRepository; + +/** + * @author Artur Kalimullin + */ +public interface AuditedRecordRepository extends CrudRepository {} diff --git a/src/test/java/org/springframework/data/couchbase/domain/Config.java b/src/test/java/org/springframework/data/couchbase/domain/Config.java index 69e77a763..481e37b2d 100644 --- a/src/test/java/org/springframework/data/couchbase/domain/Config.java +++ b/src/test/java/org/springframework/data/couchbase/domain/Config.java @@ -21,7 +21,6 @@ import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.data.auditing.DateTimeProvider; import org.springframework.data.couchbase.CouchbaseClientFactory; import org.springframework.data.couchbase.SimpleCouchbaseClientFactory; import org.springframework.data.couchbase.cache.CouchbaseCacheManager; @@ -51,6 +50,7 @@ * @author Michael Nitschinger * @author Michael Reiche * @author Jorge Rodriguez Martin + * @author Artur Kalimullin * @since 3.0 */ @Configuration @@ -128,7 +128,7 @@ public ReactiveNaiveAuditorAware testReactiveAuditorAware() { } @Bean(name = "dateTimeProviderRef") - public DateTimeProvider testDateTimeProvider() { + public AuditingDateTimeProvider testDateTimeProvider() { return new AuditingDateTimeProvider(); } @@ -211,7 +211,8 @@ public MappingCouchbaseConverter mappingCouchbaseConverter(CouchbaseMappingConte // that has an getAliasFor(info) that just returns getType().getName(). // Our CustomMappingCouchbaseConverter uses a TypeBasedCouchbaseTypeMapper that will // use the DocumentType annotation - MappingCouchbaseConverter converter = new CustomMappingCouchbaseConverter(couchbaseMappingContext, typeKey(), couchbaseCustomConversions); + MappingCouchbaseConverter converter = new CustomMappingCouchbaseConverter(couchbaseMappingContext, typeKey(), + couchbaseCustomConversions); return converter; } diff --git a/src/test/java/org/springframework/data/couchbase/domain/time/AuditingDateTimeProvider.java b/src/test/java/org/springframework/data/couchbase/domain/time/AuditingDateTimeProvider.java index 2fdc2c42a..d8cf796d8 100644 --- a/src/test/java/org/springframework/data/couchbase/domain/time/AuditingDateTimeProvider.java +++ b/src/test/java/org/springframework/data/couchbase/domain/time/AuditingDateTimeProvider.java @@ -21,6 +21,9 @@ import org.springframework.data.auditing.DateTimeProvider; +/** + * @author Artur Kalimullin + */ public class AuditingDateTimeProvider implements DateTimeProvider { private DateTimeService dateTimeService = new FixedDateTimeService(); @@ -31,6 +34,10 @@ public AuditingDateTimeProvider(DateTimeService dateTimeService) { this.dateTimeService = dateTimeService; } + public void setDateTimeService(DateTimeService dateTimeService) { + this.dateTimeService = dateTimeService; + } + @Override public Optional getNow() { return Optional.of(Instant.ofEpochSecond(dateTimeService.getCurrentDateAndTime().toEpochSecond())); diff --git a/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryKeyValueIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryKeyValueIntegrationTests.java index 466e5c329..2eca377da 100644 --- a/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryKeyValueIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryKeyValueIntegrationTests.java @@ -20,10 +20,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.lang.reflect.InvocationTargetException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -38,6 +42,10 @@ import org.springframework.data.couchbase.core.CouchbaseTemplate; import org.springframework.data.couchbase.domain.Airline; import org.springframework.data.couchbase.domain.AirlineRepository; +import org.springframework.data.couchbase.domain.AuditedImmutableEntity; +import org.springframework.data.couchbase.domain.AuditedImmutableEntityRepository; +import org.springframework.data.couchbase.domain.AuditedRecord; +import org.springframework.data.couchbase.domain.AuditedRecordRepository; import org.springframework.data.couchbase.domain.BigAirline; import org.springframework.data.couchbase.domain.Config; import org.springframework.data.couchbase.domain.Course; @@ -52,6 +60,8 @@ import org.springframework.data.couchbase.domain.UserRepository; import org.springframework.data.couchbase.domain.UserSubmission; import org.springframework.data.couchbase.domain.UserSubmissionRepository; +import org.springframework.data.couchbase.domain.time.AuditingDateTimeProvider; +import org.springframework.data.couchbase.domain.time.FixedDateTimeService; import org.springframework.data.couchbase.util.ClusterAwareIntegrationTests; import org.springframework.data.couchbase.util.ClusterType; import org.springframework.data.couchbase.util.IgnoreWhen; @@ -65,6 +75,7 @@ * * @author Michael Nitschinger * @author Michael Reiche + * @author Artur Kalimullin */ @SpringJUnitConfig(Config.class) @DirtiesContext @@ -76,8 +87,11 @@ public class CouchbaseRepositoryKeyValueIntegrationTests extends ClusterAwareInt @Autowired SubscriptionTokenRepository subscriptionTokenRepository; @Autowired UserSubmissionRepository userSubmissionRepository; @Autowired AirlineRepository airlineRepository; + @Autowired AuditedImmutableEntityRepository auditedImmutableEntityRepository; + @Autowired AuditedRecordRepository auditedRecordRepository; @Autowired PersonValueRepository personValueRepository; @Autowired CouchbaseTemplate couchbaseTemplate; + @Autowired AuditingDateTimeProvider auditingDateTimeProvider; @BeforeEach public void beforeEach() { @@ -160,6 +174,82 @@ void saveAndFindImmutableById() throws NoSuchMethodException, InvocationTargetEx personValueRepository.delete(personValue); } + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void saveAuditedRecord() { + Instant createdAt = Instant.parse("2026-08-12T09:00:00Z"); + Instant modifiedAt = Instant.parse("2026-08-12T09:01:00Z"); + AuditedRecord saved = null; + setAuditingTime(createdAt); + + try { + saved = auditedRecordRepository.save(new AuditedRecord(null, 0, null, null, "value")); + AuditedRecord found = auditedRecordRepository.findById(saved.id()).orElseThrow(); + + assertNotNull(found.id()); + assertNotEquals(0, found.version()); + assertEquals(createdAt, found.createdDate()); + assertEquals(createdAt, found.lastModifiedDate()); + + setAuditingTime(modifiedAt); + auditedRecordRepository.save(new AuditedRecord(found.id(), found.version(), found.createdDate(), + found.lastModifiedDate(), "updated value")); + + AuditedRecord updated = auditedRecordRepository.findById(saved.id()).orElseThrow(); + assertNotNull(updated.createdDate()); + assertNotNull(updated.lastModifiedDate()); + assertEquals(found.createdDate(), updated.createdDate()); + assertEquals(modifiedAt, updated.lastModifiedDate()); + assertNotEquals(updated.createdDate(), updated.lastModifiedDate()); + } finally { + resetAuditingTime(); + if (saved != null) { + auditedRecordRepository.deleteById(saved.id()); + } + } + } + + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void saveAuditedImmutableEntity() { + Instant createdAt = Instant.parse("2026-08-12T09:00:00Z"); + Instant modifiedAt = Instant.parse("2026-08-12T09:01:00Z"); + AuditedImmutableEntity saved = null; + setAuditingTime(createdAt); + + try { + saved = auditedImmutableEntityRepository.save(new AuditedImmutableEntity(null, 0, null, null, "value")); + AuditedImmutableEntity found = auditedImmutableEntityRepository.findById(saved.getId()).orElseThrow(); + + assertEquals(createdAt, found.getCreatedDate()); + assertEquals(createdAt, found.getLastModifiedDate()); + + setAuditingTime(modifiedAt); + auditedImmutableEntityRepository.save(new AuditedImmutableEntity(found.getId(), found.getVersion(), + found.getCreatedDate(), found.getLastModifiedDate(), "updated value")); + + AuditedImmutableEntity updated = auditedImmutableEntityRepository.findById(saved.getId()).orElseThrow(); + assertNotNull(updated.getCreatedDate()); + assertNotNull(updated.getLastModifiedDate()); + assertEquals(found.getCreatedDate(), updated.getCreatedDate()); + assertEquals(modifiedAt, updated.getLastModifiedDate()); + assertNotEquals(updated.getCreatedDate(), updated.getLastModifiedDate()); + } finally { + resetAuditingTime(); + if (saved != null) { + auditedImmutableEntityRepository.deleteById(saved.getId()); + } + } + } + + private void setAuditingTime(Instant time) { + auditingDateTimeProvider.setDateTimeService(() -> ZonedDateTime.ofInstant(time, ZoneOffset.UTC)); + } + + private void resetAuditingTime() { + auditingDateTimeProvider.setDateTimeService(new FixedDateTimeService()); + } + @Test // DATACOUCH-564 @IgnoreWhen(clusterTypes = ClusterType.MOCKED) void saveAndFindByIdWithList() { From beeb5760692594b174cb7eddeff976c36d5f2083 Mon Sep 17 00:00:00 2001 From: Artur Kalimullin Date: Thu, 13 Aug 2026 14:49:12 +0200 Subject: [PATCH 2/2] Return audited immutable entities from save Signed-off-by: Artur Kalimullin --- .../core/AbstractTemplateSupport.java | 15 ++++++----- .../core/CouchbaseTemplateSupport.java | 8 +++--- .../core/NonReactiveSupportWrapper.java | 7 ++--- .../ReactiveCouchbaseTemplateSupport.java | 8 +++--- .../ReactiveInsertByIdOperationSupport.java | 5 ++-- .../ReactiveMutateInByIdOperationSupport.java | 3 ++- .../ReactiveReplaceByIdOperationSupport.java | 5 ++-- .../core/ReactiveTemplateSupport.java | 5 ++-- .../ReactiveUpsertByIdOperationSupport.java | 3 ++- .../data/couchbase/core/TemplateSupport.java | 4 +-- .../core/mapping/CouchbaseDocument.java | 26 ++++++++++++++++++ .../ReactiveAuditedRecordRepository.java | 23 ++++++++++++++++ ...aseRepositoryKeyValueIntegrationTests.java | 27 +++++++++++++++++++ 13 files changed, 114 insertions(+), 25 deletions(-) create mode 100644 src/test/java/org/springframework/data/couchbase/domain/ReactiveAuditedRecordRepository.java diff --git a/src/main/java/org/springframework/data/couchbase/core/AbstractTemplateSupport.java b/src/main/java/org/springframework/data/couchbase/core/AbstractTemplateSupport.java index 235368da1..e899013e1 100644 --- a/src/main/java/org/springframework/data/couchbase/core/AbstractTemplateSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/AbstractTemplateSupport.java @@ -48,6 +48,7 @@ * * @author Michael Reiche * @author Emilien Bevierre + * @author Artur Kalimullin */ @Stability.Internal public abstract class AbstractTemplateSupport { @@ -191,16 +192,18 @@ CouchbasePersistentEntity couldBePersistentEntity(Class entityClass) { return null; } - public T applyResultBase(T entity, CouchbaseDocument converted, Object id, long cas, - Object txResultHolder, CouchbaseResourceHolder holder) { - ConvertingPropertyAccessor accessor = getPropertyAccessor(entity); + @SuppressWarnings("unchecked") + public T applyResultBase(CouchbaseDocument converted, long cas, Object txResultHolder, + CouchbaseResourceHolder holder) { + Object entityToWrite = converted.getEntityToWrite(); + ConvertingPropertyAccessor accessor = getPropertyAccessor(entityToWrite); CouchbasePersistentEntity persistentEntity = converter.getMappingContext() - .getRequiredPersistentEntity(entity.getClass()); + .getRequiredPersistentEntity(entityToWrite.getClass()); - CouchbasePersistentProperty idProperty = persistentEntity.getIdProperty(); + CouchbasePersistentProperty idProperty = persistentEntity.getIdProperty(); if (idProperty != null) { - accessor.setProperty(idProperty, id); + accessor.setProperty(idProperty, converted.getId()); } CouchbasePersistentProperty versionProperty = persistentEntity.getVersionProperty(); diff --git a/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplateSupport.java b/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplateSupport.java index d6971083c..96055c7a0 100644 --- a/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplateSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplateSupport.java @@ -40,6 +40,7 @@ * @author Jorge Rodriguez Martin * @author Carlos Espinaco * @author Emilien Bevierre + * @author Artur Kalimullin * @since 3.0 */ class CouchbaseTemplateSupport extends AbstractTemplateSupport implements ApplicationContextAware, TemplateSupport { @@ -59,6 +60,7 @@ public CouchbaseDocument encodeEntity(final Object entityToEncode) { Object maybeNewEntity = maybeCallBeforeConvert(entityToEncode, ""); final CouchbaseDocument converted = new CouchbaseDocument(); converter.write(maybeNewEntity, converted); + converted.setEntityToWrite(maybeNewEntity); maybeCallAfterConvert(entityToEncode, converted, ""); maybeEmitEvent(new BeforeSaveEvent<>(entityToEncode, converted)); return converted; @@ -77,9 +79,9 @@ public T decodeEntity(Object id, byte[] source, Long cas, Instant expiryTime } @Override - public T applyResult(T entity, CouchbaseDocument converted, Object id, long cas, - Object txResultHolder, CouchbaseResourceHolder holder) { - return applyResultBase(entity, converted, id, cas, txResultHolder, holder); + public T applyResult(CouchbaseDocument converted, long cas, Object txResultHolder, + CouchbaseResourceHolder holder) { + return applyResultBase(converted, cas, txResultHolder, holder); } @Override diff --git a/src/main/java/org/springframework/data/couchbase/core/NonReactiveSupportWrapper.java b/src/main/java/org/springframework/data/couchbase/core/NonReactiveSupportWrapper.java index 8ab1687c4..c4482f5e1 100644 --- a/src/main/java/org/springframework/data/couchbase/core/NonReactiveSupportWrapper.java +++ b/src/main/java/org/springframework/data/couchbase/core/NonReactiveSupportWrapper.java @@ -29,6 +29,7 @@ * @author Carlos Espinaco * @author Michael Reiche * @author Emilien Bevierre + * @author Artur Kalimullin * @since 4.2 */ public class NonReactiveSupportWrapper implements ReactiveTemplateSupport { @@ -59,9 +60,9 @@ public Mono decodeEntity(Object id, byte[] source, Long cas, Instant expi } @Override - public Mono applyResult(T entity, CouchbaseDocument converted, Object id, Long cas, - Object txResultHolder, CouchbaseResourceHolder holder) { - return Mono.fromSupplier(() -> support.applyResult(entity, converted, id, cas, txResultHolder, holder)); + public Mono applyResult(CouchbaseDocument converted, Long cas, Object txResultHolder, + CouchbaseResourceHolder holder) { + return Mono.fromSupplier(() -> support.applyResult(converted, cas, txResultHolder, holder)); } diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplateSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplateSupport.java index 4acc91075..fe43c028d 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplateSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplateSupport.java @@ -41,6 +41,7 @@ * @author Carlos Espinaco * @author Michael Reiche * @author Emilien Bevierre + * @author Artur Kalimullin * @since 4.2 */ class ReactiveCouchbaseTemplateSupport extends AbstractTemplateSupport @@ -61,6 +62,7 @@ public Mono encodeEntity(final Object entityToEncode) { .flatMap(entity -> maybeCallBeforeConvert(entity, "")).map(maybeNewEntity -> { final CouchbaseDocument converted = new CouchbaseDocument(); converter.write(maybeNewEntity, converted); + converted.setEntityToWrite(maybeNewEntity); return converted; }).flatMap(converted -> maybeCallAfterConvert(entityToEncode, converted, "").thenReturn(converted)) .doOnNext(converted -> maybeEmitEvent(new BeforeSaveEvent<>(entityToEncode, converted))); @@ -88,9 +90,9 @@ public Mono decodeEntity(Object id, byte[] source, Long cas, Instant expi } @Override - public Mono applyResult(T entity, CouchbaseDocument converted, Object id, Long cas, - Object txResultHolder, CouchbaseResourceHolder holder) { - return Mono.fromSupplier(() -> applyResultBase(entity, converted, id, cas, txResultHolder, holder)); + public Mono applyResult(CouchbaseDocument converted, Long cas, Object txResultHolder, + CouchbaseResourceHolder holder) { + return Mono.fromSupplier(() -> this.applyResultBase(converted, cas, txResultHolder, holder)); } @Override diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveInsertByIdOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveInsertByIdOperationSupport.java index 4c5841bcd..641a31b90 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveInsertByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveInsertByIdOperationSupport.java @@ -46,6 +46,7 @@ * @author Michael Reiche * @author Tigran Babloyan * @author Emilien Bevierre + * @author Artur Kalimullin */ public class ReactiveInsertByIdOperationSupport implements ReactiveInsertByIdOperation { @@ -107,7 +108,7 @@ public Mono one(T object) { return collection.reactive() .insert(converted.getId().toString(), converted.export(), buildOptions(pArgs.getOptions(), converted)) - .flatMap(result -> this.support.applyResult(object, converted, converted.getId(), result.cas(), + .flatMap(result -> this.support.applyResult(converted, result.cas(), null, null)); } else { rejectInvalidTransactionalOptions(); @@ -120,7 +121,7 @@ public Mono one(T object) { template.getCouchbaseClientFactory().getCluster().environment().transcoder() .encode(converted.export()).encoded(), new SpanWrapper(span)) - .flatMap(result -> this.support.applyResult(object, converted, converted.getId(), result.cas(), + .flatMap(result -> this.support.applyResult(converted, result.cas(), null, null)); } })).onErrorMap(throwable -> { diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveMutateInByIdOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveMutateInByIdOperationSupport.java index 83f32d93f..980ffd85c 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveMutateInByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveMutateInByIdOperationSupport.java @@ -44,6 +44,7 @@ * {@link ReactiveMutateInByIdOperation} implementations for Couchbase. * * @author Tigran Babloyan + * @author Artur Kalimullin */ public class ReactiveMutateInByIdOperationSupport implements ReactiveMutateInByIdOperation { @@ -119,7 +120,7 @@ public Mono one(T object) { .flatMap(collection -> collection.reactive() .mutateIn(converted.getId().toString(), getMutations(converted), buildMutateInOptions(pArgs.getOptions(), object, converted)) .flatMap( - result -> support.applyResult(object, converted, converted.getId(), result.cas(), null, null))); + result -> support.applyResult(converted, result.cas(), null, null))); }); return reactiveEntity.onErrorMap(throwable -> { diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveReplaceByIdOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveReplaceByIdOperationSupport.java index 5b9d973c1..aaa618d63 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveReplaceByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveReplaceByIdOperationSupport.java @@ -49,6 +49,7 @@ * @author Michael Reiche * @author Tigran Babloyan * @author Emilien Bevierre + * @author Artur Kalimullin */ public class ReactiveReplaceByIdOperationSupport implements ReactiveReplaceByIdOperation { @@ -110,7 +111,7 @@ public Mono one(T object) { return collection.reactive() .replace(converted.getId().toString(), converted.export(), buildReplaceOptions(pArgs.getOptions(), object, converted)) - .flatMap(result -> support.applyResult(object, converted, converted.getId(), result.cas(), null, + .flatMap(result -> support.applyResult(converted, result.cas(), null, null)); } else { rejectInvalidTransactionalOptions(); @@ -137,7 +138,7 @@ public Mono one(T object) { return ctx.replace(getResult, template.getCouchbaseClientFactory().getCluster().environment() .transcoder().encode(converted.export()).encoded(), new SpanWrapper(span)); }).flatMap( - result -> support.applyResult(object, converted, converted.getId(), result.cas(), null, null)); + result -> support.applyResult(converted, result.cas(), null, null)); } })).onErrorMap(throwable -> { if (throwable instanceof RuntimeException) { diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveTemplateSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveTemplateSupport.java index f5955b17c..88824b28e 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveTemplateSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveTemplateSupport.java @@ -29,6 +29,7 @@ * * @author Michael Reiche * @author Emilien Bevierre + * @author Artur Kalimullin */ public interface ReactiveTemplateSupport { @@ -43,8 +44,8 @@ default Mono decodeEntity(Object id, byte[] source, Long cas, Instant exp collection, txResultHolder, holder); } - Mono applyResult(T entity, CouchbaseDocument converted, Object id, Long cas, - Object txResultHolder, CouchbaseResourceHolder holder); + Mono applyResult(CouchbaseDocument converted, Long cas, Object txResultHolder, + CouchbaseResourceHolder holder); Long getCas(Object entity); diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveUpsertByIdOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveUpsertByIdOperationSupport.java index 684c85411..4499603fa 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveUpsertByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveUpsertByIdOperationSupport.java @@ -38,6 +38,7 @@ * * @author Michael Reiche * @author Tigran Babloyan + * @author Artur Kalimullin */ public class ReactiveUpsertByIdOperationSupport implements ReactiveUpsertByIdOperation { @@ -99,7 +100,7 @@ public Mono one(T object) { .flatMap(collection -> collection.reactive() .upsert(converted.getId().toString(), converted.export(), buildUpsertOptions(pArgs.getOptions(), converted)) .flatMap( - result -> support.applyResult(object, converted, converted.getId(), result.cas(), null, null))); + result -> support.applyResult(converted, result.cas(), null, null))); }); return reactiveEntity.onErrorMap(throwable -> { diff --git a/src/main/java/org/springframework/data/couchbase/core/TemplateSupport.java b/src/main/java/org/springframework/data/couchbase/core/TemplateSupport.java index 449168c14..4624a42ea 100644 --- a/src/main/java/org/springframework/data/couchbase/core/TemplateSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/TemplateSupport.java @@ -25,6 +25,7 @@ /** * @author Michael Reiche * @author Emilien Bevierre + * @author Artur Kalimullin */ public interface TemplateSupport { @@ -40,8 +41,7 @@ default T decodeEntity(Object id, byte[] source, Long cas, Instant expiryTim collection, txResultHolder, holder); } - T applyResult(T entity, CouchbaseDocument converted, Object id, long cas, Object txResultHolder, - CouchbaseResourceHolder holder); + T applyResult(CouchbaseDocument converted, long cas, Object txResultHolder, CouchbaseResourceHolder holder); Long getCas(Object entity); diff --git a/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseDocument.java b/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseDocument.java index b44952e8d..c4fd83492 100644 --- a/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseDocument.java +++ b/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseDocument.java @@ -33,6 +33,7 @@ * topmost document most likely has an ID. * * @author Michael Nitschinger + * @author Artur Kalimullin */ public class CouchbaseDocument implements CouchbaseStorable { @@ -56,6 +57,11 @@ public class CouchbaseDocument implements CouchbaseStorable { */ private int expiration; + /** + * Contains the entity encoded into this document. + */ + private Object entityToWrite; + /** * Creates a completely empty {@link CouchbaseDocument}. */ @@ -260,6 +266,26 @@ public CouchbaseDocument setId(Object id) { return this; } + /** + * Returns the entity encoded into this document. + * + * @return the entity encoded into this document. + */ + public Object getEntityToWrite() { + return entityToWrite; + } + + /** + * Sets the entity encoded into this document. + * + * @param entityToWrite the entity encoded into this document. + * @return this document. + */ + public CouchbaseDocument setEntityToWrite(Object entityToWrite) { + this.entityToWrite = entityToWrite; + return this; + } + /** * Verifies that only values of a certain and supported type can be stored. *

diff --git a/src/test/java/org/springframework/data/couchbase/domain/ReactiveAuditedRecordRepository.java b/src/test/java/org/springframework/data/couchbase/domain/ReactiveAuditedRecordRepository.java new file mode 100644 index 000000000..f30533a95 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/domain/ReactiveAuditedRecordRepository.java @@ -0,0 +1,23 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.springframework.data.couchbase.domain; + +import org.springframework.data.repository.reactive.ReactiveCrudRepository; + +/** + * @author Artur Kalimullin + */ +public interface ReactiveAuditedRecordRepository extends ReactiveCrudRepository {} diff --git a/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryKeyValueIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryKeyValueIntegrationTests.java index 2eca377da..72cfce4ed 100644 --- a/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryKeyValueIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryKeyValueIntegrationTests.java @@ -53,6 +53,7 @@ import org.springframework.data.couchbase.domain.LibraryRepository; import org.springframework.data.couchbase.domain.PersonValue; import org.springframework.data.couchbase.domain.PersonValueRepository; +import org.springframework.data.couchbase.domain.ReactiveAuditedRecordRepository; import org.springframework.data.couchbase.domain.Submission; import org.springframework.data.couchbase.domain.SubscriptionToken; import org.springframework.data.couchbase.domain.SubscriptionTokenRepository; @@ -89,6 +90,7 @@ public class CouchbaseRepositoryKeyValueIntegrationTests extends ClusterAwareInt @Autowired AirlineRepository airlineRepository; @Autowired AuditedImmutableEntityRepository auditedImmutableEntityRepository; @Autowired AuditedRecordRepository auditedRecordRepository; + @Autowired ReactiveAuditedRecordRepository reactiveAuditedRecordRepository; @Autowired PersonValueRepository personValueRepository; @Autowired CouchbaseTemplate couchbaseTemplate; @Autowired AuditingDateTimeProvider auditingDateTimeProvider; @@ -188,6 +190,8 @@ void saveAuditedRecord() { assertNotNull(found.id()); assertNotEquals(0, found.version()); + assertEquals(createdAt, saved.createdDate()); + assertEquals(createdAt, saved.lastModifiedDate()); assertEquals(createdAt, found.createdDate()); assertEquals(createdAt, found.lastModifiedDate()); @@ -221,6 +225,8 @@ void saveAuditedImmutableEntity() { saved = auditedImmutableEntityRepository.save(new AuditedImmutableEntity(null, 0, null, null, "value")); AuditedImmutableEntity found = auditedImmutableEntityRepository.findById(saved.getId()).orElseThrow(); + assertEquals(createdAt, saved.getCreatedDate()); + assertEquals(createdAt, saved.getLastModifiedDate()); assertEquals(createdAt, found.getCreatedDate()); assertEquals(createdAt, found.getLastModifiedDate()); @@ -242,6 +248,27 @@ void saveAuditedImmutableEntity() { } } + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void reactiveSaveAuditedRecord() { + Instant createdAt = Instant.parse("2026-08-12T09:00:00Z"); + AuditedRecord saved = null; + setAuditingTime(createdAt); + + try { + saved = reactiveAuditedRecordRepository.save(new AuditedRecord(null, 0, null, null, "value")).block(); + + assertNotNull(saved); + assertEquals(createdAt, saved.createdDate()); + assertEquals(createdAt, saved.lastModifiedDate()); + } finally { + resetAuditingTime(); + if (saved != null) { + reactiveAuditedRecordRepository.deleteById(saved.id()).block(); + } + } + } + private void setAuditingTime(Instant time) { auditingDateTimeProvider.setDateTimeService(() -> ZonedDateTime.ofInstant(time, ZoneOffset.UTC)); }