diff --git a/pom.xml b/pom.xml index 0d3301d94..70d34facc 100644 --- a/pom.xml +++ b/pom.xml @@ -18,8 +18,8 @@ - 3.3.0 - 3.3.0 + 3.3.2-SNAPSHOT + 3.3.2-SNAPSHOT 3.0.0-SNAPSHOT spring.data.couchbase 2.10.13 @@ -286,6 +286,7 @@ org.apache.maven.plugins maven-failsafe-plugin + false **/*IntegrationTest.java **/*IntegrationTests.java diff --git a/src/main/java/com/couchbase/client/java/transactions/AttemptContextReactiveAccessor.java b/src/main/java/com/couchbase/client/java/transactions/AttemptContextReactiveAccessor.java new file mode 100644 index 000000000..a200f963a --- /dev/null +++ b/src/main/java/com/couchbase/client/java/transactions/AttemptContextReactiveAccessor.java @@ -0,0 +1,34 @@ +/* +/* + * Copyright 2021-2022 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 com.couchbase.client.java.transactions; + +import com.couchbase.client.core.annotation.Stability; +import com.couchbase.client.core.transaction.CoreTransactionAttemptContext; +import com.couchbase.client.java.codec.JsonSerializer; + +/** + * To access the ReactiveTransactionAttemptContext held by TransactionAttemptContext + * + * @author Michael Reiche + */ +@Stability.Internal +public class AttemptContextReactiveAccessor { + public static ReactiveTransactionAttemptContext createReactiveTransactionAttemptContext( + CoreTransactionAttemptContext core, JsonSerializer jsonSerializer) { + return new ReactiveTransactionAttemptContext(core, jsonSerializer); + } +} diff --git a/src/main/java/org/springframework/data/couchbase/SimpleCouchbaseClientFactory.java b/src/main/java/org/springframework/data/couchbase/SimpleCouchbaseClientFactory.java index b97b57f95..10d9cb143 100644 --- a/src/main/java/org/springframework/data/couchbase/SimpleCouchbaseClientFactory.java +++ b/src/main/java/org/springframework/data/couchbase/SimpleCouchbaseClientFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors + * Copyright 2012-2022 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. @@ -97,9 +97,11 @@ public Scope getScope() { @Override public Collection getCollection(final String collectionName) { final Scope scope = getScope(); - if (collectionName == null) { - if (!scope.name().equals(CollectionIdentifier.DEFAULT_SCOPE)) { - throw new IllegalStateException("A collectionName must be provided if a non-default scope is used!"); + if (collectionName == null || CollectionIdentifier.DEFAULT_COLLECTION.equals(collectionName)) { + if(scope != null ) { + if (scope.name() != null && !CollectionIdentifier.DEFAULT_SCOPE.equals(scope.name())) { + throw new IllegalStateException("A collectionName must be provided if a non-default scope is used"); + } } return getBucket().defaultCollection(); } diff --git a/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java b/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java index 6982d64f4..7145f8701 100644 --- a/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java +++ b/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java @@ -26,6 +26,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; import org.springframework.core.type.filter.AnnotationTypeFilter; import org.springframework.data.convert.CustomConversions; import org.springframework.data.couchbase.CouchbaseClientFactory; @@ -40,9 +41,16 @@ import org.springframework.data.couchbase.core.mapping.Document; import org.springframework.data.couchbase.repository.config.ReactiveRepositoryOperationsMapping; import org.springframework.data.couchbase.repository.config.RepositoryOperationsMapping; +import org.springframework.data.couchbase.transaction.CouchbaseCallbackTransactionManager; +import org.springframework.data.couchbase.transaction.CouchbaseTransactionInterceptor; +import org.springframework.data.couchbase.transaction.CouchbaseTransactionalOperator; import org.springframework.data.mapping.model.CamelCaseAbbreviatingFieldNamingStrategy; import org.springframework.data.mapping.model.FieldNamingStrategy; import org.springframework.data.mapping.model.PropertyNameFieldNamingStrategy; +import org.springframework.transaction.TransactionManager; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; @@ -123,7 +131,7 @@ protected Authenticator authenticator() { * @param couchbaseCluster the cluster reference from the SDK. * @return the initialized factory. */ - @Bean + @Bean(name = BeanNames.COUCHBASE_CLIENT_FACTORY) public CouchbaseClientFactory couchbaseClientFactory(final Cluster couchbaseCluster) { return new SimpleCouchbaseClientFactory(couchbaseCluster, getBucketName(), getScopeName()); } @@ -280,9 +288,8 @@ public TranslationService couchbaseTranslationService() { /** * Creates a {@link CouchbaseMappingContext} equipped with entity classes scanned from the mapping base package. - * */ - @Bean + @Bean(BeanNames.COUCHBASE_MAPPING_CONTEXT) public CouchbaseMappingContext couchbaseMappingContext(CustomConversions customConversions) throws Exception { CouchbaseMappingContext mappingContext = new CouchbaseMappingContext(); mappingContext.setInitialEntitySet(getInitialEntitySet()); @@ -310,6 +317,44 @@ public ObjectMapper couchbaseObjectMapper() { return mapper; } + /** + * The default blocking transaction manager. It is an implementation of CallbackPreferringTransactionManager + * CallbackPreferrringTransactionmanagers do not play well with test-cases that rely + * on @TestTransaction/@BeforeTransaction/@AfterTransaction + * + * @param clientFactory + * @return + */ + @Bean(BeanNames.COUCHBASE_TRANSACTION_MANAGER) + CouchbaseCallbackTransactionManager couchbaseTransactionManager(CouchbaseClientFactory clientFactory) { + return new CouchbaseCallbackTransactionManager(clientFactory); + } + + /** + * The default TransactionalOperator. + * + * @param couchbaseCallbackTransactionManager + * @return + */ + @Bean(BeanNames.COUCHBASE_TRANSACTIONAL_OPERATOR) + public CouchbaseTransactionalOperator couchbaseTransactionalOperator( + CouchbaseCallbackTransactionManager couchbaseCallbackTransactionManager) { + return CouchbaseTransactionalOperator.create(couchbaseCallbackTransactionManager); + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public TransactionInterceptor transactionInterceptor(TransactionManager couchbaseTransactionManager) { + TransactionAttributeSource transactionAttributeSource = new AnnotationTransactionAttributeSource(); + TransactionInterceptor interceptor = new CouchbaseTransactionInterceptor(couchbaseTransactionManager, + transactionAttributeSource); + interceptor.setTransactionAttributeSource(transactionAttributeSource); + if (couchbaseTransactionManager != null) { + interceptor.setTransactionManager(couchbaseTransactionManager); + } + return interceptor; + } + /** * Configure whether to automatically create indices for domain types by deriving the from the entity or not. */ @@ -375,5 +420,4 @@ private boolean nonShadowedJacksonPresent() { public QueryScanConsistency getDefaultConsistency() { return null; } - } diff --git a/src/main/java/org/springframework/data/couchbase/config/BeanNames.java b/src/main/java/org/springframework/data/couchbase/config/BeanNames.java index cb9bf63ea..d5b68d01f 100644 --- a/src/main/java/org/springframework/data/couchbase/config/BeanNames.java +++ b/src/main/java/org/springframework/data/couchbase/config/BeanNames.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors + * Copyright 2012-2022 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. @@ -59,4 +59,10 @@ public class BeanNames { * The name for the bean that will handle reactive audit trail marking of entities. */ public static final String REACTIVE_COUCHBASE_AUDITING_HANDLER = "reactiveCouchbaseAuditingHandler"; + + public static final String COUCHBASE_CLIENT_FACTORY = "couchbaseClientFactory"; + + public static final String COUCHBASE_TRANSACTION_MANAGER = "couchbaseTransactionManager"; + + public static final String COUCHBASE_TRANSACTIONAL_OPERATOR = "couchbaseTransactionalOperator"; } diff --git a/src/main/java/org/springframework/data/couchbase/core/AbstractTemplateSupport.java b/src/main/java/org/springframework/data/couchbase/core/AbstractTemplateSupport.java new file mode 100644 index 000000000..64371dade --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/AbstractTemplateSupport.java @@ -0,0 +1,234 @@ +/* + * Copyright 2022 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; + +import java.lang.reflect.InaccessibleObjectException; +import java.util.Map; +import java.util.Set; + +import com.couchbase.client.core.annotation.Stability; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.data.couchbase.core.convert.CouchbaseConverter; +import org.springframework.data.couchbase.core.convert.join.N1qlJoinResolver; +import org.springframework.data.couchbase.core.convert.translation.TranslationService; +import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentEntity; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.couchbase.core.mapping.event.AfterSaveEvent; +import org.springframework.data.couchbase.core.mapping.event.CouchbaseMappingEvent; +import org.springframework.data.couchbase.core.support.TemplateUtils; +import org.springframework.data.couchbase.repository.support.MappingCouchbaseEntityInformation; +import org.springframework.data.couchbase.transaction.CouchbaseResourceHolder; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mapping.model.ConvertingPropertyAccessor; +import org.springframework.util.ClassUtils; + +import com.couchbase.client.core.error.CouchbaseException; + + +/** + * Base shared by Reactive and non-Reactive TemplateSupport + * + * @author Michael Reiche + */ +@Stability.Internal +public abstract class AbstractTemplateSupport { + + final ReactiveCouchbaseTemplate template; + final CouchbaseConverter converter; + final MappingContext, CouchbasePersistentProperty> mappingContext; + final TranslationService translationService; + ApplicationContext applicationContext; + static final Logger LOG = LoggerFactory.getLogger(AbstractTemplateSupport.class); + + public AbstractTemplateSupport(ReactiveCouchbaseTemplate template, CouchbaseConverter converter, + TranslationService translationService) { + this.template = template; + this.converter = converter; + this.mappingContext = converter.getMappingContext(); + this.translationService = translationService; + } + + abstract ReactiveCouchbaseTemplate getReactiveTemplate(); + + public T decodeEntityBase(String id, String source, Long cas, Class entityClass, String scope, + String collection, Object txResultHolder, CouchbaseResourceHolder holder) { + + // this is the entity class defined for the repository. It may not be the class of the document that was read + // we will reset it after reading the document + // + // This will fail for the case where: + // 1) The version is defined in the concrete class, but not in the abstract class; and + // 2) The constructor takes a "long version" argument resulting in an exception would be thrown if version in + // the source is null. + // We could expose from the MappingCouchbaseConverter determining the persistent entity from the source, + // but that is a lot of work to do every time just for this very rare and avoidable case. + // TypeInformation typeToUse = typeMapper.readType(source, type); + + CouchbasePersistentEntity persistentEntity = couldBePersistentEntity(entityClass); + + if (persistentEntity == null) { // method could return a Long, Boolean, String etc. + // QueryExecutionConverters.unwrapWrapperTypes will recursively unwrap until there is nothing left + // to unwrap. This results in List being unwrapped past String[] to String, so this may also be a + // Collection (or Array) of entityClass. We have no way of knowing - so just assume it is what we are told. + // if this is a Collection or array, only the first element will be returned. + final CouchbaseDocument converted = new CouchbaseDocument(id); + Set> set = ((CouchbaseDocument) translationService.decode(source, converted)) + .getContent().entrySet(); + return (T) set.iterator().next().getValue(); + } + + if (id == null) { + throw new CouchbaseException(TemplateUtils.SELECT_ID + " was null. Either use #{#n1ql.selectEntity} or project " + + TemplateUtils.SELECT_ID); + } + + final CouchbaseDocument converted = new CouchbaseDocument(id); + + // if possible, set the version property in the source so that if the constructor has a long version argument, + // it will have a value and not fail (as null is not a valid argument for a long argument). This possible failure + // can be avoid by defining the argument as Long instead of long. + // persistentEntity is still the (possibly abstract) class specified in the repository definition + // it's possible that the abstract class does not have a version property, and this won't be able to set the version + if (persistentEntity.getVersionProperty() != null) { + if (cas == null) { + throw new CouchbaseException("version/cas in the entity but " + TemplateUtils.SELECT_CAS + + " was not in result. Either use #{#n1ql.selectEntity} or project " + TemplateUtils.SELECT_CAS); + } + if (cas != 0) { + converted.put(persistentEntity.getVersionProperty().getName(), cas); + } + } + + // if the constructor has an argument that is long version, then construction will fail if the 'version' + // is not available as 'null' is not a legal value for a long. Changing the arg to "Long version" would solve this. + // (Version doesn't come from 'source', it comes from the cas argument to decodeEntity) + T readEntity = converter.read(entityClass, (CouchbaseDocument) translationService.decode(source, converted)); + final ConvertingPropertyAccessor accessor = getPropertyAccessor(readEntity); + + persistentEntity = couldBePersistentEntity(readEntity.getClass()); + + if (cas != null && cas != 0 && persistentEntity.getVersionProperty() != null) { + accessor.setProperty(persistentEntity.getVersionProperty(), cas); + } + N1qlJoinResolver.handleProperties(persistentEntity, accessor, getReactiveTemplate(), id, scope, collection); + + if (holder != null) { + holder.transactionResultHolder(txResultHolder, (T) accessor.getBean()); + } + + return accessor.getBean(); + } + + CouchbasePersistentEntity couldBePersistentEntity(Class entityClass) { + if (ClassUtils.isPrimitiveOrWrapper(entityClass) || entityClass == String.class) { + return null; + } + try { + return mappingContext.getPersistentEntity(entityClass); + } catch (InaccessibleObjectException t) { + + } + return null; + } + + public T applyResultBase(T entity, CouchbaseDocument converted, Object id, long cas, + Object txResultHolder, CouchbaseResourceHolder holder) { + ConvertingPropertyAccessor accessor = getPropertyAccessor(entity); + + final CouchbasePersistentEntity persistentEntity = converter.getMappingContext() + .getRequiredPersistentEntity(entity.getClass()); + + final CouchbasePersistentProperty idProperty = persistentEntity.getIdProperty(); + if (idProperty != null) { + accessor.setProperty(idProperty, id); + } + + final CouchbasePersistentProperty versionProperty = persistentEntity.getVersionProperty(); + if (versionProperty != null) { + accessor.setProperty(versionProperty, cas); + } + + if (holder != null) { + holder.transactionResultHolder(txResultHolder, (T) accessor.getBean()); + } + maybeEmitEvent(new AfterSaveEvent(accessor.getBean(), converted)); + return (T) accessor.getBean(); + + } + + public Long getCas(final Object entity) { + final ConvertingPropertyAccessor accessor = getPropertyAccessor(entity); + final CouchbasePersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(entity.getClass()); + final CouchbasePersistentProperty versionProperty = persistentEntity.getVersionProperty(); + long cas = 0; + if (versionProperty != null) { + Object casObject = accessor.getProperty(versionProperty); + if (casObject instanceof Number) { + cas = ((Number) casObject).longValue(); + } + } + return cas; + } + + public Object getId(final Object entity) { + final ConvertingPropertyAccessor accessor = getPropertyAccessor(entity); + final CouchbasePersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(entity.getClass()); + final CouchbasePersistentProperty idProperty = persistentEntity.getIdProperty(); + Object id = null; + if (idProperty != null) { + id = accessor.getProperty(idProperty); + } + return id; + } + + public String getJavaNameForEntity(final Class clazz) { + final CouchbasePersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(clazz); + MappingCouchbaseEntityInformation info = new MappingCouchbaseEntityInformation<>(persistentEntity); + return info.getJavaType().getName(); + } + + ConvertingPropertyAccessor getPropertyAccessor(final T source) { + CouchbasePersistentEntity entity = mappingContext.getRequiredPersistentEntity(source.getClass()); + PersistentPropertyAccessor accessor = entity.getPropertyAccessor(source); + return new ConvertingPropertyAccessor<>(accessor, converter.getConversionService()); + } + + public void maybeEmitEvent(CouchbaseMappingEvent event) { + if (canPublishEvent()) { + try { + this.applicationContext.publishEvent(event); + } catch (Exception e) { + LOG.warn("{} thrown during {}", e, event); + throw e; + } + } else { + LOG.info("maybeEmitEvent called, but CouchbaseTemplate not initialized with applicationContext"); + } + + } + + private boolean canPublishEvent() { + return this.applicationContext != null; + } + + public TranslationService getTranslationService() { + return translationService; + } +} diff --git a/src/main/java/org/springframework/data/couchbase/core/CouchbaseExceptionTranslator.java b/src/main/java/org/springframework/data/couchbase/core/CouchbaseExceptionTranslator.java index e4d22669e..5418182c8 100644 --- a/src/main/java/org/springframework/data/couchbase/core/CouchbaseExceptionTranslator.java +++ b/src/main/java/org/springframework/data/couchbase/core/CouchbaseExceptionTranslator.java @@ -25,12 +25,32 @@ import org.springframework.dao.DataRetrievalFailureException; import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.InvalidDataAccessResourceUsageException; -import org.springframework.dao.OptimisticLockingFailureException;; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.dao.QueryTimeoutException; import org.springframework.dao.TransientDataAccessResourceException; import org.springframework.dao.support.PersistenceExceptionTranslator; - -import com.couchbase.client.core.error.*; +import org.springframework.data.couchbase.transaction.error.UncategorizedTransactionDataAccessException; + +import com.couchbase.client.core.error.BucketNotFoundException; +import com.couchbase.client.core.error.CasMismatchException; +import com.couchbase.client.core.error.CollectionNotFoundException; +import com.couchbase.client.core.error.ConfigException; +import com.couchbase.client.core.error.DecodingFailureException; +import com.couchbase.client.core.error.DesignDocumentNotFoundException; +import com.couchbase.client.core.error.DocumentExistsException; +import com.couchbase.client.core.error.DocumentLockedException; +import com.couchbase.client.core.error.DocumentNotFoundException; +import com.couchbase.client.core.error.DurabilityAmbiguousException; +import com.couchbase.client.core.error.DurabilityImpossibleException; +import com.couchbase.client.core.error.DurabilityLevelNotAvailableException; +import com.couchbase.client.core.error.EncodingFailureException; +import com.couchbase.client.core.error.ReplicaNotConfiguredException; +import com.couchbase.client.core.error.RequestCanceledException; +import com.couchbase.client.core.error.ScopeNotFoundException; +import com.couchbase.client.core.error.ServiceNotAvailableException; +import com.couchbase.client.core.error.TemporaryFailureException; +import com.couchbase.client.core.error.ValueTooLargeException; +import com.couchbase.client.core.error.transaction.TransactionOperationFailedException; /** * Simple {@link PersistenceExceptionTranslator} for Couchbase. @@ -41,6 +61,8 @@ * * @author Michael Nitschinger * @author Simon Baslé + * @author Michael Reiche + * @author Graham Pople */ public class CouchbaseExceptionTranslator implements PersistenceExceptionTranslator { @@ -71,7 +93,7 @@ public final DataAccessException translateExceptionIfPossible(final RuntimeExcep return new OptimisticLockingFailureException(ex.getMessage(), ex); } - if ( ex instanceof ReplicaNotConfiguredException || ex instanceof DurabilityLevelNotAvailableException + if (ex instanceof ReplicaNotConfiguredException || ex instanceof DurabilityLevelNotAvailableException || ex instanceof DurabilityImpossibleException || ex instanceof DurabilityAmbiguousException) { return new DataIntegrityViolationException(ex.getMessage(), ex); } @@ -98,6 +120,13 @@ public final DataAccessException translateExceptionIfPossible(final RuntimeExcep return new DataRetrievalFailureException(ex.getMessage(), ex); } + if (ex instanceof TransactionOperationFailedException) { + // Replace the TransactionOperationFailedException, since we want the Spring operation to fail with a + // Spring error. Internal state has already been set in the AttemptContext so the retry, rollback etc. + // will get respected regardless of what gets propagated (or not) from the lambda. + return new UncategorizedTransactionDataAccessException((TransactionOperationFailedException) ex); + } + // Unable to translate exception, therefore just throw the original! throw ex; } diff --git a/src/main/java/org/springframework/data/couchbase/core/CouchbaseOperations.java b/src/main/java/org/springframework/data/couchbase/core/CouchbaseOperations.java index 33ce0791a..b85b05e89 100644 --- a/src/main/java/org/springframework/data/couchbase/core/CouchbaseOperations.java +++ b/src/main/java/org/springframework/data/couchbase/core/CouchbaseOperations.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors + * Copyright 2012-2022 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. @@ -18,11 +18,14 @@ import org.springframework.data.couchbase.CouchbaseClientFactory; import org.springframework.data.couchbase.core.convert.CouchbaseConverter; +import org.springframework.data.couchbase.core.query.Query; import com.couchbase.client.java.query.QueryScanConsistency; /** * Defines common operations on the Couchbase data source, most commonly implemented by {@link CouchbaseTemplate}. + * + * @author Michael Reiche */ public interface CouchbaseOperations extends FluentCouchbaseOperations { @@ -50,4 +53,9 @@ public interface CouchbaseOperations extends FluentCouchbaseOperations { * Returns the default consistency to use for queries */ QueryScanConsistency getConsistency(); + + T save(T entity, String... scopeAndCollection); + + Long count(Query query, Class domainType); + } diff --git a/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java b/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java index 4bf781210..e7a388110 100644 --- a/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java +++ b/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors + * Copyright 2012-2022 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. @@ -28,6 +28,7 @@ import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; import org.springframework.data.couchbase.core.mapping.CouchbasePersistentEntity; import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.couchbase.core.query.Query; import org.springframework.data.mapping.context.MappingContext; import org.springframework.lang.Nullable; @@ -49,8 +50,8 @@ public class CouchbaseTemplate implements CouchbaseOperations, ApplicationContex private final CouchbaseTemplateSupport templateSupport; private final MappingContext, CouchbasePersistentProperty> mappingContext; private final ReactiveCouchbaseTemplate reactiveCouchbaseTemplate; + private final QueryScanConsistency scanConsistency; private @Nullable CouchbasePersistentEntityIndexCreator indexCreator; - private QueryScanConsistency scanConsistency; public CouchbaseTemplate(final CouchbaseClientFactory clientFactory, final CouchbaseConverter converter) { this(clientFactory, converter, new JacksonTranslationService()); @@ -69,6 +70,7 @@ public CouchbaseTemplate(final CouchbaseClientFactory clientFactory, final Couch this.reactiveCouchbaseTemplate = new ReactiveCouchbaseTemplate(clientFactory, converter, translationService, scanConsistency); this.scanConsistency = scanConsistency; + this.mappingContext = this.converter.getMappingContext(); if (mappingContext instanceof CouchbaseMappingContext) { CouchbaseMappingContext cmc = (CouchbaseMappingContext) mappingContext; @@ -78,6 +80,16 @@ public CouchbaseTemplate(final CouchbaseClientFactory clientFactory, final Couch } } + @Override + public T save(T entity, String... scopeAndCollection) { + return reactive().save(entity, scopeAndCollection).block(); + } + + @Override + public Long count(Query query, Class domainType) { + return findByQuery(domainType).matching(query).count(); + } + @Override public ExecutableUpsertById upsertById(final Class domainType) { return new ExecutableUpsertByIdOperationSupport(this).upsertById(domainType); @@ -209,5 +221,4 @@ private void prepareIndexCreator(final ApplicationContext context) { public TemplateSupport support() { return templateSupport; } - } 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 c6ec8a72d..bd4b5d576 100644 --- a/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplateSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplateSupport.java @@ -16,37 +16,19 @@ package org.springframework.data.couchbase.core; -import java.lang.reflect.InaccessibleObjectException; -import java.util.Map; -import java.util.Set; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.data.couchbase.core.convert.CouchbaseConverter; -import org.springframework.data.couchbase.core.convert.join.N1qlJoinResolver; import org.springframework.data.couchbase.core.convert.translation.TranslationService; import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; -import org.springframework.data.couchbase.core.mapping.CouchbasePersistentEntity; -import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; import org.springframework.data.couchbase.core.mapping.event.AfterConvertCallback; -import org.springframework.data.couchbase.core.mapping.event.AfterSaveEvent; import org.springframework.data.couchbase.core.mapping.event.BeforeConvertCallback; import org.springframework.data.couchbase.core.mapping.event.BeforeConvertEvent; import org.springframework.data.couchbase.core.mapping.event.BeforeSaveEvent; -import org.springframework.data.couchbase.core.mapping.event.CouchbaseMappingEvent; -import org.springframework.data.couchbase.core.support.TemplateUtils; -import org.springframework.data.couchbase.repository.support.MappingCouchbaseEntityInformation; -import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.couchbase.transaction.CouchbaseResourceHolder; import org.springframework.data.mapping.callback.EntityCallbacks; -import org.springframework.data.mapping.context.MappingContext; -import org.springframework.data.mapping.model.ConvertingPropertyAccessor; import org.springframework.util.Assert; -import org.springframework.util.ClassUtils; - -import com.couchbase.client.core.error.CouchbaseException; /** * Internal encode/decode support for CouchbaseTemplate. @@ -57,23 +39,15 @@ * @author Carlos Espinaco * @since 3.0 */ -class CouchbaseTemplateSupport implements ApplicationContextAware, TemplateSupport { - - private static final Logger LOG = LoggerFactory.getLogger(CouchbaseTemplateSupport.class); +class CouchbaseTemplateSupport extends AbstractTemplateSupport implements ApplicationContextAware, TemplateSupport { private final CouchbaseTemplate template; - private final CouchbaseConverter converter; - private final MappingContext, CouchbasePersistentProperty> mappingContext; - private final TranslationService translationService; private EntityCallbacks entityCallbacks; - private ApplicationContext applicationContext; public CouchbaseTemplateSupport(final CouchbaseTemplate template, final CouchbaseConverter converter, final TranslationService translationService) { + super(template.reactive(), converter, translationService); this.template = template; - this.converter = converter; - this.mappingContext = converter.getMappingContext(); - this.translationService = translationService; } @Override @@ -88,139 +62,20 @@ public CouchbaseDocument encodeEntity(final Object entityToEncode) { } @Override - public T decodeEntity(String id, String source, Long cas, Class entityClass, String scope, String collection) { - - // this is the entity class defined for the repository. It may not be the class of the document that was read - // we will reset it after reading the document - // - // This will fail for the case where: - // 1) The version is defined in the concrete class, but not in the abstract class; and - // 2) The constructor takes a "long version" argument resulting in an exception would be thrown if version in - // the source is null. - // We could expose from the MappingCouchbaseConverter determining the persistent entity from the source, - // but that is a lot of work to do every time just for this very rare and avoidable case. - // TypeInformation typeToUse = typeMapper.readType(source, type); - - CouchbasePersistentEntity persistentEntity = couldBePersistentEntity(entityClass); - - if (persistentEntity == null) { // method could return a Long, Boolean, String etc. - // QueryExecutionConverters.unwrapWrapperTypes will recursively unwrap until there is nothing left - // to unwrap. This results in List being unwrapped past String[] to String, so this may also be a - // Collection (or Array) of entityClass. We have no way of knowing - so just assume it is what we are told. - // if this is a Collection or array, only the first element will be returned. - final CouchbaseDocument converted = new CouchbaseDocument(id); - Set> set = ((CouchbaseDocument) translationService.decode(source, converted)) - .getContent().entrySet(); - return (T) set.iterator().next().getValue(); - } - - if (id == null) { - throw new CouchbaseException(TemplateUtils.SELECT_ID + " was null. Either use #{#n1ql.selectEntity} or project " - + TemplateUtils.SELECT_ID); - } - - final CouchbaseDocument converted = new CouchbaseDocument(id); - - // if possible, set the version property in the source so that if the constructor has a long version argument, - // it will have a value and not fail (as null is not a valid argument for a long argument). This possible failure - // can be avoid by defining the argument as Long instead of long. - // persistentEntity is still the (possibly abstract) class specified in the repository definition - // it's possible that the abstract class does not have a version property, and this won't be able to set the version - if (persistentEntity.getVersionProperty() != null) { - if (cas == null) { - throw new CouchbaseException("version/cas in the entity but " + TemplateUtils.SELECT_CAS - + " was not in result. Either use #{#n1ql.selectEntity} or project " + TemplateUtils.SELECT_CAS); - } - if (cas != 0) { - converted.put(persistentEntity.getVersionProperty().getName(), cas); - } - } - - // if the constructor has an argument that is long version, then construction will fail if the 'version' - // is not available as 'null' is not a legal value for a long. Changing the arg to "Long version" would solve this. - // (Version doesn't come from 'source', it comes from the cas argument to decodeEntity) - T readEntity = converter.read(entityClass, (CouchbaseDocument) translationService.decode(source, converted)); - final ConvertingPropertyAccessor accessor = getPropertyAccessor(readEntity); - - persistentEntity = couldBePersistentEntity(readEntity.getClass()); - - if (cas != null && cas != 0 && persistentEntity.getVersionProperty() != null) { - accessor.setProperty(persistentEntity.getVersionProperty(), cas); - } - N1qlJoinResolver.handleProperties(persistentEntity, accessor, template.reactive(), id, scope, collection); - return accessor.getBean(); - } - - CouchbasePersistentEntity couldBePersistentEntity(Class entityClass) { - if (ClassUtils.isPrimitiveOrWrapper(entityClass) || entityClass == String.class) { - return null; - } - try { - return mappingContext.getPersistentEntity(entityClass); - } catch (InaccessibleObjectException t) { - - } - return null; - } - - @Override - public Object applyUpdatedCas(final Object entity, CouchbaseDocument converted, final long cas) { - Object returnValue; - final ConvertingPropertyAccessor accessor = getPropertyAccessor(entity); - final CouchbasePersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(entity.getClass()); - final CouchbasePersistentProperty versionProperty = persistentEntity.getVersionProperty(); - - if (versionProperty != null) { - accessor.setProperty(versionProperty, cas); - returnValue = accessor.getBean(); - } else { - returnValue = entity; - } - maybeEmitEvent(new AfterSaveEvent(returnValue, converted)); - - return returnValue; - } - - @Override - public Object applyUpdatedId(final Object entity, Object id) { - final ConvertingPropertyAccessor accessor = getPropertyAccessor(entity); - final CouchbasePersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(entity.getClass()); - final CouchbasePersistentProperty idProperty = persistentEntity.getIdProperty(); - - if (idProperty != null) { - accessor.setProperty(idProperty, id); - return accessor.getBean(); - } - return entity; + public T decodeEntity(String id, String source, Long cas, Class entityClass, String scope, String collection, + Object txHolder, CouchbaseResourceHolder holder) { + return decodeEntityBase(id, source, cas, entityClass, scope, collection, txHolder, holder); } @Override - public long getCas(final Object entity) { - final ConvertingPropertyAccessor accessor = getPropertyAccessor(entity); - final CouchbasePersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(entity.getClass()); - final CouchbasePersistentProperty versionProperty = persistentEntity.getVersionProperty(); - - long cas = 0; - if (versionProperty != null) { - Object casObject = accessor.getProperty(versionProperty); - if (casObject instanceof Number) { - cas = ((Number) casObject).longValue(); - } - } - return cas; + public T applyResult(T entity, CouchbaseDocument converted, Object id, long cas, + Object txResultHolder, CouchbaseResourceHolder holder) { + return applyResultBase(entity, converted, id, cas, txResultHolder, holder); } @Override - public String getJavaNameForEntity(final Class clazz) { - final CouchbasePersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(clazz); - MappingCouchbaseEntityInformation info = new MappingCouchbaseEntityInformation<>(persistentEntity); - return info.getJavaType().getName(); - } - - private ConvertingPropertyAccessor getPropertyAccessor(final T source) { - CouchbasePersistentEntity entity = mappingContext.getRequiredPersistentEntity(source.getClass()); - PersistentPropertyAccessor accessor = entity.getPropertyAccessor(source); - return new ConvertingPropertyAccessor<>(accessor, converter.getConversionService()); + public Integer getTxResultHolder(T source) { + return null; } @Override @@ -246,24 +101,6 @@ public void setEntityCallbacks(EntityCallbacks entityCallbacks) { this.entityCallbacks = entityCallbacks; } - public void maybeEmitEvent(CouchbaseMappingEvent event) { - if (canPublishEvent()) { - try { - this.applicationContext.publishEvent(event); - } catch (Exception e) { - LOG.warn("{} thrown during {}", e, event); - throw e; - } - } else { - LOG.info("maybeEmitEvent called, but CouchbaseTemplate not initialized with applicationContext"); - } - - } - - private boolean canPublishEvent() { - return this.applicationContext != null; - } - protected T maybeCallBeforeConvert(T object, String collection) { if (entityCallbacks != null) { return entityCallbacks.callback(BeforeConvertCallback.class, object, collection); @@ -282,4 +119,8 @@ protected T maybeCallAfterConvert(T object, CouchbaseDocument document, Stri return object; } + @Override + ReactiveCouchbaseTemplate getReactiveTemplate() { + return template.reactive(); + } } diff --git a/src/main/java/org/springframework/data/couchbase/core/ExecutableRemoveByIdOperation.java b/src/main/java/org/springframework/data/couchbase/core/ExecutableRemoveByIdOperation.java index 02381669e..b52b4e2f6 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ExecutableRemoveByIdOperation.java +++ b/src/main/java/org/springframework/data/couchbase/core/ExecutableRemoveByIdOperation.java @@ -33,6 +33,7 @@ * Remove Operations on KV service. * * @author Christoph Strobl + * @author Michael Reiche * @since 2.0 */ public interface ExecutableRemoveByIdOperation { @@ -61,6 +62,14 @@ interface TerminatingRemoveById extends OneAndAllId { @Override RemoveResult one(String id); + /** + * Remove one document based on the entity. Transactions need the entity for the cas. + * + * @param entity the document ID. + * @return result of the remove + */ + RemoveResult oneEntity(Object entity); + /** * Remove the documents in the collection. * @@ -70,6 +79,14 @@ interface TerminatingRemoveById extends OneAndAllId { @Override List all(Collection ids); + /** + * Remove documents based on the entities. Transactions need the entity for the cas. + * + * @param entities to remove. + * @return result of the remove + */ + List allEntities(Collection entities); + } /** diff --git a/src/main/java/org/springframework/data/couchbase/core/ExecutableRemoveByIdOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ExecutableRemoveByIdOperationSupport.java index 717d11a98..eb77a5458 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ExecutableRemoveByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ExecutableRemoveByIdOperationSupport.java @@ -27,6 +27,11 @@ import com.couchbase.client.java.kv.RemoveOptions; import com.couchbase.client.java.kv.ReplicateTo; +/** + * {@link ExecutableRemoveByIdOperation} implementations for Couchbase. + * + * @author Michael Reiche + */ public class ExecutableRemoveByIdOperationSupport implements ExecutableRemoveByIdOperation { private final CouchbaseTemplate template; @@ -45,8 +50,8 @@ public ExecutableRemoveById removeById() { public ExecutableRemoveById removeById(Class domainType) { return new ExecutableRemoveByIdSupport(template, domainType, OptionsBuilder.getScopeFrom(domainType), - OptionsBuilder.getCollectionFrom(domainType), null, PersistTo.NONE, ReplicateTo.NONE, - DurabilityLevel.NONE, null); + OptionsBuilder.getCollectionFrom(domainType), null, PersistTo.NONE, ReplicateTo.NONE, DurabilityLevel.NONE, + null); } static class ExecutableRemoveByIdSupport implements ExecutableRemoveById { @@ -83,15 +88,25 @@ public RemoveResult one(final String id) { return reactiveRemoveByIdSupport.one(id).block(); } + @Override + public RemoveResult oneEntity(final Object entity) { + return reactiveRemoveByIdSupport.oneEntity(entity).block(); + } + @Override public List all(final Collection ids) { return reactiveRemoveByIdSupport.all(ids).collectList().block(); } + @Override + public List allEntities(final Collection entities) { + return reactiveRemoveByIdSupport.allEntities(entities).collectList().block(); + } + @Override public RemoveByIdWithOptions inCollection(final String collection) { - return new ExecutableRemoveByIdSupport(template, domainType, scope, collection != null ? collection : this.collection, options, persistTo, replicateTo, - durabilityLevel, cas); + return new ExecutableRemoveByIdSupport(template, domainType, scope, + collection != null ? collection : this.collection, options, persistTo, replicateTo, durabilityLevel, cas); } @Override @@ -118,8 +133,8 @@ public TerminatingRemoveById withOptions(final RemoveOptions options) { @Override public RemoveByIdInCollection inScope(final String scope) { - return new ExecutableRemoveByIdSupport(template, domainType, scope != null ? scope : this.scope, collection, options, persistTo, replicateTo, - durabilityLevel, cas); + return new ExecutableRemoveByIdSupport(template, domainType, scope != null ? scope : this.scope, collection, + options, persistTo, replicateTo, durabilityLevel, cas); } @Override @@ -127,6 +142,7 @@ public RemoveByIdWithDurability withCas(Long cas) { return new ExecutableRemoveByIdSupport(template, domainType, scope, collection, options, persistTo, replicateTo, durabilityLevel, cas); } + } } 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 7cb19f82d..6e1aa7102 100644 --- a/src/main/java/org/springframework/data/couchbase/core/NonReactiveSupportWrapper.java +++ b/src/main/java/org/springframework/data/couchbase/core/NonReactiveSupportWrapper.java @@ -17,13 +17,15 @@ import reactor.core.publisher.Mono; +import org.springframework.data.couchbase.core.convert.translation.TranslationService; import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; -import org.springframework.data.couchbase.core.mapping.event.CouchbaseMappingEvent; +import org.springframework.data.couchbase.transaction.CouchbaseResourceHolder; /** * Wrapper of {@link TemplateSupport} methods to adapt them to {@link ReactiveTemplateSupport}. * * @author Carlos Espinaco + * @author Michael Reiche * @since 4.2 */ public class NonReactiveSupportWrapper implements ReactiveTemplateSupport { @@ -40,33 +42,35 @@ public Mono encodeEntity(Object entityToEncode) { } @Override - public Mono decodeEntity(String id, String source, Long cas, Class entityClass, String scope, - String collection) { - return Mono.fromSupplier(() -> support.decodeEntity(id, source, cas, entityClass, scope, collection)); + public Mono decodeEntity(String id, String source, Long cas, Class entityClass, String scope, String collection, + Object txResultHolder, CouchbaseResourceHolder holder) { + return Mono.fromSupplier(() -> support.decodeEntity(id, source, cas, entityClass, scope, collection, txResultHolder, holder)); } @Override - public Mono applyUpdatedCas(Object entity, CouchbaseDocument converted, long cas) { - return Mono.fromSupplier(() -> support.applyUpdatedCas(entity, converted, cas)); + 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)); } - @Override - public Mono applyUpdatedId(Object entity, Object id) { - return Mono.fromSupplier(() -> support.applyUpdatedId(entity, id)); - } @Override public Long getCas(Object entity) { return support.getCas(entity); } + @Override + public Object getId(Object entity) { + return support.getId(entity); + } + @Override public String getJavaNameForEntity(Class clazz) { return support.getJavaNameForEntity(clazz); } @Override - public void maybeEmitEvent(CouchbaseMappingEvent event) { - support.maybeEmitEvent(event); + public TranslationService getTranslationService() { + return support.getTranslationService(); } } diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseOperations.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseOperations.java index 81b8cfdef..2b2973a08 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseOperations.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseOperations.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors + * Copyright 2012-2022 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. @@ -15,8 +15,11 @@ */ package org.springframework.data.couchbase.core; +import reactor.core.publisher.Mono; + import org.springframework.data.couchbase.CouchbaseClientFactory; import org.springframework.data.couchbase.core.convert.CouchbaseConverter; +import org.springframework.data.couchbase.core.query.Query; import com.couchbase.client.java.query.QueryScanConsistency; @@ -49,6 +52,10 @@ public interface ReactiveCouchbaseOperations extends ReactiveFluentCouchbaseOper */ CouchbaseClientFactory getCouchbaseClientFactory(); + Mono save(T entity, String... scopeAndCollection); + + Mono count(Query query, Class personClass); + /** * @return the default consistency to use for queries */ diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplate.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplate.java index a120b5280..c86e154e4 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplate.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplate.java @@ -16,6 +16,8 @@ package org.springframework.data.couchbase.core; +import reactor.core.publisher.Mono; + import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; @@ -25,7 +27,12 @@ import org.springframework.data.couchbase.core.convert.CouchbaseConverter; import org.springframework.data.couchbase.core.convert.translation.JacksonTranslationService; import org.springframework.data.couchbase.core.convert.translation.TranslationService; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentEntity; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.couchbase.core.query.Query; import org.springframework.data.couchbase.core.support.PseudoArgs; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; import com.couchbase.client.java.Collection; import com.couchbase.client.java.query.QueryScanConsistency; @@ -45,19 +52,19 @@ public class ReactiveCouchbaseTemplate implements ReactiveCouchbaseOperations, A private final PersistenceExceptionTranslator exceptionTranslator; private final ReactiveCouchbaseTemplateSupport templateSupport; private ThreadLocal> threadLocalArgs = new ThreadLocal<>(); - private QueryScanConsistency scanConsistency; + private final QueryScanConsistency scanConsistency; public ReactiveCouchbaseTemplate(final CouchbaseClientFactory clientFactory, final CouchbaseConverter converter) { - this(clientFactory, converter, new JacksonTranslationService()); + this(clientFactory, converter, new JacksonTranslationService(), null); } public ReactiveCouchbaseTemplate(final CouchbaseClientFactory clientFactory, final CouchbaseConverter converter, - final TranslationService translationService) { + final TranslationService translationService) { this(clientFactory, converter, translationService, null); } public ReactiveCouchbaseTemplate(final CouchbaseClientFactory clientFactory, final CouchbaseConverter converter, - final TranslationService translationService, QueryScanConsistency scanConsistency) { + final TranslationService translationService, final QueryScanConsistency scanConsistency) { this.clientFactory = clientFactory; this.converter = converter; this.exceptionTranslator = clientFactory.getExceptionTranslator(); @@ -65,6 +72,47 @@ public ReactiveCouchbaseTemplate(final CouchbaseClientFactory clientFactory, fin this.scanConsistency = scanConsistency; } + public Mono save(T entity) { + return save(entity, null, null); + } + + public Mono save(T entity, String... scopeAndCollection) { + Assert.notNull(entity, "Entity must not be null!"); + String scope = scopeAndCollection.length > 0 ? scopeAndCollection[0] : null; + String collection = scopeAndCollection.length > 1 ? scopeAndCollection[1] : null; + Mono result; + final CouchbasePersistentEntity mapperEntity = getConverter().getMappingContext() + .getPersistentEntity(entity.getClass()); + final CouchbasePersistentProperty versionProperty = mapperEntity.getVersionProperty(); + final boolean versionPresent = versionProperty != null; + final Long version = versionProperty == null || versionProperty.getField() == null ? null + : (Long) ReflectionUtils.getField(versionProperty.getField(), entity); + final boolean existingDocument = version != null && version > 0; + + Class clazz = entity.getClass(); + + if (!versionPresent) { // the entity doesn't have a version property + // No version field - no cas + // If in a transaction, insert is the only thing that will work + if (TransactionalSupport.checkForTransactionInThreadLocalStorage().block().isPresent()) { + result = (Mono) insertById(clazz).inScope(scope).inCollection(collection).one(entity); + } else { // if not in a tx, then upsert will work + result = (Mono) upsertById(clazz).inScope(scope).inCollection(collection).one(entity); + } + } else if (existingDocument) { // there is a version property, and it is non-zero + // Updating existing document with cas + result = (Mono) replaceById(clazz).inScope(scope).inCollection(collection).one(entity); + } else { // there is a version property, but it's zero or not set. + // Creating new document + result = (Mono) insertById(clazz).inScope(scope).inCollection(collection).one(entity); + } + return result; + } + + public Mono count(Query query, Class domainType) { + return findByQuery(domainType).matching(query).all().count(); + } + @Override public ReactiveFindById findById(Class domainType) { return new ReactiveFindByIdOperationSupport(this).findById(domainType); @@ -165,8 +213,9 @@ public ReactiveTemplateSupport support() { * * @param ex the exception to translate */ - protected RuntimeException potentiallyConvertRuntimeException(final RuntimeException ex) { - RuntimeException resolved = exceptionTranslator.translateExceptionIfPossible(ex); + RuntimeException potentiallyConvertRuntimeException(final RuntimeException ex) { + RuntimeException resolved = exceptionTranslator != null ? exceptionTranslator.translateExceptionIfPossible(ex) + : null; return resolved == null ? ex : resolved; } 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 6cfefd64c..e886655d3 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplateSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplateSupport.java @@ -16,63 +16,40 @@ package org.springframework.data.couchbase.core; -import com.couchbase.client.core.error.CouchbaseException; -import org.springframework.data.couchbase.core.support.TemplateUtils; import reactor.core.publisher.Mono; -import java.lang.reflect.InaccessibleObjectException; -import java.util.Map; -import java.util.Set; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.data.couchbase.core.convert.CouchbaseConverter; -import org.springframework.data.couchbase.core.convert.join.N1qlJoinResolver; import org.springframework.data.couchbase.core.convert.translation.TranslationService; import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; -import org.springframework.data.couchbase.core.mapping.CouchbasePersistentEntity; -import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; -import org.springframework.data.couchbase.core.mapping.event.AfterSaveEvent; import org.springframework.data.couchbase.core.mapping.event.BeforeConvertEvent; import org.springframework.data.couchbase.core.mapping.event.BeforeSaveEvent; -import org.springframework.data.couchbase.core.mapping.event.CouchbaseMappingEvent; import org.springframework.data.couchbase.core.mapping.event.ReactiveAfterConvertCallback; import org.springframework.data.couchbase.core.mapping.event.ReactiveBeforeConvertCallback; -import org.springframework.data.couchbase.repository.support.MappingCouchbaseEntityInformation; -import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.couchbase.transaction.CouchbaseResourceHolder; import org.springframework.data.mapping.callback.EntityCallbacks; import org.springframework.data.mapping.callback.ReactiveEntityCallbacks; -import org.springframework.data.mapping.context.MappingContext; -import org.springframework.data.mapping.model.ConvertingPropertyAccessor; import org.springframework.util.Assert; -import org.springframework.util.ClassUtils; /** * Internal encode/decode support for {@link ReactiveCouchbaseTemplate}. * * @author Carlos Espinaco + * @author Michael Reiche * @since 4.2 */ -class ReactiveCouchbaseTemplateSupport implements ApplicationContextAware, ReactiveTemplateSupport { - - private static final Logger LOG = LoggerFactory.getLogger(ReactiveCouchbaseTemplateSupport.class); +class ReactiveCouchbaseTemplateSupport extends AbstractTemplateSupport + implements ApplicationContextAware, ReactiveTemplateSupport { private final ReactiveCouchbaseTemplate template; - private final CouchbaseConverter converter; - private final MappingContext, CouchbasePersistentProperty> mappingContext; - private final TranslationService translationService; private ReactiveEntityCallbacks reactiveEntityCallbacks; - private ApplicationContext applicationContext; public ReactiveCouchbaseTemplateSupport(final ReactiveCouchbaseTemplate template, final CouchbaseConverter converter, final TranslationService translationService) { + super(template, converter, translationService); this.template = template; - this.converter = converter; - this.mappingContext = converter.getMappingContext(); - this.translationService = translationService; } @Override @@ -87,146 +64,21 @@ public Mono encodeEntity(final Object entityToEncode) { } @Override - public Mono decodeEntity(String id, String source, Long cas, Class entityClass, String scope, - String collection) { - return Mono.fromSupplier(() -> { - // this is the entity class defined for the repository. It may not be the class of the document that was read - // we will reset it after reading the document - // - // This will fail for the case where: - // 1) The version is defined in the concrete class, but not in the abstract class; and - // 2) The constructor takes a "long version" argument resulting in an exception would be thrown if version in - // the source is null. - // We could expose from the MappingCouchbaseConverter determining the persistent entity from the source, - // but that is a lot of work to do every time just for this very rare and avoidable case. - // TypeInformation typeToUse = typeMapper.readType(source, type); - - CouchbasePersistentEntity persistentEntity = couldBePersistentEntity(entityClass); - - if (persistentEntity == null) { // method could return a Long, Boolean, String etc. - // QueryExecutionConverters.unwrapWrapperTypes will recursively unwrap until there is nothing left - // to unwrap. This results in List being unwrapped past String[] to String, so this may also be a - // Collection (or Array) of entityClass. We have no way of knowing - so just assume it is what we are told. - // if this is a Collection or array, only the first element will be returned. - final CouchbaseDocument converted = new CouchbaseDocument(id); - Set> set = ((CouchbaseDocument) translationService.decode(source, converted)) - .getContent().entrySet(); - return (T) set.iterator().next().getValue(); - } - - if (id == null) { - throw new CouchbaseException(TemplateUtils.SELECT_ID + " was null. Either use #{#n1ql.selectEntity} or project " - + TemplateUtils.SELECT_ID); - } - - final CouchbaseDocument converted = new CouchbaseDocument(id); - - // if possible, set the version property in the source so that if the constructor has a long version argument, - // it will have a value and not fail (as null is not a valid argument for a long argument). This possible failure - // can be avoid by defining the argument as Long instead of long. - // persistentEntity is still the (possibly abstract) class specified in the repository definition - // it's possible that the abstract class does not have a version property, and this won't be able to set the version - if (persistentEntity.getVersionProperty() != null) { - if (cas == null) { - throw new CouchbaseException("version/cas in the entity but " + TemplateUtils.SELECT_CAS - + " was not in result. Either use #{#n1ql.selectEntity} or project " + TemplateUtils.SELECT_CAS); - } - if (cas != 0) { - converted.put(persistentEntity.getVersionProperty().getName(), cas); - } - } - - // if the constructor has an argument that is long version, then construction will fail if the 'version' - // is not available as 'null' is not a legal value for a long. Changing the arg to "Long version" would solve this. - // (Version doesn't come from 'source', it comes from the cas argument to decodeEntity) - T readEntity = converter.read(entityClass, (CouchbaseDocument) translationService.decode(source, converted)); - final ConvertingPropertyAccessor accessor = getPropertyAccessor(readEntity); - - persistentEntity = couldBePersistentEntity(readEntity.getClass()); - - if (cas != null && cas != 0 && persistentEntity.getVersionProperty() != null) { - accessor.setProperty(persistentEntity.getVersionProperty(), cas); - } - N1qlJoinResolver.handleProperties(persistentEntity, accessor, template, id, scope, collection); - return accessor.getBean(); - }); - } - - CouchbasePersistentEntity couldBePersistentEntity(Class entityClass) { - if (ClassUtils.isPrimitiveOrWrapper(entityClass) || entityClass == String.class) { - return null; - } - try { - return mappingContext.getPersistentEntity(entityClass); - } catch (InaccessibleObjectException t) { - - } - return null; - } - - @Override - public Mono applyUpdatedCas(final Object entity, CouchbaseDocument converted, final long cas) { - return Mono.fromSupplier(() -> { - Object returnValue; - final ConvertingPropertyAccessor accessor = getPropertyAccessor(entity); - final CouchbasePersistentEntity persistentEntity = mappingContext - .getRequiredPersistentEntity(entity.getClass()); - final CouchbasePersistentProperty versionProperty = persistentEntity.getVersionProperty(); - - if (versionProperty != null) { - accessor.setProperty(versionProperty, cas); - returnValue = accessor.getBean(); - } else { - returnValue = entity; - } - maybeEmitEvent(new AfterSaveEvent(returnValue, converted)); - return returnValue; - }); + ReactiveCouchbaseTemplate getReactiveTemplate() { + return template; } @Override - public Mono applyUpdatedId(final Object entity, Object id) { - return Mono.fromSupplier(() -> { - final ConvertingPropertyAccessor accessor = getPropertyAccessor(entity); - final CouchbasePersistentEntity persistentEntity = mappingContext - .getRequiredPersistentEntity(entity.getClass()); - final CouchbasePersistentProperty idProperty = persistentEntity.getIdProperty(); - - if (idProperty != null) { - accessor.setProperty(idProperty, id); - return accessor.getBean(); - } - return entity; - }); - } - - @Override - public Long getCas(final Object entity) { - final ConvertingPropertyAccessor accessor = getPropertyAccessor(entity); - final CouchbasePersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(entity.getClass()); - final CouchbasePersistentProperty versionProperty = persistentEntity.getVersionProperty(); - - long cas = 0; - if (versionProperty != null) { - Object casObject = accessor.getProperty(versionProperty); - if (casObject instanceof Number) { - cas = ((Number) casObject).longValue(); - } - } - return cas; + public Mono decodeEntity(String id, String source, Long cas, Class entityClass, String scope, + String collection, Object txResultHolder, CouchbaseResourceHolder holder) { + return Mono + .fromSupplier(() -> decodeEntityBase(id, source, cas, entityClass, scope, collection, txResultHolder, holder)); } @Override - public String getJavaNameForEntity(final Class clazz) { - final CouchbasePersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(clazz); - MappingCouchbaseEntityInformation info = new MappingCouchbaseEntityInformation<>(persistentEntity); - return info.getJavaType().getName(); - } - - private ConvertingPropertyAccessor getPropertyAccessor(final T source) { - CouchbasePersistentEntity entity = mappingContext.getRequiredPersistentEntity(source.getClass()); - PersistentPropertyAccessor accessor = entity.getPropertyAccessor(source); - return new ConvertingPropertyAccessor<>(accessor, converter.getConversionService()); + 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)); } @Override @@ -252,24 +104,6 @@ public void setReactiveEntityCallbacks(ReactiveEntityCallbacks reactiveEntityCal this.reactiveEntityCallbacks = reactiveEntityCallbacks; } - public void maybeEmitEvent(CouchbaseMappingEvent event) { - if (canPublishEvent()) { - try { - this.applicationContext.publishEvent(event); - } catch (Exception e) { - LOG.warn("{} thrown during {}", e, event); - throw e; - } - } else { - LOG.info("maybeEmitEvent called, but ReactiveCouchbaseTemplate not initialized with applicationContext"); - } - - } - - private boolean canPublishEvent() { - return this.applicationContext != null; - } - protected Mono maybeCallBeforeConvert(T object, String collection) { if (reactiveEntityCallbacks != null) { return reactiveEntityCallbacks.callback(ReactiveBeforeConvertCallback.class, object, collection); diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveExistsByIdOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveExistsByIdOperationSupport.java index 8436417d0..f39013de9 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveExistsByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveExistsByIdOperationSupport.java @@ -32,6 +32,11 @@ import com.couchbase.client.java.kv.ExistsOptions; import com.couchbase.client.java.kv.ExistsResult; +/** + * ReactiveExistsById Support + * + * @author Michael Reiche + */ public class ReactiveExistsByIdOperationSupport implements ReactiveExistsByIdOperation { private final ReactiveCouchbaseTemplate template; @@ -73,8 +78,10 @@ static class ReactiveExistsByIdSupport implements ReactiveExistsById { @Override public Mono one(final String id) { PseudoArgs pArgs = new PseudoArgs<>(template, scope, collection, options, domainType); - LOG.trace("existsById key={} {}", id, pArgs); - return Mono.just(id) + if (LOG.isDebugEnabled()) { + LOG.debug("existsById key={} {}", id, pArgs); + } + return TransactionalSupport.verifyNotInTransaction("existsById").then(Mono.just(id)) .flatMap(docId -> template.getCouchbaseClientFactory().withScope(pArgs.getScope()) .getCollection(pArgs.getCollection()).reactive().exists(id, buildOptions(pArgs.getOptions())) .map(ExistsResult::exists)) diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByAnalyticsOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByAnalyticsOperationSupport.java index a1599ac6d..cb01cae10 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByAnalyticsOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByAnalyticsOperationSupport.java @@ -18,6 +18,8 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.data.couchbase.core.query.AnalyticsQuery; import org.springframework.data.couchbase.core.query.OptionsBuilder; import org.springframework.data.couchbase.core.support.TemplateUtils; @@ -33,6 +35,8 @@ public class ReactiveFindByAnalyticsOperationSupport implements ReactiveFindByAn private final ReactiveCouchbaseTemplate template; + private static final Logger LOG = LoggerFactory.getLogger(ReactiveFindByAnalyticsOperationSupport.class); + public ReactiveFindByAnalyticsOperationSupport(final ReactiveCouchbaseTemplate template) { this.template = template; } @@ -110,8 +114,11 @@ public Mono first() { public Flux all() { return Flux.defer(() -> { String statement = assembleEntityQuery(false); - return template.getCouchbaseClientFactory().getCluster().reactive() - .analyticsQuery(statement, buildAnalyticsOptions()).onErrorMap(throwable -> { + if (LOG.isDebugEnabled()) { + LOG.debug("findByAnalytics statement: {}", statement); + } + return TransactionalSupport.verifyNotInTransaction("findByAnalytics").then(template.getCouchbaseClientFactory() + .getCluster().reactive().analyticsQuery(statement, buildAnalyticsOptions())).onErrorMap(throwable -> { if (throwable instanceof RuntimeException) { return template.potentiallyConvertRuntimeException((RuntimeException) throwable); } else { @@ -132,7 +139,7 @@ public Flux all() { } row.removeKey(TemplateUtils.SELECT_ID); row.removeKey(TemplateUtils.SELECT_CAS); - return support.decodeEntity(id, row.toString(), cas, returnType, null, null); + return support.decodeEntity(id, row.toString(), cas, returnType, null, null, null, null); }); }); } @@ -141,8 +148,11 @@ public Flux all() { public Mono count() { return Mono.defer(() -> { String statement = assembleEntityQuery(true); - return template.getCouchbaseClientFactory().getCluster().reactive() - .analyticsQuery(statement, buildAnalyticsOptions()).onErrorMap(throwable -> { + if (LOG.isDebugEnabled()) { + LOG.debug("findByAnalytics statement: {}", statement); + } + return TransactionalSupport.verifyNotInTransaction("findByAnalytics").then(template.getCouchbaseClientFactory() + .getCluster().reactive().analyticsQuery(statement, buildAnalyticsOptions())).onErrorMap(throwable -> { if (throwable instanceof RuntimeException) { return template.potentiallyConvertRuntimeException((RuntimeException) throwable); } else { diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByIdOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByIdOperationSupport.java index a8391df04..065a150c9 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByIdOperationSupport.java @@ -16,10 +16,12 @@ package org.springframework.data.couchbase.core; import static com.couchbase.client.java.kv.GetAndTouchOptions.getAndTouchOptions; +import static com.couchbase.client.java.transactions.internal.ConverterUtil.makeCollectionIdentifier; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Arrays; import java.util.Collection; @@ -39,6 +41,11 @@ import com.couchbase.client.java.kv.GetAndTouchOptions; import com.couchbase.client.java.kv.GetOptions; +/** + * {@link ReactiveFindByIdOperation} implementations for Couchbase. + * + * @author Michael Reiche + */ public class ReactiveFindByIdOperationSupport implements ReactiveFindByIdOperation { private final ReactiveCouchbaseTemplate template; @@ -82,31 +89,44 @@ public Mono one(final String id) { CommonOptions gOptions = initGetOptions(); PseudoArgs pArgs = new PseudoArgs(template, scope, collection, gOptions, domainType); - LOG.trace("findById key={} {}", id, pArgs); + if (LOG.isDebugEnabled()) { + LOG.debug("findById key={} {}", id, pArgs); + } + ReactiveCollection rc = template.getCouchbaseClientFactory().withScope(pArgs.getScope()) + .getCollection(pArgs.getCollection()).reactive(); + + Mono reactiveEntity = TransactionalSupport.checkForTransactionInThreadLocalStorage().flatMap(ctxOpt -> { + if (!ctxOpt.isPresent()) { + if (pArgs.getOptions() instanceof GetAndTouchOptions) { + return rc.getAndTouch(id, expiryToUse(), (GetAndTouchOptions) pArgs.getOptions()) + .flatMap(result -> support.decodeEntity(id, result.contentAs(String.class), result.cas(), domainType, + pArgs.getScope(), pArgs.getCollection(), null, null)); + } else { + return rc.get(id, (GetOptions) pArgs.getOptions()) + .flatMap(result -> support.decodeEntity(id, result.contentAs(String.class), result.cas(), domainType, + pArgs.getScope(), pArgs.getCollection(), null, null)); + } + } else { + return ctxOpt.get().getCore().get(makeCollectionIdentifier(rc.async()), id) + .flatMap(result -> support.decodeEntity(id, new String(result.contentAsBytes(), StandardCharsets.UTF_8), + result.cas(), domainType, pArgs.getScope(), pArgs.getCollection(), + null, null)); + } + }); - return Mono.just(id).flatMap(docId -> { - ReactiveCollection reactive = template.getCouchbaseClientFactory().withScope(pArgs.getScope()) - .getCollection(pArgs.getCollection()).reactive(); - if (pArgs.getOptions() instanceof GetAndTouchOptions) { - return reactive.getAndTouch(docId, expiryToUse(), (GetAndTouchOptions) pArgs.getOptions()); + return reactiveEntity.onErrorResume(throwable -> { + if (throwable instanceof DocumentNotFoundException) { + return Mono.empty(); + } + return Mono.error(throwable); + }).onErrorMap(throwable -> { + if (throwable instanceof RuntimeException) { + return template.potentiallyConvertRuntimeException((RuntimeException) throwable); } else { - return reactive.get(docId, (GetOptions) pArgs.getOptions()); + return throwable; } - }).flatMap(result -> support.decodeEntity(id, result.contentAs(String.class), result.cas(), domainType, - pArgs.getScope(), pArgs.getCollection())).onErrorResume(throwable -> { - if (throwable instanceof RuntimeException) { - if (throwable instanceof DocumentNotFoundException) { - return Mono.empty(); - } - } - return Mono.error(throwable); - }).onErrorMap(throwable -> { - if (throwable instanceof RuntimeException) { - return template.potentiallyConvertRuntimeException((RuntimeException) throwable); - } else { - return throwable; - } - }); + }); + } @Override @@ -115,7 +135,7 @@ public Flux all(final Collection ids) { } @Override - public TerminatingFindById withOptions(final GetOptions options) { + public FindByIdInScope withOptions(final GetOptions options) { Assert.notNull(options, "Options must not be null."); return new ReactiveFindByIdSupport<>(template, domainType, scope, collection, options, fields, expiry, support); } @@ -133,7 +153,7 @@ public FindByIdInCollection inScope(final String scope) { } @Override - public FindByIdInScope project(String... fields) { + public FindByIdInCollection project(String... fields) { Assert.notNull(fields, "Fields must not be null"); return new ReactiveFindByIdSupport<>(template, domainType, scope, collection, options, Arrays.asList(fields), expiry, support); @@ -176,6 +196,7 @@ private Duration expiryToUse() { } return expiryToUse; } + } } diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByQueryOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByQueryOperationSupport.java index 42895c998..5c961d3ae 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByQueryOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindByQueryOperationSupport.java @@ -20,15 +20,20 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.data.couchbase.CouchbaseClientFactory; import org.springframework.data.couchbase.core.query.OptionsBuilder; import org.springframework.data.couchbase.core.query.Query; import org.springframework.data.couchbase.core.support.PseudoArgs; import org.springframework.data.couchbase.core.support.TemplateUtils; import org.springframework.util.Assert; +import com.couchbase.client.java.ReactiveScope; import com.couchbase.client.java.query.QueryOptions; import com.couchbase.client.java.query.QueryScanConsistency; import com.couchbase.client.java.query.ReactiveQueryResult; +import com.couchbase.client.java.transactions.AttemptContextReactiveAccessor; +import com.couchbase.client.java.transactions.TransactionQueryOptions; +import com.couchbase.client.java.transactions.TransactionQueryResult; /** * {@link ReactiveFindByQueryOperation} implementations for Couchbase. @@ -172,40 +177,51 @@ public Mono first() { public Flux all() { PseudoArgs pArgs = new PseudoArgs(template, scope, collection, options, domainType); String statement = assembleEntityQuery(false, distinctFields, pArgs.getScope(), pArgs.getCollection()); - LOG.trace("findByQuery {} statement: {}", pArgs, statement); - Mono allResult = pArgs.getScope() == null - ? template.getCouchbaseClientFactory().getCluster().reactive().query(statement, - buildOptions(pArgs.getOptions())) - : template.getCouchbaseClientFactory().withScope(pArgs.getScope()).getScope().reactive().query(statement, - buildOptions(pArgs.getOptions())); - return Flux.defer(() -> allResult.onErrorMap(throwable -> { + if (LOG.isDebugEnabled()) { + LOG.debug("findByQuery {} statement: {}", pArgs, statement); + } + CouchbaseClientFactory clientFactory = template.getCouchbaseClientFactory(); + ReactiveScope rs = clientFactory.withScope(pArgs.getScope()).getScope().reactive(); + + Mono allResult = TransactionalSupport.checkForTransactionInThreadLocalStorage().flatMap(s -> { + if (!s.isPresent()) { + QueryOptions opts = buildOptions(pArgs.getOptions()); + return pArgs.getScope() == null ? clientFactory.getCluster().reactive().query(statement, opts) + : rs.query(statement, opts); + } else { + TransactionQueryOptions opts = buildTransactionOptions(pArgs.getOptions()); + return (AttemptContextReactiveAccessor.createReactiveTransactionAttemptContext(s.get().getCore(), + clientFactory.getCluster().environment().jsonSerializer())).query(statement, opts); + } + }); + + return allResult.onErrorMap(throwable -> { if (throwable instanceof RuntimeException) { return template.potentiallyConvertRuntimeException((RuntimeException) throwable); } else { return throwable; } - }).flatMapMany(ReactiveQueryResult::rowsAsObject).flatMap(row -> { - String id = null; - Long cas = null; - if (query.isDistinct() || distinctFields != null) { - id = ""; - cas = Long.valueOf(0); - } else { - id = row.getString(TemplateUtils.SELECT_ID); - if (id == null) { - id = row.getString(TemplateUtils.SELECT_ID_3x); - row.removeKey(TemplateUtils.SELECT_ID_3x); - } - cas = row.getLong(TemplateUtils.SELECT_CAS); - if (cas == null) { - cas = row.getLong(TemplateUtils.SELECT_CAS_3x); - row.removeKey(TemplateUtils.SELECT_CAS_3x); - } - row.removeKey(TemplateUtils.SELECT_ID); - row.removeKey(TemplateUtils.SELECT_CAS); - } - return support.decodeEntity(id, row.toString(), cas, returnType, pArgs.getScope(), pArgs.getCollection()); - })); + }).flatMapMany(o -> o instanceof ReactiveQueryResult ? ((ReactiveQueryResult) o).rowsAsObject() + : Flux.fromIterable(((TransactionQueryResult) o).rowsAsObject())).flatMap(row -> { + String id = ""; + Long cas = Long.valueOf(0); + if (!query.isDistinct() && distinctFields == null) { + id = row.getString(TemplateUtils.SELECT_ID); + if (id == null) { + id = row.getString(TemplateUtils.SELECT_ID_3x); + row.removeKey(TemplateUtils.SELECT_ID_3x); + } + cas = row.getLong(TemplateUtils.SELECT_CAS); + if (cas == null) { + cas = row.getLong(TemplateUtils.SELECT_CAS_3x); + row.removeKey(TemplateUtils.SELECT_CAS_3x); + } + row.removeKey(TemplateUtils.SELECT_ID); + row.removeKey(TemplateUtils.SELECT_CAS); + } + return support.decodeEntity(id, row.toString(), cas, returnType, pArgs.getScope(), pArgs.getCollection(), + null, null); + }); } public QueryOptions buildOptions(QueryOptions options) { @@ -213,24 +229,43 @@ public QueryOptions buildOptions(QueryOptions options) { return query.buildQueryOptions(options, qsc); } + private TransactionQueryOptions buildTransactionOptions(QueryOptions options) { + TransactionQueryOptions opts = OptionsBuilder.buildTransactionQueryOptions(buildOptions(options)); + return opts; + } + @Override public Mono count() { PseudoArgs pArgs = new PseudoArgs(template, scope, collection, options, domainType); String statement = assembleEntityQuery(true, distinctFields, pArgs.getScope(), pArgs.getCollection()); - LOG.trace("findByQuery {} statement: {}", pArgs, statement); - Mono countResult = pArgs.getScope() == null - ? template.getCouchbaseClientFactory().getCluster().reactive().query(statement, - buildOptions(pArgs.getOptions())) - : template.getCouchbaseClientFactory().withScope(pArgs.getScope()).getScope().reactive().query(statement, - buildOptions(pArgs.getOptions())); - return Mono.defer(() -> countResult.onErrorMap(throwable -> { + if (LOG.isDebugEnabled()) { + LOG.debug("findByQuery {} statement: {}", pArgs, statement); + } + + CouchbaseClientFactory clientFactory = template.getCouchbaseClientFactory(); + ReactiveScope rs = clientFactory.withScope(pArgs.getScope()).getScope().reactive(); + + Mono allResult = TransactionalSupport.checkForTransactionInThreadLocalStorage().flatMap(s -> { + if (!s.isPresent()) { + QueryOptions opts = buildOptions(pArgs.getOptions()); + return pArgs.getScope() == null ? clientFactory.getCluster().reactive().query(statement, opts) + : rs.query(statement, opts); + } else { + TransactionQueryOptions opts = buildTransactionOptions(pArgs.getOptions()); + return (AttemptContextReactiveAccessor.createReactiveTransactionAttemptContext(s.get().getCore(), + clientFactory.getCluster().environment().jsonSerializer())).query(statement, opts); + } + }); + + return allResult.onErrorMap(throwable -> { if (throwable instanceof RuntimeException) { return template.potentiallyConvertRuntimeException((RuntimeException) throwable); } else { return throwable; } - }).flatMapMany(ReactiveQueryResult::rowsAsObject).map(row -> row.getLong(row.getNames().iterator().next())) - .next()); + }).flatMapMany(o -> o instanceof ReactiveQueryResult ? ((ReactiveQueryResult) o).rowsAsObject() + : Flux.fromIterable(((TransactionQueryResult) o).rowsAsObject())) + .map(row -> row.getLong(row.getNames().iterator().next())).next(); } @Override diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindFromReplicasByIdOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindFromReplicasByIdOperationSupport.java index 0fee3b9e4..f2f0880cd 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveFindFromReplicasByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveFindFromReplicasByIdOperationSupport.java @@ -31,6 +31,11 @@ import com.couchbase.client.java.codec.RawJsonTranscoder; import com.couchbase.client.java.kv.GetAnyReplicaOptions; +/** + * {@link ReactiveFindFromReplicasByIdOperation} implementations for Couchbase. + * + * @author Michael Reiche + */ public class ReactiveFindFromReplicasByIdOperationSupport implements ReactiveFindFromReplicasByIdOperation { private final ReactiveCouchbaseTemplate template; @@ -75,12 +80,14 @@ public Mono any(final String id) { garOptions.transcoder(RawJsonTranscoder.INSTANCE); } PseudoArgs pArgs = new PseudoArgs<>(template, scope, collection, garOptions, domainType); - LOG.trace("getAnyReplica key={} {}", id, pArgs); - return Mono.just(id) + if (LOG.isDebugEnabled()) { + LOG.debug("getAnyReplica key={} {}", id, pArgs); + } + return TransactionalSupport.verifyNotInTransaction("findFromReplicasById").then(Mono.just(id)) .flatMap(docId -> template.getCouchbaseClientFactory().withScope(pArgs.getScope()) .getCollection(pArgs.getCollection()).reactive().getAnyReplica(docId, pArgs.getOptions())) .flatMap(result -> support.decodeEntity(id, result.contentAs(String.class), result.cas(), returnType, - pArgs.getScope(), pArgs.getCollection())) + pArgs.getScope(), pArgs.getCollection(), null, null)) .onErrorMap(throwable -> { if (throwable instanceof RuntimeException) { return template.potentiallyConvertRuntimeException((RuntimeException) throwable); 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 516d41c0a..daa6287d2 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveInsertByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveInsertByIdOperationSupport.java @@ -15,6 +15,8 @@ */ package org.springframework.data.couchbase.core; +import static com.couchbase.client.java.transactions.internal.ConverterUtil.makeCollectionIdentifier; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -33,6 +35,11 @@ import com.couchbase.client.java.kv.PersistTo; import com.couchbase.client.java.kv.ReplicateTo; +/** + * {@link ReactiveInsertByIdOperation} implementations for Couchbase. + * + * @author Michael Reiche + */ public class ReactiveInsertByIdOperationSupport implements ReactiveInsertByIdOperation { private final ReactiveCouchbaseTemplate template; @@ -81,20 +88,48 @@ static class ReactiveInsertByIdSupport implements ReactiveInsertById { @Override public Mono one(T object) { PseudoArgs pArgs = new PseudoArgs(template, scope, collection, options, domainType); - LOG.trace("insertById object={} {}", object, pArgs); - return Mono.just(object).flatMap(support::encodeEntity) - .flatMap(converted -> template.getCouchbaseClientFactory().withScope(pArgs.getScope()) - .getCollection(pArgs.getCollection()).reactive() - .insert(converted.getId(), converted.export(), buildOptions(pArgs.getOptions(), converted)) - .flatMap(result -> support.applyUpdatedId(object, converted.getId()) - .flatMap(updatedObject -> support.applyUpdatedCas(updatedObject, converted, result.cas())))) - .onErrorMap(throwable -> { - if (throwable instanceof RuntimeException) { - return template.potentiallyConvertRuntimeException((RuntimeException) throwable); - } else { - return throwable; - } - }); + if (LOG.isDebugEnabled()) { + LOG.debug("insertById object={} {}", object, pArgs); + } + return Mono + .just(template.getCouchbaseClientFactory().withScope(pArgs.getScope()).getCollection(pArgs.getCollection())) + .flatMap(collection -> support.encodeEntity(object) + .flatMap(converted -> TransactionalSupport.checkForTransactionInThreadLocalStorage().flatMap(ctxOpt -> { + if (!ctxOpt.isPresent()) { + return collection.reactive() + .insert(converted.getId(), converted.export(), buildOptions(pArgs.getOptions(), converted)) + .flatMap(result -> this.support.applyResult(object, converted, converted.getId(), result.cas(), + null, null)); + } else { + rejectInvalidTransactionalOptions(); + return ctxOpt.get().getCore() + .insert(makeCollectionIdentifier(collection.async()), converted.getId(), + template.getCouchbaseClientFactory().getCluster().environment().transcoder() + .encode(converted.export()).encoded()) + .flatMap(result -> this.support.applyResult(object, converted, converted.getId(), result.cas(), + null, null)); + } + })).onErrorMap(throwable -> { + if (throwable instanceof RuntimeException) { + return template.potentiallyConvertRuntimeException((RuntimeException) throwable); + } else { + return throwable; + } + })); + } + + private void rejectInvalidTransactionalOptions() { + if ((this.persistTo != null && this.persistTo != PersistTo.NONE) + || (this.replicateTo != null && this.replicateTo != ReplicateTo.NONE)) { + throw new IllegalArgumentException( + "withDurability PersistTo and ReplicateTo overload is not supported in a transaction"); + } + if (this.expiry != null) { + throw new IllegalArgumentException("withExpiry is not supported in a transaction"); + } + if (this.options != null) { + throw new IllegalArgumentException("withOptions is not supported in a transaction"); + } } @Override @@ -147,6 +182,7 @@ public InsertByIdWithDurability withExpiry(final Duration expiry) { return new ReactiveInsertByIdSupport<>(template, domainType, scope, collection, options, persistTo, replicateTo, durabilityLevel, expiry, support); } + } } diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveRemoveByIdOperation.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveRemoveByIdOperation.java index a9abaa178..ecdf6a061 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveRemoveByIdOperation.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveRemoveByIdOperation.java @@ -35,6 +35,7 @@ * Remove Operations on KV service. * * @author Christoph Strobl + * @author Michael Reiche * @since 2.0 */ public interface ReactiveRemoveByIdOperation { @@ -63,6 +64,14 @@ interface TerminatingRemoveById extends OneAndAllIdReactive { @Override Mono one(String id); + /** + * Remove one document. Requires whole entity for transaction to have the cas. + * + * @param entity the entity + * @return result of the remove + */ + Mono oneEntity(Object entity); + /** * Remove the documents in the collection. * @@ -72,6 +81,14 @@ interface TerminatingRemoveById extends OneAndAllIdReactive { @Override Flux all(Collection ids); + /** + * Remove the documents in the collection. Requires whole entity for transaction to have the cas. + * + * @param ids the document IDs. + * @return result of the removes. + */ + Flux allEntities(Collection ids); + } /** diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveRemoveByIdOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveRemoveByIdOperationSupport.java index d59de09cc..9149f3616 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveRemoveByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveRemoveByIdOperationSupport.java @@ -15,6 +15,8 @@ */ package org.springframework.data.couchbase.core; +import static com.couchbase.client.java.transactions.internal.ConverterUtil.makeCollectionIdentifier; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -22,15 +24,24 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.data.couchbase.CouchbaseClientFactory; import org.springframework.data.couchbase.core.query.OptionsBuilder; import org.springframework.data.couchbase.core.support.PseudoArgs; import org.springframework.util.Assert; import com.couchbase.client.core.msg.kv.DurabilityLevel; +import com.couchbase.client.core.transaction.CoreTransactionAttemptContext; +import com.couchbase.client.core.transaction.CoreTransactionGetResult; +import com.couchbase.client.java.ReactiveCollection; import com.couchbase.client.java.kv.PersistTo; import com.couchbase.client.java.kv.RemoveOptions; import com.couchbase.client.java.kv.ReplicateTo; +/** + * {@link ReactiveRemoveByIdOperation} implementations for Couchbase. + * + * @author Michael Reiche + */ public class ReactiveRemoveByIdOperationSupport implements ReactiveRemoveByIdOperation { private final ReactiveCouchbaseTemplate template; @@ -82,18 +93,57 @@ static class ReactiveRemoveByIdSupport implements ReactiveRemoveById { @Override public Mono one(final String id) { PseudoArgs pArgs = new PseudoArgs<>(template, scope, collection, options, domainType); - LOG.trace("removeById key={} {}", id, pArgs); - return Mono.just(id) - .flatMap(docId -> template.getCouchbaseClientFactory().withScope(pArgs.getScope()) - .getCollection(pArgs.getCollection()).reactive().remove(id, buildRemoveOptions(pArgs.getOptions())) - .map(r -> RemoveResult.from(docId, r))) - .onErrorMap(throwable -> { - if (throwable instanceof RuntimeException) { - return template.potentiallyConvertRuntimeException((RuntimeException) throwable); - } else { - return throwable; + if (LOG.isDebugEnabled()) { + LOG.debug("removeById key={} {}", id, pArgs); + } + CouchbaseClientFactory clientFactory = template.getCouchbaseClientFactory(); + ReactiveCollection rc = clientFactory.withScope(pArgs.getScope()).getCollection(pArgs.getCollection()).reactive(); + + return TransactionalSupport.checkForTransactionInThreadLocalStorage().flatMap(s -> { + if (!s.isPresent()) { + return rc.remove(id, buildRemoveOptions(pArgs.getOptions())).map(r -> RemoveResult.from(id, r)); + } else { + rejectInvalidTransactionalOptions(); + + if (cas == null || cas == 0) { + throw new IllegalArgumentException("cas must be supplied for tx remove"); + } + CoreTransactionAttemptContext ctx = s.get().getCore(); + Mono gr = ctx.get(makeCollectionIdentifier(rc.async()), id); + + return gr.flatMap(getResult -> { + if (getResult.cas() != cas) { + return Mono.error(TransactionalSupport.retryTransactionOnCasMismatch(ctx, getResult.cas(), cas)); } + return ctx.remove(getResult).map(r -> new RemoveResult(id, 0, null)); }); + + } + }).onErrorMap(throwable -> { + if (throwable instanceof RuntimeException) { + return template.potentiallyConvertRuntimeException((RuntimeException) throwable); + } else { + return throwable; + } + }); + } + + private void rejectInvalidTransactionalOptions() { + if ((this.persistTo != null && this.persistTo != PersistTo.NONE) + || (this.replicateTo != null && this.replicateTo != ReplicateTo.NONE)) { + throw new IllegalArgumentException( + "withDurability PersistTo and ReplicateTo overload is not supported in a transaction"); + } + if (this.options != null) { + throw new IllegalArgumentException("withOptions is not supported in a transaction"); + } + } + + @Override + public Mono oneEntity(Object entity) { + ReactiveRemoveByIdSupport op = new ReactiveRemoveByIdSupport(template, domainType, scope, collection, options, + persistTo, replicateTo, durabilityLevel, template.support().getCas(entity)); + return op.one(template.support().getId(entity).toString()); } @Override @@ -101,6 +151,11 @@ public Flux all(final Collection ids) { return Flux.fromIterable(ids).flatMap(this::one); } + @Override + public Flux allEntities(Collection entities) { + return Flux.fromIterable(entities).flatMap(this::oneEntity); + } + private RemoveOptions buildRemoveOptions(RemoveOptions options) { return OptionsBuilder.buildRemoveOptions(options, persistTo, replicateTo, durabilityLevel, cas); } @@ -144,6 +199,7 @@ public RemoveByIdWithDurability withCas(Long cas) { return new ReactiveRemoveByIdSupport(template, domainType, scope, collection, options, persistTo, replicateTo, durabilityLevel, cas); } + } } diff --git a/src/main/java/org/springframework/data/couchbase/core/ReactiveRemoveByQueryOperationSupport.java b/src/main/java/org/springframework/data/couchbase/core/ReactiveRemoveByQueryOperationSupport.java index 4358294bb..f5f740594 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveRemoveByQueryOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveRemoveByQueryOperationSupport.java @@ -16,22 +16,31 @@ package org.springframework.data.couchbase.core; import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.data.couchbase.CouchbaseClientFactory; import org.springframework.data.couchbase.core.query.OptionsBuilder; import org.springframework.data.couchbase.core.query.Query; import org.springframework.data.couchbase.core.support.PseudoArgs; import org.springframework.data.couchbase.core.support.TemplateUtils; import org.springframework.util.Assert; +import com.couchbase.client.core.deps.com.fasterxml.jackson.databind.node.ObjectNode; +import com.couchbase.client.java.ReactiveScope; +import com.couchbase.client.java.json.JsonObject; import com.couchbase.client.java.query.QueryOptions; import com.couchbase.client.java.query.QueryScanConsistency; import com.couchbase.client.java.query.ReactiveQueryResult; +import com.couchbase.client.java.transactions.TransactionQueryOptions; +/** + * {@link ReactiveRemoveByQueryOperation} implementations for Couchbase. + * + * @author Michael Reiche + */ public class ReactiveRemoveByQueryOperationSupport implements ReactiveRemoveByQueryOperation { private static final Query ALL_QUERY = new Query(); @@ -74,21 +83,34 @@ static class ReactiveRemoveByQuerySupport implements ReactiveRemoveByQuery public Flux all() { PseudoArgs pArgs = new PseudoArgs<>(template, scope, collection, options, domainType); String statement = assembleDeleteQuery(pArgs.getScope(), pArgs.getCollection()); - LOG.trace("removeByQuery {} statement: {}", pArgs, statement); - Mono allResult = pArgs.getScope() == null - ? template.getCouchbaseClientFactory().getCluster().reactive().query(statement, - buildQueryOptions(pArgs.getOptions())) - : template.getCouchbaseClientFactory().withScope(pArgs.getScope()).getScope().reactive().query(statement, - buildQueryOptions(pArgs.getOptions())); - return Flux.defer(() -> allResult.onErrorMap(throwable -> { - if (throwable instanceof RuntimeException) { - return template.potentiallyConvertRuntimeException((RuntimeException) throwable); + if (LOG.isDebugEnabled()) { + LOG.debug("removeByQuery {} statement: {}", pArgs, statement); + } + CouchbaseClientFactory clientFactory = template.getCouchbaseClientFactory(); + ReactiveScope rs = clientFactory.withScope(pArgs.getScope()).getScope().reactive(); + + return TransactionalSupport.checkForTransactionInThreadLocalStorage().flatMapMany(transactionContext -> { + + if (!transactionContext.isPresent()) { + QueryOptions opts = buildQueryOptions(pArgs.getOptions()); + return (pArgs.getScope() == null ? clientFactory.getCluster().reactive().query(statement, opts) + : rs.query(statement, opts)).flatMapMany(ReactiveQueryResult::rowsAsObject) + .map(row -> new RemoveResult(row.getString(TemplateUtils.SELECT_ID), + row.getLong(TemplateUtils.SELECT_CAS), Optional.empty())); } else { - return throwable; + TransactionQueryOptions opts = OptionsBuilder + .buildTransactionQueryOptions(buildQueryOptions(pArgs.getOptions())); + ObjectNode convertedOptions = com.couchbase.client.java.transactions.internal.OptionsUtil + .createTransactionOptions(pArgs.getScope() == null ? null : rs, statement, opts); + return transactionContext.get().getCore() + .queryBlocking(statement, template.getBucketName(), pArgs.getScope(), convertedOptions, false) + .flatMapIterable(result -> result.rows).map(row -> { + JsonObject json = JsonObject.fromJson(row.data()); + return new RemoveResult(json.getString(TemplateUtils.SELECT_ID), json.getLong(TemplateUtils.SELECT_CAS), + Optional.empty()); + }); } - }).flatMapMany(ReactiveQueryResult::rowsAsObject) - .map(row -> new RemoveResult(row.getString(TemplateUtils.SELECT_ID), row.getLong(TemplateUtils.SELECT_CAS), - Optional.empty()))); + }); } private QueryOptions buildQueryOptions(QueryOptions options) { @@ -138,6 +160,7 @@ public RemoveByQueryInCollection inScope(final String scope) { return new ReactiveRemoveByQuerySupport<>(template, domainType, query, scanConsistency, scope != null ? scope : this.scope, collection, options); } + } } 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 246d76f46..5cb54ce0a 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveReplaceByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveReplaceByIdOperationSupport.java @@ -15,6 +15,8 @@ */ package org.springframework.data.couchbase.core; +import static com.couchbase.client.java.transactions.internal.ConverterUtil.makeCollectionIdentifier; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -28,11 +30,20 @@ import org.springframework.data.couchbase.core.support.PseudoArgs; import org.springframework.util.Assert; +import com.couchbase.client.core.io.CollectionIdentifier; import com.couchbase.client.core.msg.kv.DurabilityLevel; +import com.couchbase.client.core.transaction.CoreTransactionAttemptContext; +import com.couchbase.client.core.transaction.CoreTransactionGetResult; +import com.couchbase.client.core.transaction.util.DebugUtil; import com.couchbase.client.java.kv.PersistTo; import com.couchbase.client.java.kv.ReplaceOptions; import com.couchbase.client.java.kv.ReplicateTo; +/** + * {@link ReactiveReplaceByIdOperation} implementations for Couchbase. + * + * @author Michael Reiche + */ public class ReactiveReplaceByIdOperationSupport implements ReactiveReplaceByIdOperation { private final ReactiveCouchbaseTemplate template; @@ -81,20 +92,63 @@ static class ReactiveReplaceByIdSupport implements ReactiveReplaceById { @Override public Mono one(T object) { PseudoArgs pArgs = new PseudoArgs<>(template, scope, collection, options, domainType); - LOG.trace("replaceById object={} {}", object, pArgs); - return Mono.just(object).flatMap(support::encodeEntity) - .flatMap(converted -> template.getCouchbaseClientFactory().withScope(pArgs.getScope()) - .getCollection(pArgs.getCollection()).reactive() - .replace(converted.getId(), converted.export(), - buildReplaceOptions(pArgs.getOptions(), object, converted)) - .flatMap(result -> support.applyUpdatedCas(object, converted, result.cas()))) - .onErrorMap(throwable -> { - if (throwable instanceof RuntimeException) { - return template.potentiallyConvertRuntimeException((RuntimeException) throwable); - } else { - return throwable; - } - }); + if (LOG.isDebugEnabled()) { + LOG.debug("replaceById object={} {}", object, pArgs); + } + return Mono + .just(template.getCouchbaseClientFactory().withScope(pArgs.getScope()).getCollection(pArgs.getCollection())) + .flatMap(collection -> support.encodeEntity(object) + .flatMap(converted -> TransactionalSupport.checkForTransactionInThreadLocalStorage().flatMap(ctxOpt -> { + if (!ctxOpt.isPresent()) { + return collection.reactive() + .replace(converted.getId(), converted.export(), + buildReplaceOptions(pArgs.getOptions(), object, converted)) + .flatMap(result -> support.applyResult(object, converted, converted.getId(), result.cas(), null, + null)); + } else { + rejectInvalidTransactionalOptions(); + + Long cas = support.getCas(object); + if (cas == null || cas == 0) { + throw new IllegalArgumentException( + "cas must be supplied in object for tx replace. object=" + object); + } + + CollectionIdentifier collId = makeCollectionIdentifier(collection.async()); + CoreTransactionAttemptContext ctx = ctxOpt.get().getCore(); + ctx.logger().info(ctx.attemptId(), "refetching %s for Spring replace", + DebugUtil.docId(collId, converted.getId())); + Mono gr = ctx.get(collId, converted.getId()); + + return gr.flatMap(getResult -> { + if (getResult.cas() != cas) { + return Mono.error(TransactionalSupport.retryTransactionOnCasMismatch(ctx, getResult.cas(), cas)); + } + return ctx.replace(getResult, template.getCouchbaseClientFactory().getCluster().environment() + .transcoder().encode(converted.export()).encoded()); + }).flatMap(result -> support.applyResult(object, converted, converted.getId(), result.cas(), null, null)); + } + })).onErrorMap(throwable -> { + if (throwable instanceof RuntimeException) { + return template.potentiallyConvertRuntimeException((RuntimeException) throwable); + } else { + return throwable; + } + })); + } + + private void rejectInvalidTransactionalOptions() { + if ((this.persistTo != null && this.persistTo != PersistTo.NONE) + || (this.replicateTo != null && this.replicateTo != ReplicateTo.NONE)) { + throw new IllegalArgumentException( + "withDurability PersistTo and ReplicateTo overload is not supported in a transaction"); + } + if (this.expiry != null) { + throw new IllegalArgumentException("withExpiry is not supported in a transaction"); + } + if (this.options != null) { + throw new IllegalArgumentException("withOptions is not supported in a transaction"); + } } @Override 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 69d5db015..6acaa2f4e 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveTemplateSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveTemplateSupport.java @@ -17,25 +17,30 @@ import reactor.core.publisher.Mono; +import org.springframework.data.couchbase.core.convert.translation.TranslationService; import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; -import org.springframework.data.couchbase.core.mapping.event.CouchbaseMappingEvent; +import org.springframework.data.couchbase.transaction.CouchbaseResourceHolder; /** + * ReactiveTemplateSupport + * * @author Michael Reiche */ public interface ReactiveTemplateSupport { Mono encodeEntity(Object entityToEncode); - Mono decodeEntity(String id, String source, Long cas, Class entityClass, String scope, String collection); - - Mono applyUpdatedCas(T entity, CouchbaseDocument converted, long cas); + Mono decodeEntity(String id, String source, Long cas, Class entityClass, String scope, String collection, + Object txResultHolder, CouchbaseResourceHolder holder); - Mono applyUpdatedId(T entity, Object id); + Mono applyResult(T entity, CouchbaseDocument converted, Object id, Long cas, + Object txResultHolder, CouchbaseResourceHolder holder); Long getCas(Object entity); + Object getId(Object entity); + String getJavaNameForEntity(Class clazz); - void maybeEmitEvent(CouchbaseMappingEvent event); + TranslationService getTranslationService(); } 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 997605983..dbf27bb3c 100644 --- a/src/main/java/org/springframework/data/couchbase/core/ReactiveUpsertByIdOperationSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/ReactiveUpsertByIdOperationSupport.java @@ -33,6 +33,11 @@ import com.couchbase.client.java.kv.ReplicateTo; import com.couchbase.client.java.kv.UpsertOptions; +/** + * {@link ReactiveUpsertByIdOperation} implementations for Couchbase. + * + * @author Michael Reiche + */ public class ReactiveUpsertByIdOperationSupport implements ReactiveUpsertByIdOperation { private final ReactiveCouchbaseTemplate template; @@ -81,20 +86,27 @@ static class ReactiveUpsertByIdSupport implements ReactiveUpsertById { @Override public Mono one(T object) { PseudoArgs pArgs = new PseudoArgs(template, scope, collection, options, domainType); - LOG.trace("upsertById object={} {}", object, pArgs); - return Mono.just(object).flatMap(support::encodeEntity) - .flatMap(converted -> template.getCouchbaseClientFactory().withScope(pArgs.getScope()) - .getCollection(pArgs.getCollection()).reactive() - .upsert(converted.getId(), converted.export(), buildUpsertOptions(pArgs.getOptions(), converted)) - .flatMap(result -> support.applyUpdatedId(object, converted.getId()) - .flatMap(updatedObject -> support.applyUpdatedCas(updatedObject, converted, result.cas())))) - .onErrorMap(throwable -> { - if (throwable instanceof RuntimeException) { - return template.potentiallyConvertRuntimeException((RuntimeException) throwable); - } else { - return throwable; - } + if (LOG.isDebugEnabled()) { + LOG.debug("upsertById object={} {}", object, pArgs); + } + Mono reactiveEntity = TransactionalSupport.verifyNotInTransaction("upsertById") + .then(support.encodeEntity(object)).flatMap(converted -> { + return Mono + .just(template.getCouchbaseClientFactory().withScope(pArgs.getScope()) + .getCollection(pArgs.getCollection())) + .flatMap(collection -> collection.reactive() + .upsert(converted.getId(), converted.export(), buildUpsertOptions(pArgs.getOptions(), converted)) + .flatMap( + result -> support.applyResult(object, converted, converted.getId(), result.cas(), null, null))); }); + + return reactiveEntity.onErrorMap(throwable -> { + if (throwable instanceof RuntimeException) { + return template.potentiallyConvertRuntimeException((RuntimeException) throwable); + } else { + return throwable; + } + }); } @Override 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 bd5ab0ae9..273c67d88 100644 --- a/src/main/java/org/springframework/data/couchbase/core/TemplateSupport.java +++ b/src/main/java/org/springframework/data/couchbase/core/TemplateSupport.java @@ -15,26 +15,30 @@ */ package org.springframework.data.couchbase.core; +import org.springframework.data.couchbase.core.convert.translation.TranslationService; import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; -import org.springframework.data.couchbase.core.mapping.event.CouchbaseMappingEvent; +import org.springframework.data.couchbase.transaction.CouchbaseResourceHolder; /** - * * @author Michael Reiche */ public interface TemplateSupport { CouchbaseDocument encodeEntity(Object entityToEncode); - T decodeEntity(String id, String source, Long cas, Class entityClass, String scope, String collection); + T decodeEntity(String id, String source, Long cas, Class entityClass, String scope, String collection, + Object txResultHolder, CouchbaseResourceHolder holder); - T applyUpdatedCas(T entity, CouchbaseDocument converted, long cas); + T applyResult(T entity, CouchbaseDocument converted, Object id, long cas, Object txResultHolder, + CouchbaseResourceHolder holder); - T applyUpdatedId(T entity, Object id); + Long getCas(Object entity); - long getCas(Object entity); + Object getId(Object entity); String getJavaNameForEntity(Class clazz); - void maybeEmitEvent(CouchbaseMappingEvent event); + Integer getTxResultHolder(T source); + + TranslationService getTranslationService(); } diff --git a/src/main/java/org/springframework/data/couchbase/core/TransactionalSupport.java b/src/main/java/org/springframework/data/couchbase/core/TransactionalSupport.java new file mode 100644 index 000000000..6b56e8647 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/TransactionalSupport.java @@ -0,0 +1,75 @@ +/* + * Copyright 2022 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; + +import reactor.core.publisher.Mono; + +import java.util.Optional; + +import org.springframework.data.couchbase.transaction.CouchbaseResourceHolder; + +import com.couchbase.client.core.annotation.Stability; +import com.couchbase.client.core.error.CasMismatchException; +import com.couchbase.client.core.error.transaction.TransactionOperationFailedException; +import com.couchbase.client.core.transaction.CoreTransactionAttemptContext; +import com.couchbase.client.core.transaction.threadlocal.TransactionMarkerOwner; + +/** + * Utility methods to support transactions. + * + * @author Graham Pople + */ +@Stability.Internal +public class TransactionalSupport { + + /** + * Returns non-empty iff in a transaction. It determines this from thread-local storage and/or reactive context. + *

+ * The user could be doing a reactive operation (with .block()) inside a blocking transaction (like @Transactional). + * Or a blocking operation inside a ReactiveTransactionsWrapper transaction (which would be a bad idea). So, need to + * check both thread-local storage and reactive context. + */ + public static Mono> checkForTransactionInThreadLocalStorage() { + return TransactionMarkerOwner.get().flatMap(markerOpt -> { + Optional out = markerOpt + .flatMap(marker -> Optional.of(new CouchbaseResourceHolder(marker.context()))); + return Mono.just(out); + }); + } + + public static Mono verifyNotInTransaction(String methodName) { + return checkForTransactionInThreadLocalStorage().flatMap(s -> { + if (s.isPresent()) { + return Mono.error(new IllegalArgumentException(methodName + "can not be used inside a transaction")); + } else { + return Mono.empty(); + } + }); + } + + public static RuntimeException retryTransactionOnCasMismatch(CoreTransactionAttemptContext ctx, long cas1, + long cas2) { + try { + ctx.logger().info(ctx.attemptId(), "Spring CAS mismatch %s != %s, retrying transaction", cas1, cas2); + TransactionOperationFailedException err = TransactionOperationFailedException.Builder.createError() + .retryTransaction().cause(new CasMismatchException(null)).build(); + return ctx.operationFailed(err); + } catch (Throwable err) { + return new RuntimeException(err); + } + + } +} diff --git a/src/main/java/org/springframework/data/couchbase/core/query/OptionsBuilder.java b/src/main/java/org/springframework/data/couchbase/core/query/OptionsBuilder.java index 20fe310d5..6214bcbca 100644 --- a/src/main/java/org/springframework/data/couchbase/core/query/OptionsBuilder.java +++ b/src/main/java/org/springframework/data/couchbase/core/query/OptionsBuilder.java @@ -24,6 +24,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.time.Duration; +import java.util.Map; import java.util.Optional; import org.slf4j.Logger; @@ -49,7 +50,13 @@ import com.couchbase.client.java.kv.UpsertOptions; import com.couchbase.client.java.query.QueryOptions; import com.couchbase.client.java.query.QueryScanConsistency; +import com.couchbase.client.java.transactions.TransactionQueryOptions; +/** + * Methods for building Options objects for Couchbae APIs. + * + * @author Michael Reiche + */ public class OptionsBuilder { private static final Logger LOG = LoggerFactory.getLogger(OptionsBuilder.class); @@ -70,8 +77,8 @@ static QueryOptions buildQueryOptions(Query query, QueryOptions options, QuerySc QueryScanConsistency metaQueryScanConsistency = meta.get(SCAN_CONSISTENCY) != null ? ((ScanConsistency) meta.get(SCAN_CONSISTENCY)).query() : null; - QueryScanConsistency qsc = fromFirst(QueryScanConsistency.NOT_BOUNDED, getScanConsistency(optsJson), - scanConsistency, metaQueryScanConsistency); + QueryScanConsistency qsc = fromFirst(QueryScanConsistency.NOT_BOUNDED, query.getScanConsistency(), + getScanConsistency(optsJson), scanConsistency, metaQueryScanConsistency); Duration timeout = fromFirst(Duration.ofSeconds(0), getTimeout(optsBuilt), meta.get(TIMEOUT)); RetryStrategy retryStrategy = fromFirst(null, getRetryStrategy(optsBuilt), meta.get(RETRY_STRATEGY)); @@ -84,12 +91,32 @@ static QueryOptions buildQueryOptions(Query query, QueryOptions options, QuerySc if (retryStrategy != null) { options.retryStrategy(retryStrategy); } - if (LOG.isTraceEnabled()) { - LOG.trace("query options: {}", getQueryOpts(options.build())); + if (LOG.isDebugEnabled()) { + LOG.debug("query options: {}", getQueryOpts(options.build())); } return options; } + public static TransactionQueryOptions buildTransactionQueryOptions(QueryOptions options) { + QueryOptions.Built built = options.build(); + TransactionQueryOptions txOptions = TransactionQueryOptions.queryOptions(); + + JsonObject optsJson = getQueryOpts(built); + + if (optsJson.containsKey("use_fts")) { + throw new IllegalArgumentException("QueryOptions.flexIndex is not supported in a transaction"); + } + + for (Map.Entry entry : optsJson.toMap().entrySet()) { + txOptions.raw(entry.getKey(), entry.getValue()); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("query options: {}", optsJson); + } + return txOptions; + } + public static ExistsOptions buildExistsOptions(ExistsOptions options) { options = options != null ? options : ExistsOptions.existsOptions(); return options; @@ -108,8 +135,8 @@ public static InsertOptions buildInsertOptions(InsertOptions options, PersistTo } else if (doc.getExpiration() != 0) { options.expiry(Duration.ofSeconds(doc.getExpiration())); } - if (LOG.isTraceEnabled()) { - LOG.trace("insert options: {}" + toString(options)); + if (LOG.isDebugEnabled()) { + LOG.debug("insert options: {}" + toString(options)); } return options; } @@ -127,8 +154,8 @@ public static UpsertOptions buildUpsertOptions(UpsertOptions options, PersistTo } else if (doc.getExpiration() != 0) { options.expiry(Duration.ofSeconds(doc.getExpiration())); } - if (LOG.isTraceEnabled()) { - LOG.trace("upsert options: {}" + toString(options)); + if (LOG.isDebugEnabled()) { + LOG.debug("upsert options: {}" + toString(options)); } return options; } @@ -149,8 +176,8 @@ public static ReplaceOptions buildReplaceOptions(ReplaceOptions options, Persist if (cas != null) { options.cas(cas); } - if (LOG.isTraceEnabled()) { - LOG.trace("replace options: {}" + toString(options)); + if (LOG.isDebugEnabled()) { + LOG.debug("replace options: {}" + toString(options)); } return options; } @@ -176,14 +203,14 @@ public static RemoveOptions buildRemoveOptions(RemoveOptions options, PersistTo if (cas != null) { options.cas(cas); } - if (LOG.isTraceEnabled()) { - LOG.trace("remove options: {}", toString(options)); + if (LOG.isDebugEnabled()) { + LOG.debug("remove options: {}", toString(options)); } return options; } /** - * scope annotation could be a + * scope annotation * * @param domainType * @return @@ -199,6 +226,12 @@ public static String getScopeFrom(Class domainType) { return null; } + /** + * collection annotation + * + * @param domainType + * @return + */ public static String getCollectionFrom(Class domainType) { if (domainType == null) { return null; @@ -423,4 +456,5 @@ public static String annotationString(Class annotation AnnotatedElement[] elements) { return annotationString(annotation, "value", defaultValue, elements); } + } diff --git a/src/main/java/org/springframework/data/couchbase/core/support/PseudoArgs.java b/src/main/java/org/springframework/data/couchbase/core/support/PseudoArgs.java index cb9ac9c92..d70f6390d 100644 --- a/src/main/java/org/springframework/data/couchbase/core/support/PseudoArgs.java +++ b/src/main/java/org/springframework/data/couchbase/core/support/PseudoArgs.java @@ -19,13 +19,13 @@ import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; +import com.couchbase.client.core.error.CouchbaseException; import com.couchbase.client.core.io.CollectionIdentifier; /** - * determine the arguments to be used in the operation from various sources + * Determine the arguments to be used in the operation from various sources * * @author Michael Reiche - * * @param */ public class PseudoArgs { @@ -96,7 +96,7 @@ public PseudoArgs(ReactiveCouchbaseTemplate template, String scope, String colle // if a collection was specified but no scope, use the scope from the clientFactory if (collectionForQuery != null && scopeForQuery == null) { - scopeForQuery = template.getCouchbaseClientFactory().getScope().name(); + scopeForQuery = template.getScopeName(); } // specifying scope and collection = _default is not necessary and will fail if server doesn't have collections @@ -110,6 +110,10 @@ public PseudoArgs(ReactiveCouchbaseTemplate template, String scope, String colle this.scopeName = scopeForQuery; this.collectionName = collectionForQuery; + if (scopeForQuery != null && collectionForQuery == null) { + throw new CouchbaseException( + new IllegalArgumentException("if scope is not default or null, then collection must be specified")); + } this.options = optionsForQuery; } @@ -139,4 +143,5 @@ public String getCollection() { public String toString() { return "scope: " + getScope() + " collection: " + getCollection() + " options: " + getOptions(); } + } diff --git a/src/main/java/org/springframework/data/couchbase/repository/support/CouchbaseRepositoryBase.java b/src/main/java/org/springframework/data/couchbase/repository/support/CouchbaseRepositoryBase.java index 777aa0d6b..0fd7f5cbc 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/support/CouchbaseRepositoryBase.java +++ b/src/main/java/org/springframework/data/couchbase/repository/support/CouchbaseRepositoryBase.java @@ -27,6 +27,14 @@ import com.couchbase.client.core.io.CollectionIdentifier; import com.couchbase.client.java.query.QueryScanConsistency; +/** + * Common base for SimpleCouchbaseRepository and SimpleReactiveCouchbaseRepository + * + * @param + * @param + * + * @author Michael Reiche + */ public class CouchbaseRepositoryBase { /** @@ -60,7 +68,7 @@ Class getJavaType() { } String getId(S entity) { - return getEntityInformation().getId(entity); + return String.valueOf(getEntityInformation().getId(entity)); } /** diff --git a/src/main/java/org/springframework/data/couchbase/repository/support/DynamicInvocationHandler.java b/src/main/java/org/springframework/data/couchbase/repository/support/DynamicInvocationHandler.java index dedba009d..783593a6d 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/support/DynamicInvocationHandler.java +++ b/src/main/java/org/springframework/data/couchbase/repository/support/DynamicInvocationHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 the original author or authors. + * Copyright 2021-2022 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. @@ -44,7 +44,7 @@ public class DynamicInvocationHandler implements InvocationHandler { final ReactiveCouchbaseTemplate reactiveTemplate; CommonOptions options; String collection; - String scope;; + String scope; public DynamicInvocationHandler(T target, CommonOptions options, String collection, String scope) { this.target = target; @@ -52,10 +52,12 @@ public DynamicInvocationHandler(T target, CommonOptions options, String colle reactiveTemplate = ((CouchbaseTemplate) ((CouchbaseRepository) target).getOperations()).reactive(); this.entityInformation = ((CouchbaseRepository) target).getEntityInformation(); } else if (target instanceof ReactiveCouchbaseRepository) { - reactiveTemplate = (ReactiveCouchbaseTemplate) ((ReactiveCouchbaseRepository) target).getOperations(); - this.entityInformation = ((ReactiveCouchbaseRepository) target).getEntityInformation(); + reactiveTemplate = (ReactiveCouchbaseTemplate) ((ReactiveCouchbaseRepository) this.target).getOperations(); + this.entityInformation = ((ReactiveCouchbaseRepository) this.target).getEntityInformation(); } else { - throw new RuntimeException("Unknown target type: " + target.getClass()); + throw new RuntimeException("Unknown target type: " + target.getClass() + + " CouchbaseRepository.class.isAssignable:" + CouchbaseRepository.class.isAssignableFrom(target.getClass()) + + " " + dumpInterfaces(target.getClass(), " ")); } this.options = options; this.collection = collection; @@ -63,6 +65,18 @@ public DynamicInvocationHandler(T target, CommonOptions options, String colle this.repositoryClass = target.getClass(); } + String dumpInterfaces(Class clazz, String tab) { + StringBuffer sb = new StringBuffer(); + sb.append(tab + "{"); + for (Class c : clazz.getInterfaces()) { + sb.append(tab + " " + c.getSimpleName()); + if (c.getInterfaces().length > 0) + sb.append(dumpInterfaces(c, tab + " ")); + } + sb.append(tab + "}"); + return sb.toString(); + } + @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { diff --git a/src/main/java/org/springframework/data/couchbase/repository/support/SimpleCouchbaseRepository.java b/src/main/java/org/springframework/data/couchbase/repository/support/SimpleCouchbaseRepository.java index 4632e33e3..4ce408c8d 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/support/SimpleCouchbaseRepository.java +++ b/src/main/java/org/springframework/data/couchbase/repository/support/SimpleCouchbaseRepository.java @@ -35,9 +35,9 @@ import org.springframework.data.util.StreamUtils; import org.springframework.data.util.Streamable; import org.springframework.util.Assert; -import org.springframework.util.ReflectionUtils; import com.couchbase.client.java.query.QueryScanConsistency; +import org.springframework.util.ReflectionUtils; /** * Repository base implementation for Couchbase. @@ -71,28 +71,7 @@ public SimpleCouchbaseRepository(CouchbaseEntityInformation entityInf @Override @SuppressWarnings("unchecked") public S save(S entity) { - Assert.notNull(entity, "Entity must not be null!"); - S result; - - final CouchbasePersistentEntity mapperEntity = operations.getConverter().getMappingContext() - .getPersistentEntity(entity.getClass()); - final CouchbasePersistentProperty versionProperty = mapperEntity.getVersionProperty(); - final boolean versionPresent = versionProperty != null; - final Long version = versionProperty == null || versionProperty.getField() == null ? null - : (Long) ReflectionUtils.getField(versionProperty.getField(), entity); - final boolean existingDocument = version != null && version > 0; - - if (!versionPresent) { // the entity doesn't have a version property - // No version field - no cas - result = (S) operations.upsertById(getJavaType()).inScope(getScope()).inCollection(getCollection()).one(entity); - } else if (existingDocument) { // there is a version property, and it is non-zero - // Updating existing document with cas - result = (S) operations.replaceById(getJavaType()).inScope(getScope()).inCollection(getCollection()).one(entity); - } else { // there is a version property, but it's zero or not set. - // Creating new document - result = (S) operations.insertById(getJavaType()).inScope(getScope()).inCollection(getCollection()).one(entity); - } - return result; + return operations.save(entity, getScope(), getCollection()); } @Override diff --git a/src/main/java/org/springframework/data/couchbase/repository/support/SimpleReactiveCouchbaseRepository.java b/src/main/java/org/springframework/data/couchbase/repository/support/SimpleReactiveCouchbaseRepository.java index d986fd747..b23f9d3b4 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/support/SimpleReactiveCouchbaseRepository.java +++ b/src/main/java/org/springframework/data/couchbase/repository/support/SimpleReactiveCouchbaseRepository.java @@ -97,27 +97,7 @@ public Flux saveAll(Publisher entityStream) { @SuppressWarnings("unchecked") private Mono save(S entity, String scope, String collection) { - Assert.notNull(entity, "Entity must not be null!"); - Mono result; - final CouchbasePersistentEntity mapperEntity = operations.getConverter().getMappingContext() - .getPersistentEntity(entity.getClass()); - final CouchbasePersistentProperty versionProperty = mapperEntity.getVersionProperty(); - final boolean versionPresent = versionProperty != null; - final Long version = versionProperty == null || versionProperty.getField() == null ? null - : (Long) ReflectionUtils.getField(versionProperty.getField(), entity); - final boolean existingDocument = version != null && version > 0; - - if (!versionPresent) { // the entity doesn't have a version property - // No version field - no cas - result = (Mono) operations.upsertById(getJavaType()).inScope(scope).inCollection(collection).one(entity); - } else if (existingDocument) { // there is a version property, and it is non-zero - // Updating existing document with cas - result = (Mono) operations.replaceById(getJavaType()).inScope(scope).inCollection(collection).one(entity); - } else { // there is a version property, but it's zero or not set. - // Creating new document - result = (Mono) operations.insertById(getJavaType()).inScope(scope).inCollection(collection).one(entity); - } - return result; + return operations.save(entity, scope, collection); } @Override @@ -202,7 +182,7 @@ public Mono delete(T entity) { private Mono delete(T entity, String scope, String collection) { Assert.notNull(entity, "Entity must not be null!"); - return operations.removeById(getJavaType()).inScope(scope).inCollection(collection).one(getId(entity)).then(); + return operations.removeById(getJavaType()).inScope(scope).inCollection(collection).oneEntity(entity).then(); } @Override @@ -214,7 +194,7 @@ public Mono deleteAllById(Iterable ids) { @Override public Mono deleteAll(Iterable entities) { return operations.removeById(getJavaType()).inScope(getScope()).inCollection(getCollection()) - .all(Streamable.of(entities).map(this::getId).toList()).then(); + .allEntities((java.util.Collection) (Streamable.of(entities).toList())).then(); } @Override diff --git a/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseCallbackTransactionManager.java b/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseCallbackTransactionManager.java new file mode 100644 index 000000000..e6befd9d9 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseCallbackTransactionManager.java @@ -0,0 +1,289 @@ +/* + * Copyright 2022 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.transaction; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.TransactionalSupport; +import org.springframework.data.couchbase.transaction.error.TransactionRollbackRequestedException; +import org.springframework.data.couchbase.transaction.error.TransactionSystemAmbiguousException; +import org.springframework.data.couchbase.transaction.error.TransactionSystemUnambiguousException; +import org.springframework.lang.Nullable; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.ReactiveTransaction; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionException; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.CallbackPreferringPlatformTransactionManager; +import org.springframework.transaction.support.TransactionCallback; + +import com.couchbase.client.core.annotation.Stability; +import com.couchbase.client.java.transactions.TransactionResult; +import com.couchbase.client.java.transactions.config.TransactionOptions; +import com.couchbase.client.java.transactions.error.TransactionCommitAmbiguousException; +import com.couchbase.client.java.transactions.error.TransactionFailedException; + +/** + * The Couchbase transaction manager, providing support for @Transactional methods. + * + * @author Graham Pople + */ +public class CouchbaseCallbackTransactionManager implements CallbackPreferringPlatformTransactionManager { + + private static final Logger LOGGER = LoggerFactory.getLogger(CouchbaseCallbackTransactionManager.class); + + private final CouchbaseClientFactory couchbaseClientFactory; + private @Nullable TransactionOptions options; + + public CouchbaseCallbackTransactionManager(CouchbaseClientFactory couchbaseClientFactory) { + this(couchbaseClientFactory, null); + } + + /** + * This override is for users manually creating a CouchbaseCallbackTransactionManager, and allows the + * TransactionOptions to be overridden. + */ + public CouchbaseCallbackTransactionManager(CouchbaseClientFactory couchbaseClientFactory, + @Nullable TransactionOptions options) { + this.couchbaseClientFactory = couchbaseClientFactory; + this.options = options != null ? options : TransactionOptions.transactionOptions(); + } + + @Override + public T execute(TransactionDefinition definition, TransactionCallback callback) throws TransactionException { + boolean createNewTransaction = handlePropagation(definition); + + setOptionsFromDefinition(definition); + + if (createNewTransaction) { + return executeNewTransaction(callback); + } else { + return callback.doInTransaction(null); + } + } + + @Stability.Internal + Flux executeReactive(TransactionDefinition definition, + org.springframework.transaction.reactive.TransactionCallback callback) { + return Flux.defer(() -> { + boolean createNewTransaction = handlePropagation(definition); + + setOptionsFromDefinition(definition); + + if (createNewTransaction) { + return executeNewReactiveTransaction(callback); + } else { + return Mono.error(new UnsupportedOperationException("Unsupported operation")); + } + }); + } + + private T executeNewTransaction(TransactionCallback callback) { + final AtomicReference execResult = new AtomicReference<>(); + + // Each of these transactions will block one thread on the underlying SDK's transactions scheduler. This + // scheduler is effectively unlimited, but this can still potentially lead to high thread usage by the application. + // If this is an issue then users need to instead use the standard Couchbase reactive transactions SDK. + try { + TransactionResult ignored = couchbaseClientFactory.getCluster().transactions().run(ctx -> { + CouchbaseTransactionStatus status = new CouchbaseTransactionStatus(ctx, true, false, false, true, null); + + T res = callback.doInTransaction(status); + if (res instanceof Mono || res instanceof Flux) { + throw new UnsupportedOperationException( + "Return type is Mono or Flux, indicating a reactive transaction is being performed in a blocking way. A potential cause is the CouchbaseTransactionInterceptor is not in use."); + } + execResult.set(res); + + if (status.isRollbackOnly()) { + throw new TransactionRollbackRequestedException("TransactionStatus.isRollbackOnly() is set"); + } + }, this.options); + + return execResult.get(); + } catch (RuntimeException ex) { + throw convert(ex); + } + } + + private static RuntimeException convert(RuntimeException ex) { + if (ex instanceof TransactionCommitAmbiguousException) { + return new TransactionSystemAmbiguousException((TransactionCommitAmbiguousException) ex); + } + if (ex instanceof TransactionFailedException) { + return new TransactionSystemUnambiguousException((TransactionFailedException) ex); + } + // Should not get here + return ex; + } + + private Flux executeNewReactiveTransaction( + org.springframework.transaction.reactive.TransactionCallback callback) { + // Buffer the output rather than attempting to stream results back from a now-defunct lambda. + final List out = new ArrayList<>(); + + return couchbaseClientFactory.getCluster().reactive().transactions().run(ctx -> { + return Mono.defer(() -> { + ReactiveTransaction status = new ReactiveTransaction() { + boolean rollbackOnly = false; + + @Override + public boolean isNewTransaction() { + return true; + } + + @Override + public void setRollbackOnly() { + this.rollbackOnly = true; + } + + @Override + public boolean isRollbackOnly() { + return rollbackOnly; + } + + @Override + public boolean isCompleted() { + return false; + } + }; + + return Flux.from(callback.doInTransaction(status)).doOnNext(v -> out.add(v)).then(Mono.defer(() -> { + if (status.isRollbackOnly()) { + return Mono.error(new TransactionRollbackRequestedException("TransactionStatus.isRollbackOnly() is set")); + } + return Mono.empty(); + })); + }); + + }, this.options).thenMany(Flux.defer(() -> Flux.fromIterable(out))).onErrorMap(ex -> { + if (ex instanceof RuntimeException) { + return convert((RuntimeException) ex); + } + return ex; + }); + } + + // Propagation defines what happens when a @Transactional method is called from another @Transactional method. + private boolean handlePropagation(TransactionDefinition definition) { + boolean isExistingTransaction = TransactionalSupport.checkForTransactionInThreadLocalStorage().block().isPresent(); + + LOGGER.trace("Deciding propagation behaviour from {} and {}", definition.getPropagationBehavior(), + isExistingTransaction); + + switch (definition.getPropagationBehavior()) { + case TransactionDefinition.PROPAGATION_REQUIRED: + // Make a new transaction if required, else just execute the new method in the current transaction. + return !isExistingTransaction; + + case TransactionDefinition.PROPAGATION_SUPPORTS: + // Don't appear to have the ability to execute the callback non-transactionally in this layer. + throw new UnsupportedOperationException( + "Propagation level 'support' has been specified which is not supported"); + + case TransactionDefinition.PROPAGATION_MANDATORY: + if (!isExistingTransaction) { + throw new IllegalTransactionStateException( + "Propagation level 'mandatory' is specified but not in an active transaction"); + } + return false; + + case TransactionDefinition.PROPAGATION_REQUIRES_NEW: + // This requires suspension of the active transaction. This will be possible to support in a future + // release, if required. + throw new UnsupportedOperationException( + "Propagation level 'requires_new' has been specified which is not currently supported"); + + case TransactionDefinition.PROPAGATION_NOT_SUPPORTED: + // Don't appear to have the ability to execute the callback non-transactionally in this layer. + throw new UnsupportedOperationException( + "Propagation level 'not_supported' has been specified which is not supported"); + + case TransactionDefinition.PROPAGATION_NEVER: + if (isExistingTransaction) { + throw new IllegalTransactionStateException( + "Existing transaction found for transaction marked with propagation 'never'"); + } + return true; + + case TransactionDefinition.PROPAGATION_NESTED: + if (isExistingTransaction) { + // Couchbase transactions cannot be nested. + throw new UnsupportedOperationException( + "Propagation level 'nested' has been specified which is not supported"); + } + return true; + + default: + throw new UnsupportedOperationException( + "Unknown propagation level " + definition.getPropagationBehavior() + " has been specified"); + } + } + + /** + * @param definition reflects the @Transactional options + */ + private void setOptionsFromDefinition(TransactionDefinition definition) { + if (definition != null) { + if (definition.getTimeout() != TransactionDefinition.TIMEOUT_DEFAULT) { + if (options == null) { + options = TransactionOptions.transactionOptions(); + } + options = options.timeout(Duration.ofSeconds(definition.getTimeout())); + } + + if (!(definition.getIsolationLevel() == TransactionDefinition.ISOLATION_DEFAULT + || definition.getIsolationLevel() == TransactionDefinition.ISOLATION_READ_COMMITTED)) { + throw new IllegalArgumentException( + "Couchbase Transactions run at Read Committed isolation - other isolation levels are not supported"); + } + + // readonly is ignored as it is documented as being a hint that won't necessarily cause writes to fail + } + + } + + @Override + public TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException { + // All Spring transactional code (currently) does not call the getTransaction, commit or rollback methods if + // the transaction manager is a CallbackPreferringPlatformTransactionManager. + // So these methods should only be hit if user is using PlatformTransactionManager directly. Spring supports this, + // but due to the lambda-based nature of our transactions, we cannot. + throw new UnsupportedOperationException( + "Direct programmatic use of the Couchbase PlatformTransactionManager is not supported"); + } + + @Override + public void commit(TransactionStatus ignored) throws TransactionException { + throw new UnsupportedOperationException( + "Direct programmatic use of the Couchbase PlatformTransactionManager is not supported"); + } + + @Override + public void rollback(TransactionStatus ignored) throws TransactionException { + throw new UnsupportedOperationException( + "Direct programmatic use of the Couchbase PlatformTransactionManager is not supported"); + } +} diff --git a/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseResourceHolder.java b/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseResourceHolder.java new file mode 100644 index 000000000..dee868b8b --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseResourceHolder.java @@ -0,0 +1,61 @@ +/* + * Copyright 2022 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.transaction; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.lang.Nullable; +import org.springframework.transaction.support.ResourceHolderSupport; + +import com.couchbase.client.core.annotation.Stability; +import com.couchbase.client.core.transaction.CoreTransactionAttemptContext; + +/** + * Container for couchbase transaction resources to hold in threadlocal or reactive context. + * + * @author Michael Reiche + * + */ +@Stability.Internal +public class CouchbaseResourceHolder extends ResourceHolderSupport { + + private @Nullable CoreTransactionAttemptContext core; // which holds the atr + Map getResultMap = new HashMap<>(); + + /** + * Create a new {@link CouchbaseResourceHolder} for a given {@link CoreTransactionAttemptContext session}. + * + * @param core the associated {@link CoreTransactionAttemptContext}. Can be {@literal null}. + */ + public CouchbaseResourceHolder(@Nullable CoreTransactionAttemptContext core) { + this.core = core; + } + + /** + * @return the associated {@link CoreTransactionAttemptContext}. Can be {@literal null}. + */ + @Nullable + public CoreTransactionAttemptContext getCore() { + return core; + } + + public Object transactionResultHolder(Object holder, Object o) { + getResultMap.put(System.identityHashCode(o), holder); + return holder; + } + +} diff --git a/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionDefinition.java b/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionDefinition.java new file mode 100644 index 000000000..58ab56f79 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionDefinition.java @@ -0,0 +1,33 @@ +/* + * Copyright 2022 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.transaction; + +import org.springframework.transaction.support.DefaultTransactionDefinition; + +import com.couchbase.client.core.annotation.Stability; + +/** + * Couchbase Transaction Definition for Spring Data transaction framework. + * + * @author Michael Reiche + */ +@Stability.Internal +public class CouchbaseTransactionDefinition extends DefaultTransactionDefinition { + public CouchbaseTransactionDefinition() { + super(); + setIsolationLevel(ISOLATION_READ_COMMITTED); + } +} diff --git a/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionInterceptor.java b/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionInterceptor.java new file mode 100644 index 000000000..746973919 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionInterceptor.java @@ -0,0 +1,95 @@ +/* + * Copyright 2022 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.transaction; + +import java.io.Serializable; +import java.lang.reflect.Method; + +import com.couchbase.client.core.annotation.Stability; +import org.aopalliance.intercept.MethodInterceptor; +import org.springframework.lang.Nullable; +import org.springframework.transaction.TransactionManager; +import org.springframework.transaction.interceptor.TransactionAttribute; +import org.springframework.transaction.interceptor.TransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * This allows reactive @Transactional support with Couchbase transactions. + *

+ * The ReactiveTransactionManager does not support the lambda-based nature of Couchbase transactions, + * and there is no reactive equivalent of CallbackPreferringTransactionManager (which does). + *

+ * The solution: override the standard TransactionInterceptor and, if the + * CouchbaseCallbackTransactionManager is the provided TransactionManager, defer to that. + * + * @author Graham Pople + * @author Michael Reiche + */ +@Stability.Internal +public class CouchbaseTransactionInterceptor extends TransactionInterceptor + implements MethodInterceptor, Serializable { + + public CouchbaseTransactionInterceptor(TransactionManager ptm, TransactionAttributeSource tas) { + super(ptm, tas); + } + + @Nullable + protected Object invokeWithinTransaction(Method method, @Nullable Class targetClass, + final InvocationCallback invocation) throws Throwable { + final TransactionAttributeSource tas = getTransactionAttributeSource(); + final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null); + + if (getTransactionManager() instanceof CouchbaseCallbackTransactionManager) { + CouchbaseCallbackTransactionManager manager = (CouchbaseCallbackTransactionManager) getTransactionManager(); + + if (Mono.class.isAssignableFrom(method.getReturnType())) { + return manager.executeReactive(txAttr, ignored -> { + try { + return (Mono) invocation.proceedWithInvocation(); + } catch (RuntimeException e) { + throw e; + } catch (Throwable e) { + throw new RuntimeException(e); + } + }).singleOrEmpty(); + } else if (Flux.class.isAssignableFrom(method.getReturnType())) { + return manager.executeReactive(txAttr, ignored -> { + try { + return (Flux) invocation.proceedWithInvocation(); + } catch (RuntimeException e) { + throw e; + } catch (Throwable e) { + throw new RuntimeException(e); + } + }); + } else { + return manager.execute(txAttr, ignored -> { + try { + return invocation.proceedWithInvocation(); + } catch (RuntimeException e) { + throw e; + } catch (Throwable e) { + throw new RuntimeException(e); + } + }); + } + } else { + return super.invokeWithinTransaction(method, targetClass, invocation); + } + } +} diff --git a/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionStatus.java b/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionStatus.java new file mode 100644 index 000000000..af4a3eecf --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionStatus.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2022 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.transaction; + +import org.springframework.transaction.support.DefaultTransactionStatus; + +/** + * Couchbase transaction status for Spring Data transaction framework. + * + * @author Graham Pople + */ +public class CouchbaseTransactionStatus extends DefaultTransactionStatus { + + /** + * Create a new {@code DefaultTransactionStatus} instance. + * + * @param transaction underlying transaction object that can hold state + * for the internal transaction implementation + * @param newTransaction if the transaction is new, otherwise participating + * in an existing transaction + * @param newSynchronization if a new transaction synchronization has been + * opened for the given transaction + * @param readOnly whether the transaction is marked as read-only + * @param debug should debug logging be enabled for the handling of this transaction? + * Caching it in here can prevent repeated calls to ask the logging system whether + * debug logging should be enabled. + * @param suspendedResources a holder for resources that have been suspended + */ + public CouchbaseTransactionStatus(Object transaction, boolean newTransaction, boolean newSynchronization, boolean readOnly, boolean debug, Object suspendedResources) { + super(transaction, + newTransaction, + newSynchronization, + readOnly, + debug, + suspendedResources); + } +} diff --git a/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionalOperator.java b/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionalOperator.java new file mode 100644 index 000000000..c6bc295b7 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionalOperator.java @@ -0,0 +1,56 @@ +/* + * Copyright 2022 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.transaction; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionException; +import org.springframework.transaction.reactive.TransactionCallback; +import org.springframework.transaction.reactive.TransactionalOperator; + +/** + * The TransactionalOperator interface is another method to perform reactive transactions with Spring. + *

+ * We recommend instead using a regular reactive SDK transaction, and performing Spring operations inside it. + * + * @author Graham Pople + */ +public class CouchbaseTransactionalOperator implements TransactionalOperator { + private final CouchbaseCallbackTransactionManager manager; + + CouchbaseTransactionalOperator(CouchbaseCallbackTransactionManager manager) { + this.manager = manager; + } + + public static CouchbaseTransactionalOperator create(CouchbaseCallbackTransactionManager manager) { + return new CouchbaseTransactionalOperator(manager); + } + + @Override + public Mono transactional(Mono mono) { + return transactional(Flux.from(mono)).singleOrEmpty(); + } + + @Override + public Flux execute(TransactionCallback action) throws TransactionException { + return Flux.defer(() -> { + TransactionDefinition def = new CouchbaseTransactionDefinition(); + return manager.executeReactive(def, action); + }); + } +} diff --git a/src/main/java/org/springframework/data/couchbase/transaction/error/TransactionRollbackRequestedException.java b/src/main/java/org/springframework/data/couchbase/transaction/error/TransactionRollbackRequestedException.java new file mode 100644 index 000000000..ae7a0f9c9 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/transaction/error/TransactionRollbackRequestedException.java @@ -0,0 +1,29 @@ +/* + * Copyright 2022 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.transaction.error; + +import com.couchbase.client.core.error.CouchbaseException; + +/** + * A transaction rollback has been requested. + * + * @author Graham Pople + */ +public class TransactionRollbackRequestedException extends CouchbaseException { + public TransactionRollbackRequestedException(String message) { + super(message); + } +} diff --git a/src/main/java/org/springframework/data/couchbase/transaction/error/TransactionSystemAmbiguousException.java b/src/main/java/org/springframework/data/couchbase/transaction/error/TransactionSystemAmbiguousException.java new file mode 100644 index 000000000..eb424e977 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/transaction/error/TransactionSystemAmbiguousException.java @@ -0,0 +1,42 @@ +/* + * Copyright 2022 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.transaction.error; + +import com.couchbase.client.java.transactions.error.TransactionCommitAmbiguousException; + +/** + * The transaction expired at the point of trying to commit it. It is ambiguous whether the transaction has committed + * or not. Actors may be able to see the content of this transaction. + * + * This error is the result of inevitable and unavoidable edge cases when working with unreliable networks. For example, + * consider an ordinary mutation being made over the network to any database. The mutation could succeed on the + * database-side, and then just before the result is returned to the client, the network connection drops. The client + * cannot receive the success result and will timeout - it is ambiguous to it whether the mutation succeeded or not. + * + * The transactions logic will work to resolve the ambiguity up until the transaction expires, but if unable to resolve + * it in that time, it is forced to raise this error. The transaction may or may not have been successful, and + * error-handling of this is highly application-dependent. + * + * An asynchronous cleanup process will try to complete the transaction: roll it back if it didn't commit, roll it + * forwards if it did. + * + * @author Graham Pople + */ +public class TransactionSystemAmbiguousException extends TransactionSystemCouchbaseException { + public TransactionSystemAmbiguousException(TransactionCommitAmbiguousException ex) { + super(ex); + } +} diff --git a/src/main/java/org/springframework/data/couchbase/transaction/error/TransactionSystemCouchbaseException.java b/src/main/java/org/springframework/data/couchbase/transaction/error/TransactionSystemCouchbaseException.java new file mode 100644 index 000000000..e66097c4d --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/transaction/error/TransactionSystemCouchbaseException.java @@ -0,0 +1,45 @@ +/* + * Copyright 2022 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.transaction.error; + +import java.util.List; + +import org.springframework.transaction.TransactionSystemException; + +import com.couchbase.client.core.cnc.events.transaction.TransactionLogEvent; +import com.couchbase.client.java.transactions.error.TransactionFailedException; + +/** + * A base class of transaction-level exceptions raised by Couchbase, allowing them to be handled in one place. + * + * @author Graham Pople + */ +abstract public class TransactionSystemCouchbaseException extends TransactionSystemException { + private final TransactionFailedException internal; + + public TransactionSystemCouchbaseException(TransactionFailedException ex) { + super(ex.getMessage(), ex.getCause()); + this.internal = ex; + } + + /** + * An in-memory log is built up during each transaction. The application may want to write this to their own logs, for + * example upon transaction failure. + */ + public List logs() { + return internal.logs(); + } +} diff --git a/src/main/java/org/springframework/data/couchbase/transaction/error/TransactionSystemUnambiguousException.java b/src/main/java/org/springframework/data/couchbase/transaction/error/TransactionSystemUnambiguousException.java new file mode 100644 index 000000000..c21fcab4d --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/transaction/error/TransactionSystemUnambiguousException.java @@ -0,0 +1,31 @@ +/* + * Copyright 2022 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.transaction.error; + +import com.couchbase.client.java.transactions.error.TransactionFailedException; + +/** + * The transaction failed and unambiguously did not commit. No actors can see any part of this failed transaction. + *

+ * The application does not need to do anything to rollback the transaction. + * + * @author Graham Pople + */ +public class TransactionSystemUnambiguousException extends TransactionSystemCouchbaseException { + public TransactionSystemUnambiguousException(TransactionFailedException ex) { + super(ex); + } +} diff --git a/src/main/java/org/springframework/data/couchbase/transaction/error/UncategorizedTransactionDataAccessException.java b/src/main/java/org/springframework/data/couchbase/transaction/error/UncategorizedTransactionDataAccessException.java new file mode 100644 index 000000000..9d9159770 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/transaction/error/UncategorizedTransactionDataAccessException.java @@ -0,0 +1,46 @@ +/* + * Copyright 2022 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.transaction.error; + +import org.springframework.dao.UncategorizedDataAccessException; + +import com.couchbase.client.core.error.transaction.TransactionOperationFailedException; +import com.couchbase.client.core.error.transaction.internal.WrappedTransactionOperationFailedException; + +/** + * An opaque signal that something went wrong during the execution of an operation inside a transaction. + *

+ * The application is not expected to catch or inspect this exception, and should allow it to propagate. + *

+ * Internal state has been set that ensures that the transaction will act appropriately (including rolling back and + * retrying if necessary) regardless of what the application does with this exception. + * + * @author Graham Pople + */ +public class UncategorizedTransactionDataAccessException extends UncategorizedDataAccessException + implements WrappedTransactionOperationFailedException { + private final TransactionOperationFailedException internal; + + public UncategorizedTransactionDataAccessException(TransactionOperationFailedException err) { + super(err.getMessage(), err.getCause()); + this.internal = err; + } + + @Override + public TransactionOperationFailedException wrapped() { + return internal; + } +} diff --git a/src/test/java/org/springframework/data/couchbase/cache/CouchbaseCacheCollectionIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/cache/CouchbaseCacheCollectionIntegrationTests.java index ec6026ca7..a12962e8c 100644 --- a/src/test/java/org/springframework/data/couchbase/cache/CouchbaseCacheCollectionIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/cache/CouchbaseCacheCollectionIntegrationTests.java @@ -16,6 +16,12 @@ package org.springframework.data.couchbase.cache; +import static org.junit.Assert.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.UUID; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.data.couchbase.util.Capabilities; @@ -23,12 +29,6 @@ import org.springframework.data.couchbase.util.CollectionAwareIntegrationTests; import org.springframework.data.couchbase.util.IgnoreWhen; -import java.util.UUID; - -import static org.junit.Assert.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - /** * CouchbaseCache tests Theses tests rely on a cb server running. * diff --git a/src/test/java/org/springframework/data/couchbase/cache/CouchbaseCacheIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/cache/CouchbaseCacheIntegrationTests.java index f34df744d..e9fa46ceb 100644 --- a/src/test/java/org/springframework/data/couchbase/cache/CouchbaseCacheIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/cache/CouchbaseCacheIntegrationTests.java @@ -34,8 +34,7 @@ import org.springframework.data.couchbase.util.ClusterType; import org.springframework.data.couchbase.util.IgnoreWhen; import org.springframework.data.couchbase.util.JavaIntegrationTests; - -import com.couchbase.client.java.query.QueryOptions; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; /** * CouchbaseCache tests Theses tests rely on a cb server running. @@ -43,6 +42,7 @@ * @author Michael Reiche */ @IgnoreWhen(clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(Config.class) class CouchbaseCacheIntegrationTests extends JavaIntegrationTests { volatile CouchbaseCache cache; @@ -68,8 +68,6 @@ public void afterEach() { super.afterEach(); } - - @Test void cachePutGet() { CacheUser user1 = new CacheUser(UUID.randomUUID().toString(), "first1", "last1"); @@ -125,7 +123,6 @@ void cachePutIfAbsent() { assertEquals(user1, cache.get(user1.getId()).get()); // user1.getId() is still user1 } - @Test // this WORKS public void clearWithDelayOk() throws InterruptedException { cache.put("KEY", "VALUE"); diff --git a/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateKeyValueIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateKeyValueIntegrationTests.java index 2e9f14d1a..38af1b2aa 100644 --- a/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateKeyValueIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateKeyValueIntegrationTests.java @@ -37,7 +37,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.couchbase.core.ExecutableFindByIdOperation.ExecutableFindById; @@ -48,6 +48,7 @@ import org.springframework.data.couchbase.core.support.WithDurability; import org.springframework.data.couchbase.core.support.WithExpiry; import org.springframework.data.couchbase.domain.Address; +import org.springframework.data.couchbase.domain.Config; import org.springframework.data.couchbase.domain.NaiveAuditorAware; import org.springframework.data.couchbase.domain.PersonValue; import org.springframework.data.couchbase.domain.Submission; @@ -59,6 +60,7 @@ import org.springframework.data.couchbase.util.ClusterType; import org.springframework.data.couchbase.util.IgnoreWhen; import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import com.couchbase.client.core.error.CouchbaseException; import com.couchbase.client.java.kv.PersistTo; @@ -66,8 +68,6 @@ import com.couchbase.client.java.query.QueryOptions; import com.couchbase.client.java.query.QueryScanConsistency; -; - /** * KV tests Theses tests rely on a cb server running. * @@ -75,12 +75,15 @@ * @author Michael Reiche */ @IgnoreWhen(clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(Config.class) class CouchbaseTemplateKeyValueIntegrationTests extends JavaIntegrationTests { + @Autowired public CouchbaseTemplate couchbaseTemplate; + @Autowired public ReactiveCouchbaseTemplate reactiveCouchbaseTemplate; + @BeforeEach @Override public void beforeEach() { - super.beforeEach(); couchbaseTemplate.removeByQuery(User.class).all(); couchbaseTemplate.removeByQuery(UserAnnotated.class).all(); couchbaseTemplate.removeByQuery(UserAnnotated2.class).all(); @@ -100,7 +103,7 @@ void findByIdWithExpiry() { User foundUser = couchbaseTemplate.findById(User.class).withExpiry(Duration.ofSeconds(1)).one(user1.getId()); user1.setVersion(foundUser.getVersion());// version will have changed assertEquals(user1, foundUser); - sleepMs(2000); + sleepMs(3000); Collection foundUsers = (Collection) couchbaseTemplate.findById(User.class) .all(Arrays.asList(user1.getId(), user2.getId())); diff --git a/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateQueryCollectionIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateQueryCollectionIntegrationTests.java index 4c8814f7e..131670a94 100644 --- a/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateQueryCollectionIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateQueryCollectionIntegrationTests.java @@ -16,6 +16,7 @@ package org.springframework.data.couchbase.core; +import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -36,11 +37,13 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataRetrievalFailureException; import org.springframework.data.couchbase.core.query.Query; import org.springframework.data.couchbase.core.query.QueryCriteria; import org.springframework.data.couchbase.domain.Address; import org.springframework.data.couchbase.domain.Airport; +import org.springframework.data.couchbase.domain.CollectionsConfig; import org.springframework.data.couchbase.domain.Course; import org.springframework.data.couchbase.domain.NaiveAuditorAware; import org.springframework.data.couchbase.domain.Submission; @@ -54,6 +57,7 @@ import org.springframework.data.couchbase.util.ClusterType; import org.springframework.data.couchbase.util.CollectionAwareIntegrationTests; import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import com.couchbase.client.core.error.AmbiguousTimeoutException; import com.couchbase.client.core.error.UnambiguousTimeoutException; @@ -67,7 +71,6 @@ import com.couchbase.client.java.kv.ReplaceOptions; import com.couchbase.client.java.kv.UpsertOptions; import com.couchbase.client.java.query.QueryOptions; -import com.couchbase.client.java.query.QueryScanConsistency; /** * Query tests Theses tests rely on a cb server running This class tests collection support with @@ -77,8 +80,12 @@ * @author Michael Reiche */ @IgnoreWhen(missesCapabilities = { Capabilities.QUERY, Capabilities.COLLECTIONS }, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(CollectionsConfig.class) class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIntegrationTests { + @Autowired public CouchbaseTemplate couchbaseTemplate; + @Autowired public ReactiveCouchbaseTemplate reactiveCouchbaseTemplate; + Airport vie = new Airport("airports::vie", "vie", "loww"); @BeforeAll @@ -103,17 +110,15 @@ public void beforeEach() { // first call the super method super.beforeEach(); // then do processing for this class - couchbaseTemplate.removeByQuery(User.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inCollection(collectionName).all(); - couchbaseTemplate.findByQuery(User.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inCollection(collectionName).all(); - couchbaseTemplate.removeByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(scopeName) + couchbaseTemplate.removeByQuery(User.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).all(); + couchbaseTemplate.findByQuery(User.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).all(); + couchbaseTemplate.removeByQuery(Airport.class).withConsistency(REQUEST_PLUS).inScope(scopeName) .inCollection(collectionName).all(); - couchbaseTemplate.findByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(scopeName) + couchbaseTemplate.findByQuery(Airport.class).withConsistency(REQUEST_PLUS).inScope(scopeName) .inCollection(collectionName).all(); - couchbaseTemplate.removeByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inScope(otherScope).inCollection(otherCollection).all(); - couchbaseTemplate.findByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(otherScope) + couchbaseTemplate.removeByQuery(Airport.class).withConsistency(REQUEST_PLUS).inScope(otherScope) + .inCollection(otherCollection).all(); + couchbaseTemplate.findByQuery(Airport.class).withConsistency(REQUEST_PLUS).inScope(otherScope) .inCollection(otherCollection).all(); } @@ -123,8 +128,7 @@ public void afterEach() { // first do processing for this class couchbaseTemplate.removeByQuery(User.class).inCollection(collectionName).all(); // query with REQUEST_PLUS to ensure that the remove has completed. - couchbaseTemplate.findByQuery(User.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inCollection(collectionName).all(); + couchbaseTemplate.findByQuery(User.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).all(); // then call the super method super.afterEach(); } @@ -137,8 +141,8 @@ void findByQueryAll() { couchbaseTemplate.upsertById(User.class).inCollection(collectionName).all(Arrays.asList(user1, user2)); - final List foundUsers = couchbaseTemplate.findByQuery(User.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).all(); + final List foundUsers = couchbaseTemplate.findByQuery(User.class).withConsistency(REQUEST_PLUS) + .inCollection(collectionName).all(); for (User u : foundUsers) { if (!(u.equals(user1) || u.equals(user2))) { @@ -180,8 +184,8 @@ void findByMatchingQuery() { couchbaseTemplate.upsertById(User.class).inCollection(collectionName).all(Arrays.asList(user1, user2, specialUser)); Query specialUsers = new Query(QueryCriteria.where("firstname").like("special")); - final List foundUsers = couchbaseTemplate.findByQuery(User.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).matching(specialUsers).all(); + final List foundUsers = couchbaseTemplate.findByQuery(User.class).withConsistency(REQUEST_PLUS) + .inCollection(collectionName).matching(specialUsers).all(); assertEquals(1, foundUsers.size()); } @@ -205,8 +209,8 @@ void findByMatchingQueryProjected() { Query daveUsers = new Query(QueryCriteria.where("username").like("dave")); final List foundUserSubmissions = couchbaseTemplate.findByQuery(UserSubmission.class) - .as(UserSubmissionProjected.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inCollection(collectionName).matching(daveUsers).all(); + .as(UserSubmissionProjected.class).withConsistency(REQUEST_PLUS).inCollection(collectionName) + .matching(daveUsers).all(); assertEquals(1, foundUserSubmissions.size()); assertEquals(user.getUsername(), foundUserSubmissions.get(0).getUsername()); assertEquals(user.getId(), foundUserSubmissions.get(0).getId()); @@ -223,16 +227,16 @@ void findByMatchingQueryProjected() { Query specialUsers = new Query(QueryCriteria.where("firstname").like("special")); final List foundUsers = couchbaseTemplate.findByQuery(User.class).as(UserJustLastName.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).matching(specialUsers).all(); + .withConsistency(REQUEST_PLUS).inCollection(collectionName).matching(specialUsers).all(); assertEquals(1, foundUsers.size()); final List foundUsersReactive = reactiveCouchbaseTemplate.findByQuery(User.class) - .as(UserJustLastName.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName) - .matching(specialUsers).all().collectList().block(); + .as(UserJustLastName.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).matching(specialUsers) + .all().collectList().block(); assertEquals(1, foundUsersReactive.size()); - couchbaseTemplate.removeByQuery(UserSubmission.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).all(); - couchbaseTemplate.removeByQuery(UserSubmission.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).all(); + couchbaseTemplate.removeByQuery(UserSubmission.class).withConsistency(REQUEST_PLUS).all(); + couchbaseTemplate.removeByQuery(UserSubmission.class).withConsistency(REQUEST_PLUS).all(); } @@ -247,8 +251,8 @@ void removeByQueryAll() { assertTrue(couchbaseTemplate.existsById().inScope(scopeName).inCollection(collectionName).one(user1.getId())); assertTrue(couchbaseTemplate.existsById().inScope(scopeName).inCollection(collectionName).one(user2.getId())); - List result = couchbaseTemplate.removeByQuery(User.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).all(); + List result = couchbaseTemplate.removeByQuery(User.class).withConsistency(REQUEST_PLUS) + .inCollection(collectionName).all(); assertEquals(2, result.size(), "should have deleted user1 and user2"); assertNull( @@ -272,8 +276,8 @@ void removeByMatchingQuery() { Query nonSpecialUsers = new Query(QueryCriteria.where("firstname").notLike("special")); - couchbaseTemplate.removeByQuery(User.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inCollection(collectionName).matching(nonSpecialUsers).all(); + couchbaseTemplate.removeByQuery(User.class).withConsistency(REQUEST_PLUS).inCollection(collectionName) + .matching(nonSpecialUsers).all(); assertNull(couchbaseTemplate.findById(User.class).inCollection(collectionName).one(user1.getId())); assertNull(couchbaseTemplate.findById(User.class).inCollection(collectionName).one(user2.getId())); @@ -297,17 +301,17 @@ void distinct() { // distinct icao List airports1 = couchbaseTemplate.findByQuery(Airport.class).distinct(new String[] { "icao" }) - .as(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).all(); + .as(Airport.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).all(); assertEquals(2, airports1.size()); // distinct all-fields-in-Airport.class List airports2 = couchbaseTemplate.findByQuery(Airport.class).distinct(new String[] {}).as(Airport.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).all(); + .withConsistency(REQUEST_PLUS).inCollection(collectionName).all(); assertEquals(7, airports2.size()); // count( distinct { iata, icao } ) long count1 = couchbaseTemplate.findByQuery(Airport.class).distinct(new String[] { "iata", "icao" }) - .as(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).count(); + .as(Airport.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).count(); assertEquals(7, count1); // count( distinct (all fields in icaoClass) @@ -316,7 +320,7 @@ void distinct() { String icao; }).getClass(); long count2 = couchbaseTemplate.findByQuery(Airport.class).distinct(new String[] {}).as(icaoClass) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).count(); + .withConsistency(REQUEST_PLUS).inCollection(collectionName).count(); assertEquals(7, count2); } finally { @@ -341,25 +345,27 @@ void distinctReactive() { // distinct icao List airports1 = reactiveCouchbaseTemplate.findByQuery(Airport.class).distinct(new String[] { "icao" }) - .as(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).all() - .collectList().block(); + .as(Airport.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).all().collectList().block(); assertEquals(2, airports1.size()); // distinct all-fields-in-Airport.class List airports2 = reactiveCouchbaseTemplate.findByQuery(Airport.class).distinct(new String[] {}) - .as(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).all() - .collectList().block(); + .as(Airport.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).all().collectList().block(); assertEquals(7, airports2.size()); // count( distinct icao ) + // not currently possible to have multiple fields in COUNT(DISTINCT field1, field2, ... ) due to MB43475 Long count1 = reactiveCouchbaseTemplate.findByQuery(Airport.class).distinct(new String[] { "icao" }) - .as(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).count() - .block(); + .as(Airport.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).count().block(); assertEquals(2, count1); - // count (distinct { iata, icao } ) - Long count2 = reactiveCouchbaseTemplate.findByQuery(Airport.class).distinct(new String[] { "iata", "icao" }) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).count().block(); + // count( distinct (all fields in icaoClass) // which only has one field + // not currently possible to have multiple fields in COUNT(DISTINCT field1, field2, ... ) due to MB43475 + Class icaoClass = (new Object() { + String icao; + }).getClass(); + long count2 = (long) reactiveCouchbaseTemplate.findByQuery(Airport.class).distinct(new String[] {}).as(icaoClass) + .withConsistency(REQUEST_PLUS).inCollection(collectionName).count().block(); assertEquals(7, count2); } finally { @@ -432,9 +438,8 @@ public void findByQuery() { // 4 Airport saved = couchbaseTemplate.insertById(Airport.class).inScope(scopeName).inCollection(collectionName) .one(vie.withIcao("441")); try { - List found = couchbaseTemplate.findByQuery(Airport.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(scopeName).inCollection(collectionName) - .withOptions(options).all(); + List found = couchbaseTemplate.findByQuery(Airport.class).withConsistency(REQUEST_PLUS) + .inScope(scopeName).inCollection(collectionName).withOptions(options).all(); assertEquals(saved.getId(), found.get(0).getId()); } finally { couchbaseTemplate.removeById().inScope(scopeName).inCollection(collectionName).one(saved.getId()); @@ -485,9 +490,9 @@ public void removeByQuery() { // 8 QueryOptions options = QueryOptions.queryOptions().timeout(Duration.ofSeconds(10)); Airport saved = couchbaseTemplate.insertById(Airport.class).inScope(scopeName).inCollection(collectionName) .one(vie.withIcao("495")); - List removeResults = couchbaseTemplate.removeByQuery(Airport.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(scopeName).inCollection(collectionName) - .withOptions(options).matching(Query.query(QueryCriteria.where("iata").is(vie.getIata()))).all(); + List removeResults = couchbaseTemplate.removeByQuery(Airport.class).withConsistency(REQUEST_PLUS) + .inScope(scopeName).inCollection(collectionName).withOptions(options) + .matching(Query.query(QueryCriteria.where("iata").is(vie.getIata()))).all(); assertEquals(saved.getId(), removeResults.get(0).getId()); } @@ -576,9 +581,8 @@ public void findByQueryOther() { // 4 Airport saved = couchbaseTemplate.insertById(Airport.class).inScope(otherScope).inCollection(otherCollection) .one(vie.withIcao("594")); try { - List found = couchbaseTemplate.findByQuery(Airport.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(otherScope).inCollection(otherCollection) - .withOptions(options).all(); + List found = couchbaseTemplate.findByQuery(Airport.class).withConsistency(REQUEST_PLUS) + .inScope(otherScope).inCollection(otherCollection).withOptions(options).all(); assertEquals(saved.getId(), found.get(0).getId()); } finally { couchbaseTemplate.removeById().inScope(otherScope).inCollection(otherCollection).one(saved.getId()); @@ -629,9 +633,9 @@ public void removeByQueryOther() { // 8 QueryOptions options = QueryOptions.queryOptions().timeout(Duration.ofSeconds(10)); Airport saved = couchbaseTemplate.insertById(Airport.class).inScope(otherScope).inCollection(otherCollection) .one(vie.withIcao("648")); - List removeResults = couchbaseTemplate.removeByQuery(Airport.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(otherScope).inCollection(otherCollection) - .withOptions(options).matching(Query.query(QueryCriteria.where("iata").is(vie.getIata()))).all(); + List removeResults = couchbaseTemplate.removeByQuery(Airport.class).withConsistency(REQUEST_PLUS) + .inScope(otherScope).inCollection(otherCollection).withOptions(options) + .matching(Query.query(QueryCriteria.where("iata").is(vie.getIata()))).all(); assertEquals(saved.getId(), removeResults.get(0).getId()); } @@ -694,9 +698,8 @@ public void findByIdOptions() { // 3 @Test public void findByQueryOptions() { // 4 QueryOptions options = QueryOptions.queryOptions().timeout(Duration.ofNanos(10)); - assertThrows(AmbiguousTimeoutException.class, - () -> couchbaseTemplate.findByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inScope(otherScope).inCollection(otherCollection).withOptions(options).all()); + assertThrows(AmbiguousTimeoutException.class, () -> couchbaseTemplate.findByQuery(Airport.class) + .withConsistency(REQUEST_PLUS).inScope(otherScope).inCollection(otherCollection).withOptions(options).all()); } @Test @@ -734,8 +737,8 @@ public void removeByIdOptions() { // 7 - options public void removeByQueryOptions() { // 8 - options QueryOptions options = QueryOptions.queryOptions().timeout(Duration.ofNanos(10)); assertThrows(AmbiguousTimeoutException.class, - () -> couchbaseTemplate.removeByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inScope(otherScope).inCollection(otherCollection).withOptions(options) + () -> couchbaseTemplate.removeByQuery(Airport.class).withConsistency(REQUEST_PLUS).inScope(otherScope) + .inCollection(otherCollection).withOptions(options) .matching(Query.query(QueryCriteria.where("iata").is(vie.getIata()))).all()); } @@ -760,9 +763,8 @@ public void testScopeCollectionAnnotation() { try { UserCol saved = couchbaseTemplate.insertById(UserCol.class).inScope(scopeName).inCollection(collectionName) .one(user); - List found = couchbaseTemplate.findByQuery(UserCol.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(scopeName).inCollection(collectionName) - .matching(query).all(); + List found = couchbaseTemplate.findByQuery(UserCol.class).withConsistency(REQUEST_PLUS) + .inScope(scopeName).inCollection(collectionName).matching(query).all(); assertEquals(saved, found.get(0), "should have found what was saved"); couchbaseTemplate.removeByQuery(UserCol.class).inScope(scopeName).inCollection(collectionName).matching(query) .all(); @@ -781,9 +783,8 @@ public void testScopeCollectionRepoWith() { try { UserCol saved = couchbaseTemplate.insertById(UserCol.class).inScope(scopeName).inCollection(collectionName) .one(user); - List found = couchbaseTemplate.findByQuery(UserCol.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(scopeName).inCollection(collectionName) - .matching(query).all(); + List found = couchbaseTemplate.findByQuery(UserCol.class).withConsistency(REQUEST_PLUS) + .inScope(scopeName).inCollection(collectionName).matching(query).all(); assertEquals(saved, found.get(0), "should have found what was saved"); couchbaseTemplate.removeByQuery(UserCol.class).inScope(scopeName).inCollection(collectionName).matching(query) .all(); diff --git a/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateQueryIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateQueryIntegrationTests.java index e6e749f12..d91d0d35b 100644 --- a/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateQueryIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/core/CouchbaseTemplateQueryIntegrationTests.java @@ -33,11 +33,13 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.couchbase.core.query.Query; import org.springframework.data.couchbase.core.query.QueryCriteria; import org.springframework.data.couchbase.domain.Address; import org.springframework.data.couchbase.domain.Airport; import org.springframework.data.couchbase.domain.AssessmentDO; +import org.springframework.data.couchbase.domain.Config; import org.springframework.data.couchbase.domain.Course; import org.springframework.data.couchbase.domain.NaiveAuditorAware; import org.springframework.data.couchbase.domain.Submission; @@ -53,6 +55,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; /** * Query tests Theses tests rely on a cb server running @@ -63,8 +66,12 @@ * @author Mauro Monti */ @IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(Config.class) class CouchbaseTemplateQueryIntegrationTests extends JavaIntegrationTests { + @Autowired public CouchbaseTemplate couchbaseTemplate; + @Autowired public ReactiveCouchbaseTemplate reactiveCouchbaseTemplate; + @BeforeEach @Override public void beforeEach() { @@ -267,6 +274,15 @@ void distinct() { .as(Airport.class).withConsistency(REQUEST_PLUS).count(); assertEquals(7, count1); + // count( distinct (all fields in icaoClass) + Class icaoClass = (new Object() { + String iata; + String icao; + }).getClass(); + long count2 = couchbaseTemplate.findByQuery(Airport.class).distinct(new String[] {}).as(icaoClass) + .withConsistency(REQUEST_PLUS).count(); + assertEquals(7, count2); + } finally { couchbaseTemplate.removeById() .all(Arrays.stream(iatas).map((iata) -> "airports::" + iata).collect(Collectors.toSet())); @@ -298,7 +314,8 @@ void distinctReactive() { assertEquals(7, airports2.size()); // count( distinct icao ) - Long count1 = reactiveCouchbaseTemplate.findByQuery(Airport.class).distinct(new String[] { "icao" }) + // not currently possible to have multiple fields in COUNT(DISTINCT field1, field2, ... ) due to MB43475 + long count1 = reactiveCouchbaseTemplate.findByQuery(Airport.class).distinct(new String[] { "icao" }) .as(Airport.class).withConsistency(REQUEST_PLUS).count().block(); assertEquals(2, count1); diff --git a/src/test/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplateKeyValueIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplateKeyValueIntegrationTests.java index 8fd709ec5..f57f4625a 100644 --- a/src/test/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplateKeyValueIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/core/ReactiveCouchbaseTemplateKeyValueIntegrationTests.java @@ -16,6 +16,7 @@ package org.springframework.data.couchbase.core; +import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -37,6 +38,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.couchbase.core.ReactiveFindByIdOperation.ReactiveFindById; @@ -46,6 +48,7 @@ import org.springframework.data.couchbase.core.support.OneAndAllIdReactive; import org.springframework.data.couchbase.core.support.WithDurability; import org.springframework.data.couchbase.core.support.WithExpiry; +import org.springframework.data.couchbase.domain.Config; import org.springframework.data.couchbase.domain.PersonValue; import org.springframework.data.couchbase.domain.ReactiveNaiveAuditorAware; import org.springframework.data.couchbase.domain.User; @@ -55,10 +58,10 @@ import org.springframework.data.couchbase.util.ClusterType; import org.springframework.data.couchbase.util.IgnoreWhen; import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import com.couchbase.client.java.kv.PersistTo; import com.couchbase.client.java.kv.ReplicateTo; -import com.couchbase.client.java.query.QueryScanConsistency; /** * KV tests Theses tests rely on a cb server running. @@ -67,15 +70,24 @@ * @author Michael Reiche */ @IgnoreWhen(clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(Config.class) class ReactiveCouchbaseTemplateKeyValueIntegrationTests extends JavaIntegrationTests { + @Autowired public CouchbaseTemplate couchbaseTemplate; + @Autowired public ReactiveCouchbaseTemplate reactiveCouchbaseTemplate; + @BeforeEach @Override public void beforeEach() { super.beforeEach(); - List r1 = reactiveCouchbaseTemplate.removeByQuery(User.class).all().collectList().block(); - List r2 = reactiveCouchbaseTemplate.removeByQuery(UserAnnotated.class).all().collectList().block(); - List r3 = reactiveCouchbaseTemplate.removeByQuery(UserAnnotated2.class).all().collectList().block(); + List r1 = reactiveCouchbaseTemplate.removeByQuery(User.class).withConsistency(REQUEST_PLUS).all() + .collectList().block(); + List r2 = reactiveCouchbaseTemplate.removeByQuery(UserAnnotated.class).withConsistency(REQUEST_PLUS) + .all().collectList().block(); + List r3 = reactiveCouchbaseTemplate.removeByQuery(UserAnnotated2.class).withConsistency(REQUEST_PLUS) + .all().collectList().block(); + List f3 = reactiveCouchbaseTemplate.findByQuery(UserAnnotated2.class).withConsistency(REQUEST_PLUS) + .all().collectList().block(); } @Test @@ -91,15 +103,14 @@ void findByIdWithExpiry() { .one(user1.getId()).block(); user1.setVersion(foundUser.getVersion());// version will have changed assertEquals(user1, foundUser); - sleepMs(2000); + sleepMs(3000); Collection foundUsers = (Collection) reactiveCouchbaseTemplate.findById(User.class) .all(Arrays.asList(user1.getId(), user2.getId())).collectList().block(); assertEquals(1, foundUsers.size(), "should have found exactly 1 user"); assertEquals(user2, foundUsers.iterator().next()); } finally { - reactiveCouchbaseTemplate.removeByQuery(User.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).all() - .collectList().block(); + reactiveCouchbaseTemplate.removeByQuery(User.class).withConsistency(REQUEST_PLUS).all().collectList().block(); } } diff --git a/src/test/java/org/springframework/data/couchbase/core/query/ReactiveCouchbaseTemplateQueryCollectionIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/core/query/ReactiveCouchbaseTemplateQueryCollectionIntegrationTests.java index 42edc672a..7a35aa70c 100644 --- a/src/test/java/org/springframework/data/couchbase/core/query/ReactiveCouchbaseTemplateQueryCollectionIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/core/query/ReactiveCouchbaseTemplateQueryCollectionIntegrationTests.java @@ -16,6 +16,7 @@ package org.springframework.data.couchbase.core.query; +import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -36,10 +37,13 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.core.CouchbaseTemplate; import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; import org.springframework.data.couchbase.core.RemoveResult; import org.springframework.data.couchbase.domain.Address; import org.springframework.data.couchbase.domain.Airport; +import org.springframework.data.couchbase.domain.CollectionsConfig; import org.springframework.data.couchbase.domain.Course; import org.springframework.data.couchbase.domain.NaiveAuditorAware; import org.springframework.data.couchbase.domain.Submission; @@ -52,6 +56,7 @@ import org.springframework.data.couchbase.util.ClusterType; import org.springframework.data.couchbase.util.CollectionAwareIntegrationTests; import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import com.couchbase.client.core.error.AmbiguousTimeoutException; import com.couchbase.client.core.error.UnambiguousTimeoutException; @@ -64,7 +69,6 @@ import com.couchbase.client.java.kv.ReplaceOptions; import com.couchbase.client.java.kv.UpsertOptions; import com.couchbase.client.java.query.QueryOptions; -import com.couchbase.client.java.query.QueryScanConsistency; /** * Query tests Theses tests rely on a cb server running This class tests collection support with @@ -74,10 +78,14 @@ * @author Michael Reiche */ @IgnoreWhen(missesCapabilities = { Capabilities.QUERY, Capabilities.COLLECTIONS }, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(CollectionsConfig.class) class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIntegrationTests { + @Autowired public CouchbaseTemplate couchbaseTemplate; + @Autowired public ReactiveCouchbaseTemplate reactiveCouchbaseTemplate; + Airport vie = new Airport("airports::vie", "vie", "low80"); - ReactiveCouchbaseTemplate template = reactiveCouchbaseTemplate; + ReactiveCouchbaseTemplate template; @BeforeAll public static void beforeAll() { @@ -101,18 +109,16 @@ public void beforeEach() { // first call the super method super.beforeEach(); // then do processing for this class - couchbaseTemplate.removeByQuery(User.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inCollection(collectionName).all(); - couchbaseTemplate.findByQuery(User.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inCollection(collectionName).all(); - couchbaseTemplate.removeByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(scopeName) + couchbaseTemplate.removeByQuery(User.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).all(); + couchbaseTemplate.findByQuery(User.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).all(); + couchbaseTemplate.removeByQuery(Airport.class).inScope(scopeName).inCollection(collectionName).all(); + couchbaseTemplate.findByQuery(Airport.class).withConsistency(REQUEST_PLUS).inScope(scopeName) .inCollection(collectionName).all(); - couchbaseTemplate.findByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(scopeName) - .inCollection(collectionName).all(); - couchbaseTemplate.removeByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inScope(otherScope).inCollection(otherCollection).all(); - couchbaseTemplate.findByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(otherScope) + couchbaseTemplate.removeByQuery(Airport.class).inScope(otherScope).inCollection(otherCollection).all(); + couchbaseTemplate.findByQuery(Airport.class).withConsistency(REQUEST_PLUS).inScope(otherScope) .inCollection(otherCollection).all(); + + template = reactiveCouchbaseTemplate; } @AfterEach @@ -121,8 +127,7 @@ public void afterEach() { // first do processing for this class couchbaseTemplate.removeByQuery(User.class).inCollection(collectionName).all(); // query with REQUEST_PLUS to ensure that the remove has completed. - couchbaseTemplate.findByQuery(User.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inCollection(collectionName).all(); + couchbaseTemplate.findByQuery(User.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).all(); // then call the super method super.afterEach(); } @@ -135,8 +140,8 @@ void findByQueryAll() { couchbaseTemplate.upsertById(User.class).inCollection(collectionName).all(Arrays.asList(user1, user2)); - final List foundUsers = couchbaseTemplate.findByQuery(User.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).all(); + final List foundUsers = couchbaseTemplate.findByQuery(User.class).withConsistency(REQUEST_PLUS) + .inCollection(collectionName).all(); for (User u : foundUsers) { if (!(u.equals(user1) || u.equals(user2))) { @@ -178,8 +183,8 @@ void findByMatchingQuery() { couchbaseTemplate.upsertById(User.class).inCollection(collectionName).all(Arrays.asList(user1, user2, specialUser)); Query specialUsers = new Query(QueryCriteria.where("firstname").like("special")); - final List foundUsers = couchbaseTemplate.findByQuery(User.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).matching(specialUsers).all(); + final List foundUsers = couchbaseTemplate.findByQuery(User.class).withConsistency(REQUEST_PLUS) + .inCollection(collectionName).matching(specialUsers).all(); assertEquals(1, foundUsers.size()); } @@ -203,8 +208,8 @@ void findByMatchingQueryProjected() { Query daveUsers = new Query(QueryCriteria.where("username").like("dave")); final List foundUserSubmissions = couchbaseTemplate.findByQuery(UserSubmission.class) - .as(UserSubmissionProjected.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inCollection(collectionName).matching(daveUsers).all(); + .as(UserSubmissionProjected.class).withConsistency(REQUEST_PLUS).inCollection(collectionName) + .matching(daveUsers).all(); assertEquals(1, foundUserSubmissions.size()); assertEquals(user.getUsername(), foundUserSubmissions.get(0).getUsername()); assertEquals(user.getId(), foundUserSubmissions.get(0).getId()); @@ -221,12 +226,12 @@ void findByMatchingQueryProjected() { Query specialUsers = new Query(QueryCriteria.where("firstname").like("special")); final List foundUsers = couchbaseTemplate.findByQuery(User.class).as(UserJustLastName.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).matching(specialUsers).all(); + .withConsistency(REQUEST_PLUS).inCollection(collectionName).matching(specialUsers).all(); assertEquals(1, foundUsers.size()); final List foundUsersReactive = reactiveCouchbaseTemplate.findByQuery(User.class) - .as(UserJustLastName.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName) - .matching(specialUsers).all().collectList().block(); + .as(UserJustLastName.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).matching(specialUsers) + .all().collectList().block(); assertEquals(1, foundUsersReactive.size()); } @@ -242,8 +247,8 @@ void removeByQueryAll() { assertTrue(couchbaseTemplate.existsById().inScope(scopeName).inCollection(collectionName).one(user1.getId())); assertTrue(couchbaseTemplate.existsById().inScope(scopeName).inCollection(collectionName).one(user2.getId())); - List result = couchbaseTemplate.removeByQuery(User.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).all(); + List result = couchbaseTemplate.removeByQuery(User.class).withConsistency(REQUEST_PLUS) + .inCollection(collectionName).all(); assertEquals(2, result.size(), "should have deleted user1 and user2"); assertNull( @@ -267,8 +272,8 @@ void removeByMatchingQuery() { Query nonSpecialUsers = new Query(QueryCriteria.where("firstname").notLike("special")); - couchbaseTemplate.removeByQuery(User.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inCollection(collectionName).matching(nonSpecialUsers).all(); + couchbaseTemplate.removeByQuery(User.class).withConsistency(REQUEST_PLUS).inCollection(collectionName) + .matching(nonSpecialUsers).all(); assertNull(couchbaseTemplate.findById(User.class).inCollection(collectionName).one(user1.getId())); assertNull(couchbaseTemplate.findById(User.class).inCollection(collectionName).one(user2.getId())); @@ -292,17 +297,17 @@ void distinct() { // distinct icao List airports1 = couchbaseTemplate.findByQuery(Airport.class).distinct(new String[] { "icao" }) - .as(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).all(); + .as(Airport.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).all(); assertEquals(2, airports1.size()); // distinct all-fields-in-Airport.class List airports2 = couchbaseTemplate.findByQuery(Airport.class).distinct(new String[] {}).as(Airport.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).all(); + .withConsistency(REQUEST_PLUS).inCollection(collectionName).all(); assertEquals(7, airports2.size()); // count( distinct { iata, icao } ) long count1 = couchbaseTemplate.findByQuery(Airport.class).distinct(new String[] { "iata", "icao" }) - .as(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).count(); + .as(Airport.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).count(); assertEquals(7, count1); // count( distinct (all fields in icaoClass) @@ -311,7 +316,7 @@ void distinct() { String icao; }).getClass(); long count2 = couchbaseTemplate.findByQuery(Airport.class).distinct(new String[] {}).as(icaoClass) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).count(); + .withConsistency(REQUEST_PLUS).inCollection(collectionName).count(); assertEquals(7, count2); } finally { @@ -336,25 +341,23 @@ void distinctReactive() { // distinct icao List airports1 = reactiveCouchbaseTemplate.findByQuery(Airport.class).distinct(new String[] { "icao" }) - .as(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).all() - .collectList().block(); + .as(Airport.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).all().collectList().block(); assertEquals(2, airports1.size()); // distinct all-fields-in-Airport.class List airports2 = reactiveCouchbaseTemplate.findByQuery(Airport.class).distinct(new String[] {}) - .as(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).all() - .collectList().block(); + .as(Airport.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).all().collectList().block(); assertEquals(7, airports2.size()); // count( distinct icao ) + // not currently possible to have multiple fields in COUNT(DISTINCT field1, field2, ... ) due to MB43475 Long count1 = reactiveCouchbaseTemplate.findByQuery(Airport.class).distinct(new String[] { "icao" }) - .as(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).count() - .block(); + .as(Airport.class).withConsistency(REQUEST_PLUS).inCollection(collectionName).count().block(); assertEquals(2, count1); // count( distinct { iata, icao } ) Long count2 = reactiveCouchbaseTemplate.findByQuery(Airport.class).distinct(new String[] { "iata", "icao" }) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inCollection(collectionName).count().block(); + .withConsistency(REQUEST_PLUS).inCollection(collectionName).count().block(); assertEquals(7, count2); } finally { @@ -427,8 +430,8 @@ public void findByQuery() { // 4 Airport saved = template.insertById(Airport.class).inScope(scopeName).inCollection(collectionName) .one(vie.withIcao("lowa")).block(); try { - List found = template.findByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inScope(scopeName).inCollection(collectionName).withOptions(options).all().collectList().block(); + List found = template.findByQuery(Airport.class).withConsistency(REQUEST_PLUS).inScope(scopeName) + .inCollection(collectionName).withOptions(options).all().collectList().block(); assertEquals(saved.getId(), found.get(0).getId()); } finally { template.removeById().inScope(scopeName).inCollection(collectionName).one(saved.getId()).block(); @@ -479,10 +482,9 @@ public void removeByQuery() { // 8 QueryOptions options = QueryOptions.queryOptions().timeout(Duration.ofSeconds(10)); Airport saved = template.insertById(Airport.class).inScope(scopeName).inCollection(collectionName) .one(vie.withIcao("lowe")).block(); - List removeResults = template.removeByQuery(Airport.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(scopeName).inCollection(collectionName) - .withOptions(options).matching(Query.query(QueryCriteria.where("iata").is(vie.getIata()))).all().collectList() - .block(); + List removeResults = template.removeByQuery(Airport.class).withConsistency(REQUEST_PLUS) + .inScope(scopeName).inCollection(collectionName).withOptions(options) + .matching(Query.query(QueryCriteria.where("iata").is(vie.getIata()))).all().collectList().block(); assertEquals(saved.getId(), removeResults.get(0).getId()); } @@ -526,13 +528,12 @@ public void existsByIdOther() { // 1 ExistsOptions existsOptions = ExistsOptions.existsOptions().timeout(Duration.ofSeconds(10)); Airport saved = template.insertById(Airport.class).inScope(otherScope).inCollection(otherCollection) .one(vie.withIcao("lowg")).block(); - try { Boolean exists = template.existsById().inScope(otherScope).inCollection(otherCollection) - .withOptions(existsOptions).one(vie.getId()).block(); - assertTrue(exists, "Airport should exist: " + vie.getId()); + .withOptions(existsOptions).one(saved.getId()).block(); + assertTrue(exists, "Airport should exist: " + saved.getId()); } finally { - template.removeById().inScope(otherScope).inCollection(otherCollection).one(vie.getId()).block(); + template.removeById().inScope(otherScope).inCollection(otherCollection).one(saved.getId()).block(); } } @@ -571,8 +572,8 @@ public void findByQueryOther() { // 4 Airport saved = template.insertById(Airport.class).inScope(otherScope).inCollection(otherCollection) .one(vie.withIcao("lowj")).block(); try { - List found = template.findByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inScope(otherScope).inCollection(otherCollection).withOptions(options).all().collectList().block(); + List found = template.findByQuery(Airport.class).withConsistency(REQUEST_PLUS).inScope(otherScope) + .inCollection(otherCollection).withOptions(options).all().collectList().block(); assertEquals(saved.getId(), found.get(0).getId()); } finally { template.removeById().inScope(otherScope).inCollection(otherCollection).one(saved.getId()).block(); @@ -623,10 +624,9 @@ public void removeByQueryOther() { // 8 QueryOptions options = QueryOptions.queryOptions().timeout(Duration.ofSeconds(10)); Airport saved = template.insertById(Airport.class).inScope(otherScope).inCollection(otherCollection) .one(vie.withIcao("lown")).block(); - List removeResults = template.removeByQuery(Airport.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(otherScope).inCollection(otherCollection) - .withOptions(options).matching(Query.query(QueryCriteria.where("iata").is(vie.getIata()))).all().collectList() - .block(); + List removeResults = template.removeByQuery(Airport.class).withConsistency(REQUEST_PLUS) + .inScope(otherScope).inCollection(otherCollection).withOptions(options) + .matching(Query.query(QueryCriteria.where("iata").is(vie.getIata()))).all().collectList().block(); assertEquals(saved.getId(), removeResults.get(0).getId()); } @@ -690,7 +690,7 @@ public void findByIdOptions() { // 3 public void findByQueryOptions() { // 4 QueryOptions options = QueryOptions.queryOptions().timeout(Duration.ofNanos(10)); assertThrows(AmbiguousTimeoutException.class, - () -> template.findByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).inScope(otherScope) + () -> template.findByQuery(Airport.class).withConsistency(REQUEST_PLUS).inScope(otherScope) .inCollection(otherCollection).withOptions(options).all().collectList().block()); } @@ -729,8 +729,8 @@ public void removeByIdOptions() { // 7 - options public void removeByQueryOptions() { // 8 - options QueryOptions options = QueryOptions.queryOptions().timeout(Duration.ofNanos(10)); assertThrows(AmbiguousTimeoutException.class, - () -> template.removeByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inScope(otherScope).inCollection(otherCollection).withOptions(options) + () -> template.removeByQuery(Airport.class).withConsistency(REQUEST_PLUS).inScope(otherScope) + .inCollection(otherCollection).withOptions(options) .matching(Query.query(QueryCriteria.where("iata").is(vie.getIata()))).all().collectList().block()); } diff --git a/src/test/java/org/springframework/data/couchbase/domain/AbstractEntity.java b/src/test/java/org/springframework/data/couchbase/domain/AbstractEntity.java index 6b4c7aa95..e46dae102 100644 --- a/src/test/java/org/springframework/data/couchbase/domain/AbstractEntity.java +++ b/src/test/java/org/springframework/data/couchbase/domain/AbstractEntity.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors + * Copyright 2012-2022 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. @@ -24,6 +24,7 @@ /** * @author Oliver Gierke + * @author Michael Reiche */ @Document public class AbstractEntity { @@ -39,6 +40,10 @@ public UUID getId() { return id; } + public String id() { + return id.toString(); + } + /** * set the id */ diff --git a/src/test/java/org/springframework/data/couchbase/domain/Airport.java b/src/test/java/org/springframework/data/couchbase/domain/Airport.java index 8046e8a8b..cda300bf6 100644 --- a/src/test/java/org/springframework/data/couchbase/domain/Airport.java +++ b/src/test/java/org/springframework/data/couchbase/domain/Airport.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2021 the original author or authors. + * Copyright 2017-2022 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. @@ -17,6 +17,7 @@ package org.springframework.data.couchbase.domain; import jakarta.validation.constraints.Max; + import org.springframework.data.annotation.CreatedBy; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.PersistenceConstructor; @@ -44,8 +45,8 @@ public class Airport extends ComparableEntity { @CreatedBy private String createdBy; @Expiration private long expiration; - @Max(2) - long size; + @Max(2) long size; + private long someNumber; @PersistenceConstructor public Airport(String key, String iata, String icao) { @@ -91,11 +92,11 @@ public String getCreatedBy() { return createdBy; } - public long getSize(){ + public long getSize() { return size; } - public void setSize(long size){ + public void setSize(long size) { this.size = size; } } diff --git a/src/test/java/org/springframework/data/couchbase/domain/AirportRepository.java b/src/test/java/org/springframework/data/couchbase/domain/AirportRepository.java index 435c82bfc..f9f520f2c 100644 --- a/src/test/java/org/springframework/data/couchbase/domain/AirportRepository.java +++ b/src/test/java/org/springframework/data/couchbase/domain/AirportRepository.java @@ -64,6 +64,7 @@ public interface AirportRepository extends CouchbaseRepository, List findByIataInAndIcaoIn(java.util.Collection size, java.util.Collection color, Pageable pageable); + // override an annotate with REQUEST_PLUS @Override @ScanConsistency(query = QueryScanConsistency.REQUEST_PLUS) List findAll(); @@ -90,6 +91,12 @@ List findByIataInAndIcaoIn(java.util.Collection size, java.util @ScanConsistency(query = QueryScanConsistency.REQUEST_PLUS) Airport findByIataIn(JsonArray iatas); + @Query("Select \"\" AS __id, 0 AS __cas, substr(iata,0,1) as iata, count(*) as someNumber FROM #{#n1ql.bucket} WHERE #{#n1ql.filter} GROUP BY substr(iata,0,1)") + List groupByIata(); + + @ScanConsistency(query = QueryScanConsistency.REQUEST_PLUS) + Airport findArchivedByIata(Iata iata); + // NOT_BOUNDED to test ScanConsistency // @ScanConsistency(query = QueryScanConsistency.NOT_BOUNDED) Airport iata(String iata); diff --git a/src/test/java/org/springframework/data/couchbase/domain/CollectionsConfig.java b/src/test/java/org/springframework/data/couchbase/domain/CollectionsConfig.java new file mode 100644 index 000000000..d9a926b20 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/domain/CollectionsConfig.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-2022 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; + +/** + * Config to be used for testing scopes and collections. + * + * @author Michael Reiche + */ +public class CollectionsConfig extends Config { + @Override + public String getScopeName() { + return "my_scope"; + } +} diff --git a/src/test/java/org/springframework/data/couchbase/domain/FluxIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/domain/FluxIntegrationTests.java index 510aabae5..d38ffca1b 100644 --- a/src/test/java/org/springframework/data/couchbase/domain/FluxIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/domain/FluxIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors + * Copyright 2021-2022 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. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.data.couchbase.domain; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -30,8 +29,7 @@ import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -70,8 +68,13 @@ @IgnoreWhen(clusterTypes = ClusterType.MOCKED) public class FluxIntegrationTests extends JavaIntegrationTests { - @BeforeAll - public static void beforeEverything() { + @Autowired public CouchbaseTemplate couchbaseTemplate; + @Autowired public ReactiveCouchbaseTemplate reactiveCouchbaseTemplate; + + @BeforeEach + @Override + public void beforeEach() { + /** * The couchbaseTemplate inherited from JavaIntegrationTests uses org.springframework.data.couchbase.domain.Config * It has typeName = 't' (instead of _class). Don't use it. @@ -85,23 +88,19 @@ public static void beforeEverything() { couchbaseTemplate.getCouchbaseClientFactory().getBucket().defaultCollection().upsert(k, JsonObject.create().put("x", k)); } + super.beforeEach(); } - @AfterAll - public static void afterEverthing() { + @AfterEach + public void afterEach() { couchbaseTemplate.removeByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).all(); couchbaseTemplate.findByQuery(Airport.class).withConsistency(QueryScanConsistency.REQUEST_PLUS).all(); + super.afterEach(); for (String k : keyList) { couchbaseTemplate.getCouchbaseClientFactory().getBucket().defaultCollection().remove(k); } } - @BeforeEach - @Override - public void beforeEach() { - super.beforeEach(); - } - static List keyList = Arrays.asList("a", "b", "c", "d", "e"); static Collection collection; static ReactiveCollection rCollection; diff --git a/src/test/java/org/springframework/data/couchbase/domain/Person.java b/src/test/java/org/springframework/data/couchbase/domain/Person.java index eb2a4dd75..639ae4b5e 100644 --- a/src/test/java/org/springframework/data/couchbase/domain/Person.java +++ b/src/test/java/org/springframework/data/couchbase/domain/Person.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors + * Copyright 2012-2022 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. @@ -22,13 +22,20 @@ import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedBy; import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.Transient; import org.springframework.data.annotation.Version; import org.springframework.data.couchbase.core.mapping.Document; import org.springframework.data.couchbase.core.mapping.Field; +import org.springframework.data.domain.Persistable; import org.springframework.lang.Nullable; +/** + * Person entity for tests. + * + * @author Michael Reiche + */ @Document -public class Person extends AbstractEntity { +public class Person extends AbstractEntity implements Persistable { Optional firstname; @Nullable Optional lastname; @@ -47,13 +54,18 @@ public class Person extends AbstractEntity { private Address address; - public Person() {} + @Transient private boolean isNew; + + public Person() { + setId(UUID.randomUUID()); + } public Person(String firstname, String lastname) { this(); setFirstname(firstname); setLastname(lastname); setMiddlename("Nick"); + isNew(true); } public Person(int id, String firstname, String lastname) { @@ -61,19 +73,24 @@ public Person(int id, String firstname, String lastname) { setId(new UUID(id, id)); } + public Person(UUID id, String firstname, String lastname) { + this(firstname, lastname); + setId(id); + } + static String optional(String name, Optional obj) { if (obj != null) { if (obj.isPresent()) { - return (" " + name + ": '" + obj.get() + "'\n"); + return (" " + name + ": '" + obj.get() + "'"); } else { - return " " + name + ": null\n"; + return " " + name + ": null"; } } return ""; } - public Optional getFirstname() { - return firstname; + public String getFirstname() { + return firstname.get(); } public void setFirstname(String firstname) { @@ -84,8 +101,8 @@ public void setFirstname(Optional firstname) { this.firstname = firstname; } - public Optional getLastname() { - return lastname; + public String getLastname() { + return lastname.get(); } public void setLastname(String lastname) { @@ -131,7 +148,7 @@ public String toString() { sb.append(optional(", firstname", firstname)); sb.append(optional(", lastname", lastname)); if (middlename != null) - sb.append(", middlename : " + middlename); + sb.append(", middlename : '" + middlename + "'"); sb.append(", version : " + version); if (creator != null) { sb.append(", creator : " + creator); @@ -148,8 +165,48 @@ public String toString() { if (getAddress() != null) { sb.append(", address : " + getAddress().toString()); } - sb.append("}"); + sb.append("\n}"); return sb.toString(); } + public Person withFirstName(String firstName) { + Person p = new Person(this.getId(), firstName, this.getLastname()); + p.version = version; + return p; + } + + // A with-er that returns the same object ?? + public Person withVersion(Long version) { + // Person p = new Person(this.getId(), this.getFirstname(), this.getLastname()); + this.version = version; + return this; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!super.equals(obj)) { + return false; + } + + Person that = (Person) obj; + return this.getId().equals(that.getId()) && this.getFirstname().equals(that.getFirstname()) + && this.getLastname().equals(that.getLastname()) && this.getMiddlename().equals(that.getMiddlename()); + } + + @Override + public boolean isNew() { + return isNew; + } + + public void isNew(boolean isNew) { + this.isNew = isNew; + } + + public Person withIdFirstname() { + return this.withFirstName(getId().toString()); + } + } diff --git a/src/test/java/org/springframework/data/couchbase/domain/PersonRepository.java b/src/test/java/org/springframework/data/couchbase/domain/PersonRepository.java index 2c9985d74..baa832a8c 100644 --- a/src/test/java/org/springframework/data/couchbase/domain/PersonRepository.java +++ b/src/test/java/org/springframework/data/couchbase/domain/PersonRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors + * Copyright 2012-2022 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. @@ -18,9 +18,10 @@ import java.util.List; import java.util.UUID; +import org.springframework.data.couchbase.repository.CouchbaseRepository; +import org.springframework.data.couchbase.repository.DynamicProxyable; import org.springframework.data.couchbase.repository.Query; import org.springframework.data.couchbase.repository.ScanConsistency; -import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.query.Param; import com.couchbase.client.java.query.QueryScanConsistency; @@ -28,7 +29,7 @@ /** * @author Michael Reiche */ -public interface PersonRepository extends CrudRepository { +public interface PersonRepository extends CouchbaseRepository, DynamicProxyable { /* * These methods are exercised in HomeController of the test spring-boot DemoApplication @@ -95,7 +96,7 @@ public interface PersonRepository extends CrudRepository { boolean existsById(UUID var1); - Iterable findAll(); + List findAll(); long count(); diff --git a/src/test/java/org/springframework/data/couchbase/domain/PersonWithoutVersion.java b/src/test/java/org/springframework/data/couchbase/domain/PersonWithoutVersion.java new file mode 100644 index 000000000..bb4fdaf34 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/domain/PersonWithoutVersion.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-2022 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.util.Optional; +import java.util.UUID; + +import org.springframework.data.couchbase.core.mapping.Document; + +/** + * Person entity without a an @Version property + * + * @author Michael Reiche + */ +@Document +public class PersonWithoutVersion extends AbstractEntity { + Optional firstname; + Optional lastname; + + public PersonWithoutVersion() { + firstname = Optional.empty(); + lastname = Optional.empty(); + } + + public PersonWithoutVersion(String firstname, String lastname) { + this.firstname = Optional.of(firstname); + this.lastname = Optional.of(lastname); + setId(UUID.randomUUID()); + } + + public PersonWithoutVersion(UUID id, String firstname, String lastname) { + this.firstname = Optional.of(firstname); + this.lastname = Optional.of(lastname); + setId(id); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/domain/ReactiveAirportRepository.java b/src/test/java/org/springframework/data/couchbase/domain/ReactiveAirportRepository.java index 005b3f51e..786c4ea7c 100644 --- a/src/test/java/org/springframework/data/couchbase/domain/ReactiveAirportRepository.java +++ b/src/test/java/org/springframework/data/couchbase/domain/ReactiveAirportRepository.java @@ -46,6 +46,10 @@ public interface ReactiveAirportRepository extends ReactiveCouchbaseRepository, DynamicProxyable { + @Query("SELECT META(#{#n1ql.bucket}).id AS __id, META(#{#n1ql.bucket}).cas AS __cas, meta().id as id FROM #{#n1ql.bucket} WHERE #{#n1ql.filter} #{[1]}") + @ScanConsistency(query = QueryScanConsistency.REQUEST_PLUS) + Flux findIdByDynamicN1ql(String docType, String queryStatement); + @Override @ScanConsistency(query = QueryScanConsistency.REQUEST_PLUS) Flux findAll(); diff --git a/src/test/java/org/springframework/data/couchbase/domain/ReactivePersonRepository.java b/src/test/java/org/springframework/data/couchbase/domain/ReactivePersonRepository.java new file mode 100644 index 000000000..e87974e0f --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/domain/ReactivePersonRepository.java @@ -0,0 +1,27 @@ +/* + * Copyright 2012-2022 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.couchbase.repository.DynamicProxyable; +import org.springframework.data.couchbase.repository.ReactiveCouchbaseRepository; + +/** + * @author Michael Reiche + */ +public interface ReactivePersonRepository + extends ReactiveCouchbaseRepository, DynamicProxyable { + +} diff --git a/src/test/java/org/springframework/data/couchbase/domain/UserRepository.java b/src/test/java/org/springframework/data/couchbase/domain/UserRepository.java index 99a11fb5e..20fe84ee1 100644 --- a/src/test/java/org/springframework/data/couchbase/domain/UserRepository.java +++ b/src/test/java/org/springframework/data/couchbase/domain/UserRepository.java @@ -76,4 +76,7 @@ default List getByFirstname(String firstname) { } catch (InterruptedException ie) {} return findByFirstname(firstname); } + + @Override + User save(User user); } diff --git a/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryQueryIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryQueryIntegrationTests.java index fbb945824..4c09a9734 100644 --- a/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryQueryIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryQueryIntegrationTests.java @@ -16,6 +16,7 @@ package org.springframework.data.couchbase.repository; +import static com.couchbase.client.java.query.QueryOptions.queryOptions; import static com.couchbase.client.java.query.QueryScanConsistency.NOT_BOUNDED; import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; import static java.util.Arrays.asList; @@ -108,6 +109,7 @@ import com.couchbase.client.java.json.JsonArray; import com.couchbase.client.java.kv.GetResult; import com.couchbase.client.java.kv.InsertOptions; +import com.couchbase.client.java.kv.MutationState; import com.couchbase.client.java.query.QueryOptions; import com.couchbase.client.java.query.QueryScanConsistency; @@ -141,6 +143,7 @@ public class CouchbaseRepositoryQueryIntegrationTests extends ClusterAwareIntegr public void beforeEach() { super.beforeEach(); couchbaseTemplate.removeByQuery(User.class).withConsistency(REQUEST_PLUS).all(); + couchbaseTemplate.removeByQuery(Person.class).withConsistency(REQUEST_PLUS).all(); couchbaseTemplate.findByQuery(User.class).withConsistency(REQUEST_PLUS).all(); couchbaseTemplate.removeByQuery(Airport.class).withConsistency(REQUEST_PLUS).all(); couchbaseTemplate.findByQuery(Airport.class).withConsistency(REQUEST_PLUS).all(); @@ -283,8 +286,7 @@ void findBySimpleProperty() { try { vie = new Airport("airports::vie", "vie", "low6"); vie = airportRepository.save(vie); - Airport airport2 = airportRepository - .withOptions(QueryOptions.queryOptions().scanConsistency(QueryScanConsistency.REQUEST_PLUS)) + Airport airport2 = airportRepository.withOptions(queryOptions().scanConsistency(REQUEST_PLUS)) .findByIata(vie.getIata()); assertEquals(airport2, vie); @@ -356,6 +358,9 @@ public void saveNotBoundedRequestPlus() { @Test public void saveNotBoundedWithDefaultRepository() { + if (config().isUsingCloud()) { // I don't think the query following the insert will be quick enough for the test + return; + } airportRepository.withOptions(QueryOptions.queryOptions().scanConsistency(REQUEST_PLUS)).deleteAll(); ApplicationContext ac = new AnnotationConfigApplicationContext(Config.class); // the Config class has been modified, these need to be loaded again @@ -363,16 +368,21 @@ public void saveNotBoundedWithDefaultRepository() { AirportRepositoryScanConsistencyTest airportRepositoryRP = (AirportRepositoryScanConsistencyTest) ac .getBean("airportRepositoryScanConsistencyTest"); - List sizeBeforeTest = airportRepositoryRP.findAll(); + List sizeBeforeTest = (List)airportRepositoryRP.findAll(); assertEquals(0, sizeBeforeTest.size()); - Airport vie = new Airport("airports::vie", "vie", "low9"); - Airport saved = airportRepositoryRP.save(vie); - List allSaved = airportRepositoryRP.findAll(); - couchbaseTemplate.removeById(Airport.class).one(saved.getId()); - if (!config().isUsingCloud()) { - assertTrue(allSaved.isEmpty(), "should not have been empty"); + boolean notFound = false; + for (int i = 0; i < 100; i++) { + Airport vie = new Airport("airports::vie", "vie", "low9"); + Airport saved = airportRepositoryRP.save(vie); + List allSaved = (List)airportRepositoryRP.findAll(); + couchbaseTemplate.removeById(Airport.class).one(saved.getId()); + if (allSaved.isEmpty()) { + notFound = true; + break; + } } + assertTrue(notFound, "the doc should not have been found. maybe"); } @Test @@ -388,7 +398,7 @@ public void saveRequestPlusWithDefaultRepository() { Airport vie = new Airport("airports::vie", "vie", "low9"); Airport saved = airportRepositoryRP.save(vie); - List allSaved = airportRepositoryRP.findAll(); + List allSaved = airportRepositoryRP.findAll(REQUEST_PLUS); couchbaseTemplate.removeById(Airport.class).one(saved.getId()); assertEquals(1, allSaved.size(), "should have found 1 airport"); } @@ -399,8 +409,7 @@ void findByTypeAlias() { try { vie = new Airport("airports::vie", "vie", "loww"); vie = airportRepository.save(vie); - List airports = couchbaseTemplate.findByQuery(Airport.class) - .withConsistency(QueryScanConsistency.REQUEST_PLUS) + List airports = couchbaseTemplate.findByQuery(Airport.class).withConsistency(REQUEST_PLUS) .matching(org.springframework.data.couchbase.core.query.Query .query(QueryCriteria.where(N1QLExpression.x("_class")).is("airport"))) .all(); @@ -462,15 +471,13 @@ void findBySimplePropertyWithCollection() { Airport saved = airportRepository.withScope(scopeName).withCollection(collectionName).save(vie); // given collection (on scope used by template) Airport airport2 = airportRepository.withCollection(collectionName) - .withOptions(QueryOptions.queryOptions().scanConsistency(QueryScanConsistency.REQUEST_PLUS)) - .iata(vie.getIata()); + .withOptions(queryOptions().scanConsistency(REQUEST_PLUS)).iata(vie.getIata()); assertEquals(saved, airport2); // given scope and collection Airport airport3 = airportRepository.withScope(scopeName).withCollection(collectionName) - .withOptions(QueryOptions.queryOptions().scanConsistency(QueryScanConsistency.REQUEST_PLUS)) - .iata(vie.getIata()); + .withOptions(queryOptions().scanConsistency(REQUEST_PLUS)).iata(vie.getIata()); assertEquals(saved, airport3); // given bad collection @@ -478,7 +485,8 @@ void findBySimplePropertyWithCollection() { () -> airportRepository.withCollection("bogusCollection").iata(vie.getIata())); // given bad scope - assertThrows(IndexFailureException.class, () -> airportRepository.withScope("bogusScope").iata(vie.getIata())); + assertThrows(IndexFailureException.class, + () -> airportRepository.withScope("bogusScope").withCollection(collectionName).iata(vie.getIata())); } finally { airportRepository.delete(vie); @@ -512,12 +520,11 @@ void findBySimplePropertyWithOptions() { try { Airport saved = airportRepository.save(vie); // Duration of 1 nano-second will cause timeout - assertThrows(AmbiguousTimeoutException.class, () -> airportRepository - .withOptions(QueryOptions.queryOptions().timeout(Duration.ofNanos(1))).iata(vie.getIata())); + assertThrows(AmbiguousTimeoutException.class, + () -> airportRepository.withOptions(queryOptions().timeout(Duration.ofNanos(1))).iata(vie.getIata())); - Airport airport3 = airportRepository.withOptions( - QueryOptions.queryOptions().scanConsistency(QueryScanConsistency.REQUEST_PLUS).parameters(positionalParams)) - .iata(vie.getIata()); + Airport airport3 = airportRepository + .withOptions(queryOptions().scanConsistency(REQUEST_PLUS).parameters(positionalParams)).iata(vie.getIata()); assertEquals(saved, airport3); } finally { @@ -535,7 +542,8 @@ public void saveNotBounded() { // set version == 0 so save() will be an upsert, not a replace Airport saved = airportRepository.save(vie.clearVersion()); try { - airport2 = airportRepository.iata(saved.getIata()); + airport2 = airportRepository.withOptions(queryOptions().scanConsistency(NOT_BOUNDED)) + .iata(saved.getIata()); if (airport2 == null) { break; } @@ -548,7 +556,8 @@ public void saveNotBounded() { assertEquals(vie.getId(), removeResult.getId()); assertTrue(removeResult.getCas() != 0); assertTrue(removeResult.getMutationToken().isPresent()); - Airport airport3 = airportRepository.iata(vie.getIata()); + Airport airport3 = airportRepository.withOptions(queryOptions().scanConsistency(REQUEST_PLUS) + .consistentWith(MutationState.from(removeResult.getMutationToken().get()))).iata(vie.getIata()); assertNull(airport3, "should have been removed"); } } @@ -717,6 +726,24 @@ void countSlicePage() { } } + @Test + void testGroupBy() { + String[] iatas = { "JFK", "IAD", "SFO", "SJC", "SEA", "LAX", "PHX" }; + try { + airportRepository.saveAll( + Arrays.stream(iatas).map((iata) -> new Airport("airports::" + iata, iata, iata.toLowerCase(Locale.ROOT))) + .collect(Collectors.toSet())); + List airports = airportRepository.groupByIata(); + for (Airport a : airports) { + System.out.println(a); + } + + } finally { + airportRepository + .deleteAllById(Arrays.stream(iatas).map((iata) -> "airports::" + iata).collect(Collectors.toSet())); + } + } + @Test void badCount() { assertThrows(CouchbaseQueryExecutionException.class, () -> airportRepository.countBad()); @@ -872,7 +899,7 @@ void threadSafeStringParametersTest() throws Exception { void deleteAllById() { Airport vienna = new Airport("airports::vie", "vie", "LOWW"); - Airport frankfurt = new Airport("airports::fra", "fra", "EDDF"); + Airport frankfurt = new Airport("airports::fra", "fra", "EDDZ"); Airport losAngeles = new Airport("airports::lax", "lax", "KLAX"); try { airportRepository.saveAll(asList(vienna, frankfurt, losAngeles)); diff --git a/src/test/java/org/springframework/data/couchbase/repository/ReactiveCouchbaseRepositoryKeyValueIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/repository/ReactiveCouchbaseRepositoryKeyValueIntegrationTests.java index cd8d42589..d15268e98 100644 --- a/src/test/java/org/springframework/data/couchbase/repository/ReactiveCouchbaseRepositoryKeyValueIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/repository/ReactiveCouchbaseRepositoryKeyValueIntegrationTests.java @@ -59,10 +59,8 @@ public class ReactiveCouchbaseRepositoryKeyValueIntegrationTests extends ClusterAwareIntegrationTests { @Autowired ReactiveUserRepository userRepository; - @Autowired ReactiveAirportRepository reactiveAirportRepository; - - @Autowired ReactiveAirlineRepository airlineRepository; + @Autowired ReactiveAirlineRepository reactiveAirlineRepository; @Test @IgnoreWhen(clusterTypes = ClusterType.MOCKED) @@ -82,9 +80,10 @@ void saveReplaceUpsertInsert() { // Airline does not have a version Airline airline = new Airline(UUID.randomUUID().toString(), "MyAirline", null); // save the document - we don't care how on this call - airlineRepository.save(airline).block(); - airlineRepository.save(airline).block(); // If it was an insert it would fail. Can't tell if an upsert or replace. - airlineRepository.delete(airline).block(); + reactiveAirlineRepository.save(airline).block(); + reactiveAirlineRepository.save(airline).block(); // If it was an insert it would fail. Can't tell if an upsert or + // replace. + reactiveAirlineRepository.delete(airline).block(); } @Test diff --git a/src/test/java/org/springframework/data/couchbase/repository/ReactiveCouchbaseRepositoryQueryIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/repository/ReactiveCouchbaseRepositoryQueryIntegrationTests.java index 4c0e1819c..815f9a4c1 100644 --- a/src/test/java/org/springframework/data/couchbase/repository/ReactiveCouchbaseRepositoryQueryIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/repository/ReactiveCouchbaseRepositoryQueryIntegrationTests.java @@ -96,6 +96,28 @@ void shouldSaveAndFindAll() { } } + @Test + void testQuery() { + Airport vie = null; + Airport jfk = null; + try { + vie = new Airport("airports::vie", "vie", "low1"); + reactiveAirportRepository.save(vie).block(); + jfk = new Airport("airports::jfk", "JFK", "xxxx"); + reactiveAirportRepository.save(jfk).block(); + + List all = reactiveAirportRepository.findIdByDynamicN1ql("", "").toStream().collect(Collectors.toList()); + System.out.println(all); + assertFalse(all.isEmpty()); + assertTrue(all.stream().anyMatch(a -> a.equals("airports::vie"))); + assertTrue(all.stream().anyMatch(a -> a.equals("airports::jfk"))); + + } finally { + reactiveAirportRepository.delete(vie).block(); + reactiveAirportRepository.delete(jfk).block(); + } + } + @Test void findBySimpleProperty() { Airport vie = null; @@ -219,7 +241,7 @@ void count() { void deleteAllById() { Airport vienna = new Airport("airports::vie", "vie", "LOWW"); - Airport frankfurt = new Airport("airports::fra", "fra", "EDDF"); + Airport frankfurt = new Airport("airports::fra", "fra", "EDDX"); Airport losAngeles = new Airport("airports::lax", "lax", "KLAX"); try { @@ -242,7 +264,7 @@ void deleteAllById() { void deleteAll() { Airport vienna = new Airport("airports::vie", "vie", "LOWW"); - Airport frankfurt = new Airport("airports::fra", "fra", "EDDF"); + Airport frankfurt = new Airport("airports::fra", "fra", "EDDY"); Airport losAngeles = new Airport("airports::lax", "lax", "KLAX"); try { diff --git a/src/test/java/org/springframework/data/couchbase/repository/query/CouchbaseRepositoryQueryCollectionIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/repository/query/CouchbaseRepositoryQueryCollectionIntegrationTests.java index cf1a63159..027eb33c1 100644 --- a/src/test/java/org/springframework/data/couchbase/repository/query/CouchbaseRepositoryQueryCollectionIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/repository/query/CouchbaseRepositoryQueryCollectionIntegrationTests.java @@ -31,12 +31,15 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; +import org.springframework.data.couchbase.core.RemoveResult; import org.springframework.data.couchbase.domain.Address; import org.springframework.data.couchbase.domain.AddressAnnotated; import org.springframework.data.couchbase.domain.Airport; import org.springframework.data.couchbase.domain.AirportRepository; import org.springframework.data.couchbase.domain.AirportRepositoryAnnotated; -import org.springframework.data.couchbase.domain.Config; +import org.springframework.data.couchbase.domain.CollectionsConfig; import org.springframework.data.couchbase.domain.User; import org.springframework.data.couchbase.domain.UserCol; import org.springframework.data.couchbase.domain.UserColRepository; @@ -60,15 +63,18 @@ * * @author Michael Reiche */ -@SpringJUnitConfig(Config.class) @IgnoreWhen(missesCapabilities = { Capabilities.QUERY, Capabilities.COLLECTIONS }, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(CollectionsConfig.class) public class CouchbaseRepositoryQueryCollectionIntegrationTests extends CollectionAwareIntegrationTests { - @Autowired AirportRepository airportRepository; // initialized in beforeEach() - @Autowired UserColRepository userColRepository; // initialized in beforeEach() - @Autowired UserSubmissionAnnotatedRepository userSubmissionAnnotatedRepository; // initialized in beforeEach() - @Autowired UserSubmissionUnannotatedRepository userSubmissionUnannotatedRepository; // initialized in beforeEach() @Autowired AirportRepositoryAnnotated airportRepositoryAnnotated; + @Autowired AirportRepository airportRepository; + @Autowired UserColRepository userColRepository; + @Autowired UserSubmissionAnnotatedRepository userSubmissionAnnotatedRepository; + @Autowired UserSubmissionUnannotatedRepository userSubmissionUnannotatedRepository; + + @Autowired public CouchbaseTemplate couchbaseTemplate; + @Autowired public ReactiveCouchbaseTemplate reactiveCouchbaseTemplate; @BeforeAll public static void beforeAll() { @@ -222,9 +228,14 @@ public void testScopeCollectionAnnotationSwap() { // collection from CrudMethodMetadata of UserCol.save() UserCol userCol = new UserCol("1", "Dave", "Wilson"); Airport airport = new Airport("3", "myIata", "myIcao"); - UserCol savedCol = userColRepository.save(userCol); // uses UserCol annotation scope, populates CrudMethodMetadata - userColRepository.delete(userCol); // uses UserCol annotation scope, populates CrudMethodMetadata - assertThrows(IllegalStateException.class, () -> airportRepository.save(airport)); + try { + UserCol savedCol = userColRepository.save(userCol); // uses UserCol annotation scope, populates CrudMethodMetadata + userColRepository.delete(userCol); // uses UserCol annotation scope, populates CrudMethodMetadata + assertThrows(IllegalStateException.class, () -> airportRepository.save(airport)); + } finally { + List removed = couchbaseTemplate.removeByQuery(Airport.class).all(); + couchbaseTemplate.findByQuery(Airport.class).withConsistency(REQUEST_PLUS).all(); + } } // template default scope is my_scope diff --git a/src/test/java/org/springframework/data/couchbase/repository/query/ReactiveCouchbaseRepositoryQueryCollectionIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/repository/query/ReactiveCouchbaseRepositoryQueryCollectionIntegrationTests.java index be2a14861..3ae641c2f 100644 --- a/src/test/java/org/springframework/data/couchbase/repository/query/ReactiveCouchbaseRepositoryQueryCollectionIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/repository/query/ReactiveCouchbaseRepositoryQueryCollectionIntegrationTests.java @@ -27,8 +27,10 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; import org.springframework.data.couchbase.domain.Airport; -import org.springframework.data.couchbase.domain.Config; +import org.springframework.data.couchbase.domain.CollectionsConfig; import org.springframework.data.couchbase.domain.ReactiveAirportRepository; import org.springframework.data.couchbase.domain.ReactiveAirportRepositoryAnnotated; import org.springframework.data.couchbase.domain.ReactiveUserColRepository; @@ -38,26 +40,28 @@ import org.springframework.data.couchbase.util.ClusterType; import org.springframework.data.couchbase.util.CollectionAwareIntegrationTests; import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import com.couchbase.client.core.error.IndexFailureException; import com.couchbase.client.core.io.CollectionIdentifier; import com.couchbase.client.java.json.JsonArray; import com.couchbase.client.java.query.QueryOptions; import com.couchbase.client.java.query.QueryScanConsistency; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; /** * Reactive Repository Query Tests with Collections * * @author Michael Reiche */ -@SpringJUnitConfig(Config.class) +@SpringJUnitConfig(CollectionsConfig.class) @IgnoreWhen(missesCapabilities = { Capabilities.QUERY, Capabilities.COLLECTIONS }, clusterTypes = ClusterType.MOCKED) public class ReactiveCouchbaseRepositoryQueryCollectionIntegrationTests extends CollectionAwareIntegrationTests { @Autowired ReactiveAirportRepository reactiveAirportRepository; @Autowired ReactiveAirportRepositoryAnnotated reactiveAirportRepositoryAnnotated; @Autowired ReactiveUserColRepository userColRepository; + @Autowired public CouchbaseTemplate couchbaseTemplate; + @Autowired public ReactiveCouchbaseTemplate reactiveCouchbaseTemplate; @BeforeAll public static void beforeAll() { @@ -221,9 +225,10 @@ void stringDeleteCollectionTest() { Airport otherAirport = new Airport(loc(), "xxx", "xyz"); try { airport = reactiveAirportRepository.withScope(scopeName).withCollection(collectionName).save(airport).block(); - otherAirport = reactiveAirportRepository.withScope(scopeName).withCollection(collectionName).save(otherAirport).block(); - assertEquals(1, - reactiveAirportRepository.withScope(scopeName).withCollection(collectionName).deleteByIata(airport.getIata()).collectList().block().size()); + otherAirport = reactiveAirportRepository.withScope(scopeName).withCollection(collectionName).save(otherAirport) + .block(); + assertEquals(1, reactiveAirportRepository.withScope(scopeName).withCollection(collectionName) + .deleteByIata(airport.getIata()).collectList().block().size()); } catch (Exception e) { e.printStackTrace(); throw e; @@ -240,7 +245,8 @@ void stringDeleteWithRepositoryAnnotationTest() { airport = reactiveAirportRepositoryAnnotated.withScope(scopeName).save(airport).block(); otherAirport = reactiveAirportRepositoryAnnotated.withScope(scopeName).save(otherAirport).block(); // don't specify a collection - should get collection from AirportRepositoryAnnotated - assertEquals(1, reactiveAirportRepositoryAnnotated.withScope(scopeName).deleteByIata(airport.getIata()).collectList().block().size()); + assertEquals(1, reactiveAirportRepositoryAnnotated.withScope(scopeName).deleteByIata(airport.getIata()) + .collectList().block().size()); } catch (Exception e) { e.printStackTrace(); throw e; @@ -258,8 +264,8 @@ void stringDeleteWithMethodAnnotationTest() { Airport airportSaved = reactiveAirportRepositoryAnnotated.withScope(scopeName).save(airport).block(); Airport otherAirportSaved = reactiveAirportRepositoryAnnotated.withScope(scopeName).save(otherAirport).block(); // don't specify a collection - should get collection from deleteByIataAnnotated method - assertThrows(IndexFailureException.class, () -> assertEquals(1, - reactiveAirportRepositoryAnnotated.withScope(scopeName).deleteByIataAnnotated(airport.getIata()).collectList().block().size())); + assertThrows(IndexFailureException.class, () -> assertEquals(1, reactiveAirportRepositoryAnnotated + .withScope(scopeName).deleteByIataAnnotated(airport.getIata()).collectList().block().size())); } catch (Exception e) { e.printStackTrace(); throw e; diff --git a/src/test/java/org/springframework/data/couchbase/transactions/AfterTransactionAssertion.java b/src/test/java/org/springframework/data/couchbase/transactions/AfterTransactionAssertion.java new file mode 100644 index 000000000..54484ff92 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/AfterTransactionAssertion.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-2022 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.transactions; + +import lombok.Data; + +import org.springframework.data.domain.Persistable; + +/** + * For testing transactions. + * + * @author Michael Reiche + */ +@Data +public class AfterTransactionAssertion { + + private final T persistable; + private boolean expectToBePresent; + + public void isPresent() { + expectToBePresent = true; + } + + public void isNotPresent() { + expectToBePresent = false; + } + + public Object getId() { + return persistable.getId(); + } + + public boolean shouldBePresent() { + return expectToBePresent; + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/CouchbasePersonTransactionIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/CouchbasePersonTransactionIntegrationTests.java new file mode 100644 index 000000000..34fa22e9b --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/CouchbasePersonTransactionIntegrationTests.java @@ -0,0 +1,348 @@ +/* + * Copyright 2022 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.transactions; + +import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import lombok.Data; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; +import org.springframework.data.couchbase.core.RemoveResult; +import org.springframework.data.couchbase.core.TransactionalSupport; +import org.springframework.data.couchbase.core.query.Query; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.domain.PersonRepository; +import org.springframework.data.couchbase.domain.ReactivePersonRepository; +import org.springframework.data.couchbase.transaction.error.TransactionSystemUnambiguousException; +import org.springframework.data.couchbase.transactions.util.TransactionTestUtil; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.reactive.TransactionalOperator; + +import com.couchbase.client.core.error.DocumentExistsException; +import com.couchbase.client.java.transactions.TransactionResult; + +/** + * Tests for com.couchbase.transactions using + *
  • couchbase reactive transaction manager via transactional operator couchbase non-reactive transaction + * manager via @Transactional @Transactional(transactionManager = + * BeanNames.REACTIVE_COUCHBASE_TRANSACTION_MANAGER)
  • + * + * @author Michael Reiche + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(classes = { TransactionsConfig.class, PersonService.class }) +public class CouchbasePersonTransactionIntegrationTests extends JavaIntegrationTests { + // intellij flags "Could not autowire" when config classes are specified with classes={...}. But they are populated. + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired PersonRepository repo; + @Autowired ReactivePersonRepository rxRepo; + @Autowired CouchbaseTemplate cbTmpl; + @Autowired ReactiveCouchbaseTemplate rxCBTmpl; + @Autowired PersonService personService; + @Autowired TransactionalOperator transactionalOperator; + + String sName = "_default"; + String cName = "_default"; + + Person WalterWhite; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + @AfterAll + public static void afterAll() { + callSuperAfterAll(new Object() {}); + } + + @AfterEach + public void afterEachTest() { + TransactionTestUtil.assertNotInTransaction(); + } + + @BeforeEach + public void beforeEachTest() { + WalterWhite = new Person("Walter", "White"); + TransactionTestUtil.assertNotInTransaction(); + List rp0 = cbTmpl.removeByQuery(Person.class).withConsistency(REQUEST_PLUS).all(); + List rp1 = cbTmpl.removeByQuery(Person.class).withConsistency(REQUEST_PLUS).inScope(sName) + .inCollection(cName).all(); + List rp2 = cbTmpl.removeByQuery(EventLog.class).withConsistency(REQUEST_PLUS).all(); + List rp3 = cbTmpl.removeByQuery(EventLog.class).withConsistency(REQUEST_PLUS).inScope(sName) + .inCollection(cName).all(); + + List p0 = cbTmpl.findByQuery(Person.class).withConsistency(REQUEST_PLUS).all(); + List p1 = cbTmpl.findByQuery(Person.class).withConsistency(REQUEST_PLUS).inScope(sName).inCollection(cName) + .all(); + List e0 = cbTmpl.findByQuery(EventLog.class).withConsistency(REQUEST_PLUS).all(); + List e1 = cbTmpl.findByQuery(EventLog.class).withConsistency(REQUEST_PLUS).inScope(sName) + .inCollection(cName).all(); + + } + + @DisplayName("rollback after exception using transactionalOperator") + @Test + public void shouldRollbackAfterException() { + assertThrowsWithCause(() -> personService.savePersonErrors(WalterWhite), + TransactionSystemUnambiguousException.class, SimulateFailureException.class); + Long count = cbTmpl.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count(); + assertEquals(0, count, "should have done roll back and left 0 entries"); + } + + @Test + @DisplayName("rollback after exception using @Transactional") + public void shouldRollbackAfterExceptionOfTxAnnotatedMethod() { + assertThrowsWithCause(() -> personService.declarativeSavePersonErrors(WalterWhite), + TransactionSystemUnambiguousException.class, SimulateFailureException.class); + Long count = cbTmpl.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count(); + assertEquals(0, count, "should have done roll back and left 0 entries"); + } + + @Test + @DisplayName("rollback after exception after using @Transactional(reactive)") + public void shouldRollbackAfterExceptionOfTxAnnotatedMethodReactive() { + assertThrowsWithCause(() -> personService.declarativeSavePersonErrorsReactive(WalterWhite).block(), + TransactionSystemUnambiguousException.class, SimulateFailureException.class); + Long count = cbTmpl.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count(); + assertEquals(0, count, "should have done roll back and left 0 entries"); + } + + @Test + public void commitShouldPersistTxEntries() { + Person p = personService.savePerson(WalterWhite); + Long count = cbTmpl.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count(); + assertEquals(1, count, "should have saved and found 1"); + } + + @Test + public void commitShouldPersistTxEntriesOfTxAnnotatedMethod() { + Person p = personService.declarativeSavePerson(WalterWhite); + Long count = cbTmpl.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count(); + assertEquals(1, count, "should have saved and found 1"); + } + + @Test + /** + * This fails with TransactionOperationFailedException {ec:FAIL_CAS_MISMATCH, retry:true, autoRollback:true}. I don't + * know why it isn't retried. This seems like it is due to the functioning of AbstractPlatformTransactionManager + */ + public void replaceInTxAnnotatedCallback() { + Person person = cbTmpl.insertById(Person.class).one(WalterWhite); + Person switchedPerson = new Person(person.getId(), "Dave", "Reynolds"); + + AtomicInteger tryCount = new AtomicInteger(0); + Person p = personService.declarativeFindReplacePersonCallback(switchedPerson, tryCount); + Person pFound = cbTmpl.findById(Person.class).one(person.id()); + assertEquals(switchedPerson.getFirstname(), pFound.getFirstname(), "should have been switched"); + } + + @Test + public void commitShouldPersistTxEntriesOfTxAnnotatedMethodReactive() { + Person p = personService.declarativeSavePersonReactive(WalterWhite).block(); + Long count = cbTmpl.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count(); + assertEquals(1, count, "should have saved and found 1"); + } + + @Test + public void commitShouldPersistTxEntriesAcrossCollections() { + List persons = personService.saveWithLogs(WalterWhite); + Long count = cbTmpl.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count(); + assertEquals(1, count, "should have saved and found 1"); + Long countEvents = cbTmpl.count(new Query(), EventLog.class); // + assertEquals(4, countEvents, "should have saved and found 4"); + } + + @Test + public void rollbackShouldAbortAcrossCollections() { + assertThrowsWithCause(() -> personService.saveWithErrorLogs(WalterWhite), + TransactionSystemUnambiguousException.class, SimulateFailureException.class); + List persons = cbTmpl.findByQuery(Person.class).withConsistency(REQUEST_PLUS).all(); + assertEquals(0, persons.size(), "should have done roll back and left 0 entries"); + List events = cbTmpl.findByQuery(EventLog.class).withConsistency(REQUEST_PLUS).all(); // + assertEquals(0, events.size(), "should have done roll back and left 0 entries"); + } + + @Test + public void countShouldWorkInsideTransaction() { + Long count = personService.countDuringTx(WalterWhite); + assertEquals(1, count, "should have counted 1 during tx"); + } + + @Test + public void emitMultipleElementsDuringTransaction() { + List docs = personService.saveWithLogs(WalterWhite); + assertEquals(4, docs.size(), "should have found 4 eventlogs"); + } + + @Test + public void errorAfterTxShouldNotAffectPreviousStep() { + Person p = personService.savePerson(WalterWhite); + assertThrowsOneOf(() -> personService.savePerson(p), TransactionSystemUnambiguousException.class, + DocumentExistsException.class); + Long count = cbTmpl.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count(); + assertEquals(1, count, "should have saved and found 1"); + } + + @Test + public void replacePersonCBTransactionsRxTmpl() { + Person person = cbTmpl.insertById(Person.class).one(WalterWhite); + Mono result = rxCBTmpl.findById(Person.class).one(person.id()) // + .flatMap(pp -> rxCBTmpl.replaceById(Person.class).one(pp)).doOnNext(ppp -> TransactionalSupport + .checkForTransactionInThreadLocalStorage().doOnNext(v -> assertTrue(v.isPresent()))) + .as(transactionalOperator::transactional); + result.block(); + Person pFound = cbTmpl.findById(Person.class).one(person.id()); + assertEquals(person, pFound, "should have found expected " + person); + } + + @Test + public void insertPersonCBTransactionsRxTmplRollback() { + Mono result = rxCBTmpl.insertById(Person.class).one(WalterWhite) // + .doOnNext(ppp -> TransactionalSupport.checkForTransactionInThreadLocalStorage() + .doOnNext(v -> assertTrue(v.isPresent()))) + .map(p -> throwSimulateFailureException(p)).as(transactionalOperator::transactional); // tx + assertThrowsWithCause(result::block, TransactionSystemUnambiguousException.class, SimulateFailureException.class); + Person pFound = cbTmpl.findById(Person.class).one(WalterWhite.id()); + assertNull(pFound, "insert should have been rolled back"); + } + + @Test + public void insertTwicePersonCBTransactionsRxTmplRollback() { + Mono result = rxCBTmpl.insertById(Person.class).one(WalterWhite) // + .flatMap(ppp -> rxCBTmpl.insertById(Person.class).one(ppp)) // + .as(transactionalOperator::transactional); + assertThrowsWithCause(result::block, TransactionSystemUnambiguousException.class, DuplicateKeyException.class); + Person pFound = cbTmpl.findById(Person.class).one(WalterWhite.id()); + assertNull(pFound, "insert should have been rolled back"); + } + + /** + * I think this test might fail sometimes? Does it need retryWhen() ? + */ + @Disabled("todo gp: disabling temporarily as hanging intermittently") + @Test + public void wrapperReplaceWithCasConflictResolvedViaRetry() { + AtomicInteger tryCount = new AtomicInteger(); + Person person = cbTmpl.insertById(Person.class).one(WalterWhite); + String newName = "Dave"; + + TransactionResult txResult = couchbaseClientFactory.getCluster().transactions().run(ctx -> { + Person ppp = cbTmpl.findById(Person.class).one(person.id()); + ReplaceLoopThread.updateOutOfTransaction(cbTmpl, person, tryCount.incrementAndGet()); + Person pppp = cbTmpl.replaceById(Person.class).one(ppp.withFirstName(newName)); + }); + + Person pFound = cbTmpl.findById(Person.class).one(person.id()); + assertTrue(tryCount.get() > 1, "should have been more than one try. tries: " + tryCount.get()); + assertEquals(newName, pFound.getFirstname(), "should have been switched"); + } + + /** + * This does process retries - by CallbackTransactionManager.execute() -> transactions.run() -> executeTransaction() + * -> retryWhen. + */ + /** + * This fails with TransactionOperationFailedException {ec:FAIL_CAS_MISMATCH, retry:true, autoRollback:true}. I don't + * know why it isn't retried. This seems like it is due to the functioning of AbstractPlatformTransactionManager + */ + @Test + public void replaceWithCasConflictResolvedViaRetryAnnotatedCallback() { + Person person = cbTmpl.insertById(Person.class).one(WalterWhite); + Person switchedPerson = new Person(person.getId(), "Dave", "Reynolds"); + AtomicInteger tryCount = new AtomicInteger(); + + Person p = personService.declarativeFindReplacePersonCallback(switchedPerson, tryCount); + Person pFound = cbTmpl.findById(Person.class).one(person.id()); + assertEquals(switchedPerson.getFirstname(), pFound.getFirstname(), "should have been switched"); + assertTrue(tryCount.get() > 1, "should have been more than one try. tries: " + tryCount.get()); + } + + /** + * Reactive @Transactional does not retry write-write conflicts. It throws RetryTransactionException up to the client + * and expects the client to retry. + */ + @Test + public void replaceWithCasConflictResolvedViaRetryAnnotatedReactive() { + Person person = cbTmpl.insertById(Person.class).one(WalterWhite); + Person switchedPerson = new Person(person.getId(), "Dave", "Reynolds"); + AtomicInteger tryCount = new AtomicInteger(); + + Person res = personService.declarativeFindReplacePersonReactive(switchedPerson, tryCount).block(); + + Person pFound = cbTmpl.findById(Person.class).one(person.id()); + assertEquals(switchedPerson.getFirstname(), pFound.getFirstname(), "should have been switched"); + assertTrue(tryCount.get() > 1, "should have been more than one try. tries: " + tryCount.get()); + } + + @Test + public void replaceWithCasConflictResolvedViaRetryAnnotated() { + Person person = cbTmpl.insertById(Person.class).one(WalterWhite); + Person switchedPerson = person.withFirstName("Dave"); + AtomicInteger tryCount = new AtomicInteger(); + Person p = personService.declarativeFindReplacePerson(switchedPerson, tryCount); + Person pFound = cbTmpl.findById(Person.class).one(person.id()); + System.out.println("pFound: " + pFound); + assertEquals(switchedPerson.getFirstname(), pFound.getFirstname(), "should have been switched"); + assertTrue(tryCount.get() > 1, "should have been more than one try. tries: " + tryCount.get()); + } + + @Data + static class EventLog { + + public EventLog() {}; // don't remove this + + public EventLog(ObjectId oid, String action) { + this.id = oid.toString(); + this.action = action; + } + + String id; + String action; + + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("EventLog : {\n"); + sb.append(" id : " + getId()); + sb.append(", action: " + action); + return sb.toString(); + } + } + +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/CouchbasePersonTransactionReactiveIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/CouchbasePersonTransactionReactiveIntegrationTests.java new file mode 100644 index 000000000..088841fa5 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/CouchbasePersonTransactionReactiveIntegrationTests.java @@ -0,0 +1,228 @@ +/* + * Copyright 2022 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.transactions; + +import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; + +import lombok.Data; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.List; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.annotation.Version; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; +import org.springframework.data.couchbase.core.RemoveResult; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.domain.PersonRepository; +import org.springframework.data.couchbase.domain.ReactivePersonRepository; +import org.springframework.data.couchbase.transaction.error.TransactionSystemUnambiguousException; +import org.springframework.data.couchbase.transactions.util.TransactionTestUtil; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import com.couchbase.client.java.Cluster; + +/** + * todo gp: these tests are using the `.as(transactionalOperator::transactional)` method which is for the chopping + * block, so presumably these tests are too + * todo mr: I'm not sure how as(transactionalOperator::transactional) is different than + * todo mr: transactionOperator.transaction(...)in CouchbaseTransactionalOperatorTemplateIntegrationTests ? + * + * @author Michael Reiche + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(classes = { TransactionsConfig.class, PersonServiceReactive.class }) +public class CouchbasePersonTransactionReactiveIntegrationTests extends JavaIntegrationTests { + // intellij flags "Could not autowire" when config classes are specified with classes={...}. But they are populated. + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired ReactivePersonRepository rxRepo; + @Autowired PersonRepository repo; + @Autowired CouchbaseTemplate cbTmpl; + @Autowired ReactiveCouchbaseTemplate rxCBTmpl; + @Autowired Cluster myCluster; + @Autowired PersonServiceReactive personService; + @Autowired ReactiveCouchbaseTemplate operations; + + // if these are changed from default, then beforeEach needs to clean up separately + String sName = "_default"; + String cName = "_default"; + Person WalterWhite; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + @AfterAll + public static void afterAll() { + callSuperAfterAll(new Object() {}); + } + + @BeforeEach + public void beforeEachTest() { + WalterWhite = new Person("Walter", "White"); + TransactionTestUtil.assertNotInTransaction(); + List pr = operations.removeByQuery(Person.class).withConsistency(REQUEST_PLUS).all().collectList() + .block(); + List er = operations.removeByQuery(EventLog.class).withConsistency(REQUEST_PLUS).all().collectList() + .block(); + List p = operations.findByQuery(Person.class).withConsistency(REQUEST_PLUS).all().collectList().block(); + List e = operations.findByQuery(EventLog.class).withConsistency(REQUEST_PLUS).all().collectList().block(); + } + + @Test + public void shouldRollbackAfterException() { + personService.savePersonErrors(WalterWhite) // + .as(StepVerifier::create) // + .verifyError(TransactionSystemUnambiguousException.class); + operations.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count() // + .as(StepVerifier::create) // + .expectNext(0L) // + .verifyComplete(); + } + + @Test + public void shouldRollbackAfterExceptionOfTxAnnotatedMethod() { + assertThrowsWithCause(() -> personService.declarativeSavePersonErrors(WalterWhite).blockLast(), + TransactionSystemUnambiguousException.class, SimulateFailureException.class); + } + + @Test + public void commitShouldPersistTxEntries() { + + personService.savePerson(WalterWhite) // + .as(StepVerifier::create) // + .expectNextCount(1) // + .verifyComplete(); + + operations.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count() // + .as(StepVerifier::create) // + .expectNext(1L) // + .verifyComplete(); + } + + @Test + public void commitShouldPersistTxEntriesOfTxAnnotatedMethod() { + + personService.declarativeSavePerson(WalterWhite).as(StepVerifier::create) // + .expectNextCount(1) // + .verifyComplete(); + + operations.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count() // + .as(StepVerifier::create) // + .expectNext(1L) // + .verifyComplete(); + + } + + @Test + public void commitShouldPersistTxEntriesAcrossCollections() { + + personService.saveWithLogs(WalterWhite) // + .then() // + .as(StepVerifier::create) // + .verifyComplete(); + + operations.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count() // + .as(StepVerifier::create) // + .expectNext(1L) // + .verifyComplete(); + + operations.findByQuery(EventLog.class).withConsistency(REQUEST_PLUS).count() // + .as(StepVerifier::create) // + .expectNext(4L) // + .verifyComplete(); + } + + @Test + public void rollbackShouldAbortAcrossCollections() { + + personService.saveWithErrorLogs(WalterWhite) // + .then() // + .as(StepVerifier::create) // + .verifyError(); + + operations.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count() // + .as(StepVerifier::create) // + .expectNext(0L) // + .verifyComplete(); + + operations.findByQuery(EventLog.class).withConsistency(REQUEST_PLUS).count()// + .as(StepVerifier::create) // + .expectNext(0L) // + .verifyComplete(); + } + + @Test + public void countShouldWorkInsideTransaction() { + personService.countDuringTx(WalterWhite) // + .as(StepVerifier::create) // + .expectNext(1L) // + .verifyComplete(); + } + + @Test + public void emitMultipleElementsDuringTransaction() { + personService.saveWithLogs(WalterWhite) // + .as(StepVerifier::create) // + .expectNextCount(4L) // + .verifyComplete(); + } + + @Test + public void errorAfterTxShouldNotAffectPreviousStep() { + + personService.savePerson(WalterWhite) // + .then(Mono.error(new SimulateFailureException())).as(StepVerifier::create) // + .verifyError(); + operations.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count() // + .as(StepVerifier::create) // + .expectNext(1L) // + .verifyComplete(); + } + + @Data + // @AllArgsConstructor + static class EventLog { + public EventLog() {} + + public EventLog(ObjectId oid, String action) { + this.id = oid.toString(); + this.action = action; + } + + public EventLog(String id, String action) { + this.id = id; + this.action = action; + } + + String id; + String action; + @Version Long version; + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseReactiveTransactionNativeIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseReactiveTransactionNativeIntegrationTests.java new file mode 100644 index 000000000..cf20134f3 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseReactiveTransactionNativeIntegrationTests.java @@ -0,0 +1,231 @@ +/* + * Copyright 2022 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.transactions; + +import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; +import org.springframework.data.couchbase.core.RemoveResult; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.domain.PersonRepository; +import org.springframework.data.couchbase.domain.ReactivePersonRepository; +import org.springframework.data.couchbase.transaction.CouchbaseTransactionalOperator; +import org.springframework.data.couchbase.transaction.error.TransactionSystemUnambiguousException; +import org.springframework.data.couchbase.transactions.util.TransactionTestUtil; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.reactive.TransactionalOperator; + +/** + * Tests for CouchbaseTransactionalOperator. + * + * @author Michael Reiche + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(TransactionsConfig.class) +public class CouchbaseReactiveTransactionNativeIntegrationTests extends JavaIntegrationTests { + + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired ReactivePersonRepository rxRepo; + @Autowired PersonRepository repo; + @Autowired CouchbaseTemplate cbTmpl; + @Autowired ReactiveCouchbaseTemplate rxCBTmpl; + @Autowired ReactiveCouchbaseTemplate operations; + // This will pick up CouchbaseTransactionalOperator + @Autowired TransactionalOperator txOperator; + + String sName = "_default"; + String cName = "_default"; + + Person WalterWhite; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + @AfterAll + public static void afterAll() { + callSuperAfterAll(new Object() {}); + } + + @BeforeEach + public void beforeEachTest() { + assertTrue(txOperator instanceof CouchbaseTransactionalOperator); + WalterWhite = new Person("Walter", "White"); + TransactionTestUtil.assertNotInTransaction(); + TransactionTestUtil.assertNotInTransaction(); + List rp0 = cbTmpl.removeByQuery(Person.class).withConsistency(REQUEST_PLUS).all(); + List rp1 = cbTmpl.removeByQuery(Person.class).withConsistency(REQUEST_PLUS).inScope(sName) + .inCollection(cName).all(); + List p0 = cbTmpl.findByQuery(Person.class).withConsistency(REQUEST_PLUS).all(); + List p1 = cbTmpl.findByQuery(Person.class).withConsistency(REQUEST_PLUS).inScope(sName).inCollection(cName) + .all(); + } + + @Test + public void replacePersonTemplate() { + Person person = rxCBTmpl.insertById(Person.class).inCollection(cName).one(WalterWhite).block(); + Flux result = txOperator.execute((ctx) -> rxCBTmpl.findById(Person.class).one(person.id()) + .flatMap(p -> rxCBTmpl.replaceById(Person.class).one(p.withFirstName("Walt")))); + result.blockLast(); + Person pFound = rxCBTmpl.findById(Person.class).inCollection(cName).one(person.id()).block(); + assertEquals("Walt", pFound.getFirstname(), "firstname should be Walt"); + } + + @Test + public void replacePersonRbTemplate() { + Person person = rxCBTmpl.insertById(Person.class).inCollection(cName).one(WalterWhite).block(); + Flux result = txOperator.execute((ctx) -> rxCBTmpl.findById(Person.class).one(person.id()) + .flatMap(p -> rxCBTmpl.replaceById(Person.class).one(p.withFirstName("Walt"))) + .map(it -> throwSimulateFailureException(it))); + assertThrowsWithCause(result::blockLast, TransactionSystemUnambiguousException.class, + SimulateFailureException.class); + Person pFound = rxCBTmpl.findById(Person.class).inCollection(cName).one(person.id()).block(); + assertEquals(person, pFound, "Should have found " + person); + } + + @Test + public void insertPersonTemplate() { + Person person = WalterWhite; + Flux result = txOperator.execute((ctx) -> rxCBTmpl.insertById(Person.class).one(person) + .flatMap(p -> rxCBTmpl.replaceById(Person.class).one(p.withFirstName("Walt")))); + result.blockLast(); + Person pFound = rxCBTmpl.findById(Person.class).inCollection(cName).one(person.id()).block(); + assertEquals("Walt", pFound.getFirstname(), "firstname should be Walt"); + } + + @Test + public void insertPersonRbTemplate() { + Person person = WalterWhite; + Flux result = txOperator.execute((ctx) -> rxCBTmpl.insertById(Person.class).one(person) + .flatMap(p -> rxCBTmpl.replaceById(Person.class).one(p.withFirstName("Walt"))) + .map(it -> throwSimulateFailureException(it))); + assertThrowsWithCause(result::blockLast, TransactionSystemUnambiguousException.class, + SimulateFailureException.class); + Person pFound = rxCBTmpl.findById(Person.class).inCollection(cName).one(person.id()).block(); + assertNull(pFound, "Should NOT have found " + pFound); + } + + @Test + public void replacePersonRbRepo() { + Person person = rxCBTmpl.insertById(Person.class).inCollection(cName).one(WalterWhite).block(); + Flux result = txOperator.execute((ctx) -> rxRepo.withCollection(cName).findById(person.id()) + .flatMap(p -> rxRepo.withCollection(cName).save(p.withFirstName("Walt"))) + .flatMap(it -> Mono.error(new SimulateFailureException()))); + assertThrowsWithCause(result::blockLast, TransactionSystemUnambiguousException.class, + SimulateFailureException.class); + Person pFound = rxRepo.withCollection(cName).findById(person.id()).block(); + assertEquals(person, pFound, "Should have found " + person); + } + + @Test + public void insertPersonRbRepo() { + Person person = WalterWhite; + Flux result = txOperator.execute((ctx) -> rxRepo.withCollection(cName).save(person) // insert + .map(it -> throwSimulateFailureException(it))); + assertThrowsWithCause(result::blockLast, TransactionSystemUnambiguousException.class, + SimulateFailureException.class); + Person pFound = rxRepo.withCollection(cName).findById(person.id()).block(); + assertNull(pFound, "Should NOT have found " + pFound); + } + + @Test + public void insertPersonRepo() { + Person person = WalterWhite; + Flux result = txOperator.execute((ctx) -> rxRepo.withCollection(cName).save(person) // insert + .flatMap(p -> rxRepo.withCollection(cName).save(p.withFirstName("Walt")))); + result.blockLast(); + Person pFound = rxRepo.withCollection(cName).findById(person.id()).block(); + assertEquals("Walt", pFound.getFirstname(), "firstname should be Walt"); + } + + @Test + public void replacePersonSpringTransactional() { + Person person = WalterWhite; + rxCBTmpl.insertById(Person.class).inCollection(cName).one(person).block(); + Mono result = rxCBTmpl.findById(Person.class).one(person.id()) + .flatMap(p -> rxCBTmpl.replaceById(Person.class).one(p.withFirstName("Walt"))).as(txOperator::transactional); + result.block(); + Person pFound = rxCBTmpl.findById(Person.class).inCollection(cName).one(person.id()).block(); + assertEquals(person.withFirstName("Walt"), pFound, "Should have found " + person); + } + + @Test + public void replacePersonRbSpringTransactional() { + Person person = rxCBTmpl.insertById(Person.class).inCollection(cName).one(WalterWhite).block(); + Mono result = rxCBTmpl.findById(Person.class).one(person.id()) + .flatMap(p -> rxCBTmpl.replaceById(Person.class).one(p.withFirstName("Walt"))) + .flatMap(it -> Mono.error(new SimulateFailureException())).as(txOperator::transactional); + assertThrowsWithCause(result::block, TransactionSystemUnambiguousException.class, SimulateFailureException.class); + Person pFound = rxCBTmpl.findById(Person.class).inCollection(cName).one(person.id()).block(); + assertEquals(person, pFound, "Should have found " + person); + assertEquals(person.getFirstname(), pFound.getFirstname(), "firstname should be " + person.getFirstname()); + } + + @Test + public void findReplacePersonCBTransactionsRxTmpl() { + Person person = rxCBTmpl.insertById(Person.class).inCollection(cName).one(WalterWhite).block(); + Flux result = txOperator.execute(ctx -> rxCBTmpl.findById(Person.class).inCollection(cName).one(person.id()) + .flatMap(pGet -> rxCBTmpl.replaceById(Person.class).inCollection(cName).one(pGet.withFirstName("Walt")))); + result.blockLast(); + Person pFound = rxCBTmpl.findById(Person.class).inCollection(cName).one(person.id()).block(); + assertEquals(person.withFirstName("Walt"), pFound, "Should have found Walt"); + } + + @Test + public void insertReplacePersonsCBTransactionsRxTmpl() { + Person person = WalterWhite; + Flux result = txOperator.execute((ctx) -> rxCBTmpl.insertById(Person.class).inCollection(cName).one(person) + .flatMap(pInsert -> rxCBTmpl.replaceById(Person.class).inCollection(cName).one(pInsert.withFirstName("Walt")))); + result.blockLast(); + Person pFound = rxCBTmpl.findById(Person.class).inCollection(cName).one(person.id()).block(); + assertEquals(person.withFirstName("Walt"), pFound, "Should have found Walt"); + } + + @Test + void transactionalSavePerson() { + Person person = WalterWhite; + savePerson(person).block(); + Person pFound = rxCBTmpl.findById(Person.class).inCollection(cName).one(person.id()).block(); + assertEquals(person, pFound, "Should have found " + person); + } + + public Mono savePerson(Person person) { + return operations.save(person) // + .as(txOperator::transactional); + } + +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionNativeIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionNativeIntegrationTests.java new file mode 100644 index 000000000..b37fd692d --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionNativeIntegrationTests.java @@ -0,0 +1,188 @@ +/* + * Copyright 2022 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.transactions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.domain.PersonRepository; +import org.springframework.data.couchbase.domain.ReactivePersonRepository; +import org.springframework.data.couchbase.transaction.CouchbaseTransactionalOperator; +import org.springframework.data.couchbase.transaction.error.TransactionSystemUnambiguousException; +import org.springframework.data.couchbase.transactions.util.TransactionTestUtil; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.TransactionManager; +import org.springframework.transaction.reactive.TransactionalOperator; + +/** + * Tests for com.couchbase.transactions without using the spring data transactions framework + *

    + * Tests CouchbaseTransactionalOperator. + * + * @author Michael Reiche + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(TransactionsConfig.class) +// I think these are all redundant (see CouchbaseReactiveTransactionNativeTests). There does not seem to be a blocking +// form of TransactionalOperator. Also there does not seem to be a need for a CouchbaseTransactionalOperator as +// TransactionalOperator.create(reactiveCouchbaseTransactionManager) seems to work just fine. (I don't recall what +// merits the "Native" in the name). +public class CouchbaseTransactionNativeIntegrationTests extends JavaIntegrationTests { + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired TransactionManager couchbaseTransactionManager; + @Autowired PersonRepository repo; + @Autowired ReactivePersonRepository repoRx; + @Autowired CouchbaseTemplate cbTmpl; + @Autowired ReactiveCouchbaseTemplate rxCbTmpl; + @Autowired TransactionalOperator txOperator; + static String cName; // short name + + Person WalterWhite; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + // short names + cName = null;// cName; + } + + @AfterAll + public static void afterAll() { + callSuperAfterAll(new Object() {}); + } + + @BeforeEach + public void beforeEach() { + assertTrue(txOperator instanceof CouchbaseTransactionalOperator); + WalterWhite = new Person("Walter", "White"); + TransactionTestUtil.assertNotInTransaction(); + } + + @AfterEach + public void afterEach() { + TransactionTestUtil.assertNotInTransaction(); + } + + @Test + public void replacePersonTemplate() { + Person person = cbTmpl.insertById(Person.class).inCollection(cName).one(WalterWhite); + assertThrowsWithCause(() -> txOperator.execute((ctx) -> rxCbTmpl.findById(Person.class).one(person.id()) // + .flatMap(pp -> rxCbTmpl.replaceById(Person.class).one(pp.withIdFirstname()) // + .map(ppp -> throwSimulateFailureException(ppp)))) + .blockLast(), TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person pFound = cbTmpl.findById(Person.class).inCollection(cName).one(person.getId().toString()); + assertEquals(person.getFirstname(), pFound.getFirstname(), "firstname should be " + person.getFirstname()); + + } + + @Test + public void replacePersonRbTemplate() { + Person person = cbTmpl.insertById(Person.class).inCollection(cName).one(WalterWhite); + assertThrowsWithCause( + () -> txOperator.execute((ctx) -> rxCbTmpl.findById(Person.class).one(person.getId().toString()) // + .flatMap(p -> rxCbTmpl.replaceById(Person.class).one(p.withIdFirstname())) // + .map(ppp -> throwSimulateFailureException(ppp))).blockLast(), // + TransactionSystemUnambiguousException.class, SimulateFailureException.class); + Person pFound = cbTmpl.findById(Person.class).inCollection(cName).one(person.getId().toString()); + assertEquals(person.getFirstname(), pFound.getFirstname(), "firstname should be " + person.getFirstname()); + + } + + @Test + public void insertPersonTemplate() { + txOperator.execute((ctx) -> rxCbTmpl.insertById(Person.class).one(WalterWhite) + .flatMap(p -> rxCbTmpl.replaceById(Person.class).one(p.withFirstName("Walt")))).blockLast(); + Person pFound = cbTmpl.findById(Person.class).inCollection(cName).one(WalterWhite.id()); + assertEquals("Walt", pFound.getFirstname(), "firstname should be Walt"); + } + + @Test + public void insertPersonRbTemplate() { + assertThrowsWithCause( + () -> txOperator.execute((ctx) -> rxCbTmpl.insertById(Person.class).one(WalterWhite) + .flatMap(p -> rxCbTmpl.replaceById(Person.class).one(p.withFirstName("Walt"))) + .map(it -> throwSimulateFailureException(it))).blockLast(), + TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person pFound = cbTmpl.findById(Person.class).inCollection(cName).one(WalterWhite.id()); + assertNull(pFound, "Should NOT have found " + pFound); + } + + @Test + public void replacePersonRbRepo() { + Person person = repo.withCollection(cName).save(WalterWhite); + assertThrowsWithCause(() -> txOperator.execute(ctx -> { + return repoRx.withCollection(cName).findById(person.id()) + .flatMap(p -> repoRx.withCollection(cName).save(p.withFirstName("Walt"))) + .map(pp -> throwSimulateFailureException(pp)); + }).blockLast(), TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person pFound = cbTmpl.findById(Person.class).inCollection(cName).one(person.id()); + assertEquals(person, pFound, "Should have found " + person); + } + + @Test + public void insertPersonRbRepo() { + assertThrowsWithCause(() -> txOperator.execute((ctx) -> repoRx.withCollection(cName).save(WalterWhite) // insert + .flatMap(p -> repoRx.withCollection(cName).save(p.withFirstName("Walt"))) // replace + .map(it -> throwSimulateFailureException(it))).blockLast(), TransactionSystemUnambiguousException.class, + SimulateFailureException.class); + + Person pFound = cbTmpl.findById(Person.class).inCollection(cName).one(WalterWhite.id()); + assertNull(pFound, "Should NOT have found " + pFound); + } + + @Test + public void insertPersonRepo() { + txOperator.execute((ctx) -> repoRx.withCollection(cName).save(WalterWhite) // insert + .flatMap(p -> repoRx.withCollection(cName).save(p.withFirstName("Walt"))) // replace + ).blockFirst(); + Optional pFound = repo.withCollection(cName).findById(WalterWhite.id()); + assertEquals("Walt", pFound.get().getFirstname(), "firstname should be Walt"); + } + + @Test + public void replacePersonRbSpringTransactional() { + Person person = cbTmpl.insertById(Person.class).inCollection(cName).one(WalterWhite); + assertThrowsWithCause( + () -> txOperator.execute((ctx) -> rxCbTmpl.findById(Person.class).one(person.getId().toString()) + .flatMap(p -> rxCbTmpl.replaceById(Person.class).one(p.withFirstName("Walt"))) + .map(it -> throwSimulateFailureException(it))).blockLast(), + TransactionSystemUnambiguousException.class, SimulateFailureException.class); + Person pFound = cbTmpl.findById(Person.class).inCollection(cName).one(person.id()); + assertEquals(person.getFirstname(), pFound.getFirstname(), "firstname should be Walter"); + } + +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalNonAllowableOperationsIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalNonAllowableOperationsIntegrationTests.java new file mode 100644 index 000000000..6ff59cc81 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalNonAllowableOperationsIntegrationTests.java @@ -0,0 +1,133 @@ +/* + * Copyright 2022 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.transactions; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseOperations; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.transaction.error.TransactionSystemUnambiguousException; +import org.springframework.data.couchbase.transactions.util.TransactionTestUtil; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; + +/** + * Tests for @Transactional methods, where operations that aren't supported in a transaction are being used. They should + * be prevented at runtime. + * + * @author Graham Pople + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(classes = { TransactionsConfig.class, + CouchbaseTransactionalNonAllowableOperationsIntegrationTests.PersonService.class }) +public class CouchbaseTransactionalNonAllowableOperationsIntegrationTests extends JavaIntegrationTests { + + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired PersonService personService; + + Person WalterWhite; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + @BeforeEach + public void beforeEachTest() { + WalterWhite = new Person("Walter", "White"); + TransactionTestUtil.assertNotInTransaction(); + } + + void test(Consumer r) { + AtomicInteger tryCount = new AtomicInteger(0); + + assertThrowsWithCause(() -> { + personService.doInTransaction(tryCount, (ops) -> { + r.accept(ops); + return null; + }); + }, TransactionSystemUnambiguousException.class, IllegalArgumentException.class); + + assertEquals(1, tryCount.get()); + } + + @DisplayName("Using existsById() in a transaction is rejected at runtime") + @Test + public void existsById() { + test((ops) -> { + ops.existsById(Person.class).one(WalterWhite.id()); + }); + } + + @DisplayName("Using findByAnalytics() in a transaction is rejected at runtime") + @Test + public void findByAnalytics() { + test((ops) -> { + ops.findByAnalytics(Person.class).one(); + }); + } + + @DisplayName("Using findFromReplicasById() in a transaction is rejected at runtime") + @Test + public void findFromReplicasById() { + test((ops) -> { + ops.findFromReplicasById(Person.class).any(WalterWhite.id()); + }); + } + + @DisplayName("Using upsertById() in a transaction is rejected at runtime") + @Test + public void upsertById() { + test((ops) -> { + ops.upsertById(Person.class).one(WalterWhite); + }); + } + + @Service + @Component + @EnableTransactionManagement + static class PersonService { + final CouchbaseOperations personOperations; + + public PersonService(CouchbaseOperations ops) { + personOperations = ops; + } + + @Transactional + public T doInTransaction(AtomicInteger tryCount, Function callback) { + tryCount.incrementAndGet(); + return callback.apply(personOperations); + } + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalOperatorTemplateIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalOperatorTemplateIntegrationTests.java new file mode 100644 index 000000000..55bdbbc14 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalOperatorTemplateIntegrationTests.java @@ -0,0 +1,329 @@ +/* + * Copyright 2022 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.transactions; + +import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.springframework.data.couchbase.transactions.util.TransactionTestUtil.assertNotInTransaction; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; +import org.springframework.data.couchbase.core.query.QueryCriteria; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.transaction.CouchbaseCallbackTransactionManager; +import org.springframework.data.couchbase.transaction.CouchbaseTransactionalOperator; +import org.springframework.data.couchbase.transaction.error.TransactionSystemUnambiguousException; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.reactive.TransactionalOperator; + +/** + * Tests for CouchbaseTransactionalOperator, using template methods (findById etc.) + * + * @author Graham Pople + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(TransactionsConfig.class) +public class CouchbaseTransactionalOperatorTemplateIntegrationTests extends JavaIntegrationTests { + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired ReactiveCouchbaseTemplate ops; + @Autowired CouchbaseTemplate blocking; + + Person WalterWhite; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + @AfterAll + public static void afterAll() { + callSuperAfterAll(new Object() {}); + } + + @BeforeEach + public void beforeEachTest() { + WalterWhite = new Person("Walter", "White"); + assertNotInTransaction(); + } + + @AfterEach + public void afterEachTest() { + assertNotInTransaction(); + } + + static class RunResult { + public final int attempts; + + public RunResult(int attempts) { + this.attempts = attempts; + } + } + + private RunResult doMonoInTransaction(Supplier> lambda) { + CouchbaseCallbackTransactionManager manager = new CouchbaseCallbackTransactionManager(couchbaseClientFactory); + TransactionalOperator operator = CouchbaseTransactionalOperator.create(manager); + AtomicInteger attempts = new AtomicInteger(); + + operator.transactional(Mono.fromRunnable(() -> attempts.incrementAndGet()).then(lambda.get())).block(); + + assertNotInTransaction(); + + return new RunResult(attempts.get()); + } + + @DisplayName("A basic golden path insert using CouchbaseSimpleTransactionalOperator.execute should succeed") + @Test + public void committedInsertWithExecute() { + CouchbaseCallbackTransactionManager manager = new CouchbaseCallbackTransactionManager(couchbaseClientFactory); + TransactionalOperator operator = CouchbaseTransactionalOperator.create(manager); + + operator.execute(v -> { + return Mono.defer(() -> { + return ops.insertById(Person.class).one(WalterWhite); + }); + }).blockLast(); + + Person fetched = blocking.findById(Person.class).one(WalterWhite.id()); + assertEquals(WalterWhite.getFirstname(), fetched.getFirstname()); + } + + @DisplayName("A basic golden path insert using CouchbaseSimpleTransactionalOperator.transactional(Flux) should succeed") + @Test + public void committedInsertWithFlux() { + CouchbaseCallbackTransactionManager manager = new CouchbaseCallbackTransactionManager(couchbaseClientFactory); + TransactionalOperator operator = CouchbaseTransactionalOperator.create(manager); + + Flux flux = Flux.defer(() -> { + return ops.insertById(Person.class).one(WalterWhite); + }); + + operator.transactional(flux).blockLast(); + + Person fetched = blocking.findById(Person.class).one(WalterWhite.id()); + assertEquals(WalterWhite.getFirstname(), fetched.getFirstname()); + } + + @DisplayName("A basic golden path insert using CouchbaseSimpleTransactionalOperator.transactional(Mono) should succeed") + @Test + public void committedInsert() { + + RunResult rr = doMonoInTransaction(() -> { + return Mono.defer(() -> { + return ops.insertById(Person.class).one(WalterWhite); + }); + }); + + Person fetched = blocking.findById(Person.class).one(WalterWhite.id()); + assertEquals(WalterWhite.getFirstname(), fetched.getFirstname()); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path replace should succeed") + @Test + public void committedReplace() { + Person p = blocking.insertById(Person.class).one(WalterWhite); + + RunResult rr = doMonoInTransaction(() -> { + return ops.findById(Person.class).one(WalterWhite.id()).flatMap(person -> { + person.setFirstname("changed"); + return ops.replaceById(Person.class).one(person); + }); + }); + + Person fetched = blocking.findById(Person.class).one(WalterWhite.id()); + assertEquals("changed", fetched.getFirstname()); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path remove should succeed") + @Test + public void committedRemove() { + + Person person = blocking.insertById(Person.class).one(WalterWhite); + + RunResult rr = doMonoInTransaction(() -> { + return ops.findById(Person.class).one(person.id()) + .flatMap(fetched -> ops.removeById(Person.class).oneEntity(fetched)); + }); + + Person fetched = blocking.findById(Person.class).one(person.id()); + assertNull(fetched); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path removeByQuery should succeed") + @Test + public void committedRemoveByQuery() { + Person person = blocking.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + RunResult rr = doMonoInTransaction(() -> { + return ops.removeByQuery(Person.class).withConsistency(REQUEST_PLUS) + .matching(QueryCriteria.where("firstname").eq(person.id())).all().next(); + }); + + Person fetched = blocking.findById(Person.class).one(person.id()); + assertNull(fetched); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path findByQuery should succeed") + @Test + public void committedFindByQuery() { + Person person = blocking.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + RunResult rr = doMonoInTransaction(() -> { + return ops.findByQuery(Person.class).withConsistency(REQUEST_PLUS) + .matching(QueryCriteria.where("firstname").eq(person.id())).all().next(); + }); + + assertEquals(1, rr.attempts); + } + + @DisplayName("Basic test of doing an insert then rolling back") + @Test + public void rollbackInsert() { + AtomicInteger attempts = new AtomicInteger(); + + assertThrowsWithCause(() -> doMonoInTransaction(() -> { + attempts.incrementAndGet(); + return ops.insertById(Person.class).one(WalterWhite).map((p) -> throwSimulateFailureException(p)); + }), TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person fetched = blocking.findById(Person.class).one(WalterWhite.toString()); + assertNull(fetched); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a replace then rolling back") + @Test + public void rollbackReplace() { + AtomicInteger attempts = new AtomicInteger(); + Person person = blocking.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> doMonoInTransaction(() -> { + attempts.incrementAndGet(); + return ops.findById(Person.class).one(person.id()) // + .flatMap(p -> ops.replaceById(Person.class).one(p.withFirstName("changed"))) // + .map(p -> throwSimulateFailureException(p)); + }), TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person fetched = blocking.findById(Person.class).one(person.id()); + assertEquals(person.getFirstname(), fetched.getFirstname()); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a remove then rolling back") + @Test + public void rollbackRemove() { + AtomicInteger attempts = new AtomicInteger(); + Person person = blocking.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> doMonoInTransaction(() -> { + attempts.incrementAndGet(); + return ops.findById(Person.class).one(person.id()).flatMap(p -> ops.removeById(Person.class).oneEntity(p)) // + .doOnSuccess(p -> throwSimulateFailureException(p)); // remove has no result + }), TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person fetched = blocking.findById(Person.class).one(person.id()); + assertNotNull(fetched); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a removeByQuery then rolling back") + @Test + public void rollbackRemoveByQuery() { + AtomicInteger attempts = new AtomicInteger(); + Person person = blocking.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> doMonoInTransaction(() -> { + attempts.incrementAndGet(); + return ops.removeByQuery(Person.class).matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all() + .elementAt(0).map(p -> throwSimulateFailureException(p)); + }), TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person fetched = blocking.findById(Person.class).one(person.id()); + assertNotNull(fetched); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a findByQuery then rolling back") + @Test + public void rollbackFindByQuery() { + AtomicInteger attempts = new AtomicInteger(); + Person person = blocking.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> doMonoInTransaction(() -> { + attempts.incrementAndGet(); + return ops.findByQuery(Person.class).matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all() + .elementAt(0).map(p -> throwSimulateFailureException(p)); + }), TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + assertEquals(1, attempts.get()); + } + + @DisplayName("Forcing CAS mismatch causes a transaction retry") + @Test + public void casMismatchCausesRetry() { + Person person = blocking.insertById(Person.class).one(WalterWhite); + AtomicInteger attempts = new AtomicInteger(); + + // Needs to take place in a separate thread to bypass the ThreadLocalStorage checks + Thread forceCASMismatch = new Thread(() -> { + Person fetched = blocking.findById(Person.class).one(person.id()); + blocking.replaceById(Person.class).one(fetched.withFirstName("Changed externally")); + }); + + doMonoInTransaction(() -> { + return ops.findById(Person.class).one(person.id()).flatMap(fetched -> Mono.defer(() -> { + + if (attempts.incrementAndGet() == 1) { + forceCASMismatch.start(); + try { + forceCASMismatch.join(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + return ops.replaceById(Person.class).one(fetched.withFirstName("Changed by transaction")); + })); + }); + + Person fetched = blocking.findById(Person.class).one(person.id()); + assertEquals("Changed by transaction", fetched.getFirstname()); + assertEquals(2, attempts.get()); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalOptionsIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalOptionsIntegrationTests.java new file mode 100644 index 000000000..90ecde159 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalOptionsIntegrationTests.java @@ -0,0 +1,133 @@ +/* + * Copyright 2022 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.transactions; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseOperations; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.transaction.error.TransactionSystemUnambiguousException; +import org.springframework.data.couchbase.transactions.util.TransactionTestUtil; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import com.couchbase.client.core.error.transaction.AttemptExpiredException; + +/** + * Tests for @Transactional methods, setting all the various options allowed by @Transactional. + * + * @author Graham Pople + */ +@IgnoreWhen(clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig( + classes = { TransactionsConfig.class, CouchbaseTransactionalOptionsIntegrationTests.PersonService.class }) +public class CouchbaseTransactionalOptionsIntegrationTests extends JavaIntegrationTests { + + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired PersonService personService; + @Autowired CouchbaseTemplate operations; + + Person WalterWhite; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + @BeforeEach + public void beforeEachTest() { + WalterWhite = new Person("Walter", "White"); + TransactionTestUtil.assertNotInTransaction(); + } + + @DisplayName("@Transactional(timeout = 2) will timeout at around 2 seconds") + @Test + public void timeout() { + long start = System.nanoTime(); + Person person = operations.insertById(Person.class).one(WalterWhite); + assertThrowsWithCause(() -> { + personService.timeout(person.id()); + }, TransactionSystemUnambiguousException.class, AttemptExpiredException.class); + Duration timeTaken = Duration.ofNanos(System.nanoTime() - start); + assertTrue(timeTaken.toMillis() >= 2000); + assertTrue(timeTaken.toMillis() < 10_000); // Default transaction timeout is 15s + } + + @DisplayName("@Transactional(isolation = Isolation.ANYTHING_BUT_READ_COMMITTED) will fail") + @Test + public void unsupportedIsolation() { + assertThrowsWithCause(() -> { + personService.unsupportedIsolation(); + }, IllegalArgumentException.class); + + } + + @DisplayName("@Transactional(isolation = Isolation.READ_COMMITTED) will succeed") + @Test + public void supportedIsolation() { + personService.supportedIsolation(); + } + + @Service + @Component + @EnableTransactionManagement + static class PersonService { + final CouchbaseOperations ops; + + public PersonService(CouchbaseOperations ops) { + this.ops = ops; + } + + @Transactional + public T doInTransaction(AtomicInteger tryCount, Function callback) { + tryCount.incrementAndGet(); + return callback.apply(ops); + } + + @Transactional(timeout = 2) + public void timeout(String id) { + while (true) { + Person p = ops.findById(Person.class).one(id); + ops.replaceById(Person.class).one(p); + } + } + + @Transactional(isolation = Isolation.REPEATABLE_READ) + public void unsupportedIsolation() {} + + @Transactional(isolation = Isolation.READ_COMMITTED) + public void supportedIsolation() {} + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalPropagationIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalPropagationIntegrationTests.java new file mode 100644 index 000000000..dcb00c5e8 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalPropagationIntegrationTests.java @@ -0,0 +1,354 @@ +/* + * Copyright 2022 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.transactions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.springframework.data.couchbase.transactions.util.TransactionTestUtil.assertInTransaction; +import static org.springframework.data.couchbase.transactions.util.TransactionTestUtil.assertNotInTransaction; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseOperations; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.transaction.error.TransactionSystemUnambiguousException; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.couchbase.client.core.error.transaction.RetryTransactionException; + +/** + * Tests for the various propagation values allowed on @Transactional methods. + * + * @author Graham Pople + */ +@IgnoreWhen(clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig( + classes = { TransactionsConfig.class, CouchbaseTransactionalPropagationIntegrationTests.PersonService.class }) +public class CouchbaseTransactionalPropagationIntegrationTests extends JavaIntegrationTests { + private static final Logger LOGGER = LoggerFactory.getLogger(CouchbaseTransactionalPropagationIntegrationTests.class); + + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired PersonService personService; + @Autowired CouchbaseTemplate operations; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + @BeforeEach + public void beforeEachTest() { + assertNotInTransaction(); + } + + @AfterEach + public void afterEachTest() { + assertNotInTransaction(); + } + + @DisplayName("Call @Transactional(propagation = DEFAULT) - succeeds, creates a transaction") + @Test + public void callDefault() { + personService.propagationDefault(ops -> { + assertInTransaction(); + }); + } + + @DisplayName("Call @Transactional(propagation = SUPPORTS) - fails as unsupported") + @Test + public void callSupports() { + assertThrowsWithCause(() -> personService.propagationSupports(ops -> {}), UnsupportedOperationException.class); + } + + @DisplayName("Call @Transactional(propagation = MANDATORY) - fails as not in an active transaction") + @Test + public void callMandatory() { + assertThrowsWithCause(() -> personService.propagationMandatory(ops -> {}), IllegalTransactionStateException.class); + } + + @DisplayName("Call @Transactional(propagation = REQUIRES_NEW) - fails as unsupported") + @Test + public void callRequiresNew() { + assertThrowsWithCause(() -> personService.propagationRequiresNew(ops -> {}), UnsupportedOperationException.class); + + } + + @DisplayName("Call @Transactional(propagation = NOT_SUPPORTED) - fails as unsupported") + @Test + public void callNotSupported() { + assertThrowsWithCause(() -> personService.propagationNotSupported(ops -> {}), UnsupportedOperationException.class); + } + + @DisplayName("Call @Transactional(propagation = NEVER) - succeeds as not in a transaction, starts one") + @Test + public void callNever() { + personService.propagationNever(ops -> { + assertInTransaction(); + }); + } + + @DisplayName("Call @Transactional(propagation = NESTED) - succeeds as not in an existing transaction, starts one") + @Test + public void callNested() { + personService.propagationNested(ops -> { + assertInTransaction(); + }); + } + + @DisplayName("Call @Transactional that calls @Transactional(propagation = DEFAULT) - succeeds, continues existing") + @Test + public void callDefaultThatCallsDefault() { + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + + personService.propagationDefault(ops -> { + ops.insertById(Person.class).one(new Person(id1, "Ada", "Lovelace")); + + personService.propagationDefault(ops2 -> { + ops2.insertById(Person.class).one(new Person(id2, "Grace", "Hopper")); + + assertInTransaction(); + }); + }); + + // Validate everything committed + + assertNotNull(operations.findById(Person.class).one(id1.toString())); + assertNotNull(operations.findById(Person.class).one(id2.toString())); + } + + @DisplayName("Call @Transactional that calls @Transactional(propagation = REQUIRED) - succeeds, continues existing") + @Test + public void callDefaultThatCallsRequired() { + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + + personService.propagationDefault(ops -> { + ops.insertById(Person.class).one(new Person(id1, "Ada", "Lovelace")); + + personService.propagationRequired(ops2 -> { + ops2.insertById(Person.class).one(new Person(id2, "Grace", "Hopper")); + + assertInTransaction(); + }); + }); + + // Validate everything committed + assertNotNull(operations.findById(Person.class).one(id1.toString())); + assertNotNull(operations.findById(Person.class).one(id2.toString())); + } + + @DisplayName("Call @Transactional that calls @Transactional(propagation = MANDATORY) - succeeds, continues existing") + @Test + public void callDefaultThatCallsMandatory() { + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + + personService.propagationDefault(ops -> { + ops.insertById(Person.class).one(new Person(id1, "Ada", "Lovelace")); + + personService.propagationMandatory(ops2 -> { + ops2.insertById(Person.class).one(new Person(id2, "Grace", "Hopper")); + + assertInTransaction(); + }); + }); + + // Validate everything committed + assertNotNull(operations.findById(Person.class).one(id1.toString())); + assertNotNull(operations.findById(Person.class).one(id2.toString())); + } + + @DisplayName("Call @Transactional that calls @Transactional(propagation = REQUIRES_NEW) - fails as unsupported") + @Test + public void callDefaultThatCallsRequiresNew() { + UUID id1 = UUID.randomUUID(); + + assertThrowsWithCause(() -> personService.propagationDefault(ops -> { + ops.insertById(Person.class).one(new Person(id1, "Ada", "Lovelace")); + personService.propagationRequiresNew(ops2 -> {}); + }), TransactionSystemUnambiguousException.class, UnsupportedOperationException.class); + + // Validate everything rolled back + assertNull(operations.findById(Person.class).one(id1.toString())); + } + + @DisplayName("Call @Transactional that calls @Transactional(propagation = NOT_SUPPORTED) - fails as unsupported") + @Test + public void callDefaultThatCallsNotSupported() { + UUID id1 = UUID.randomUUID(); + + assertThrowsWithCause(() -> { + personService.propagationDefault(ops -> { + ops.insertById(Person.class).one(new Person(id1, "Ada", "Lovelace")); + personService.propagationNotSupported(ops2 -> {}); + }); + }, TransactionSystemUnambiguousException.class, UnsupportedOperationException.class); + + // Validate everything rolled back + assertNull(operations.findById(Person.class).one(id1.toString())); + } + + @DisplayName("Call @Transactional that calls @Transactional(propagation = NEVER) - fails as in a transaction") + @Test + public void callDefaultThatCallsNever() { + UUID id1 = UUID.randomUUID(); + + assertThrowsWithCause(() -> { + personService.propagationDefault(ops -> { + ops.insertById(Person.class).one(new Person(id1, "Ada", "Lovelace")); + personService.propagationNever(ops2 -> {}); + }); + }, TransactionSystemUnambiguousException.class, IllegalTransactionStateException.class); + + // Validate everything rolled back + assertNull(operations.findById(Person.class).one(id1.toString())); + } + + @DisplayName("Call @Transactional that calls @Transactional(propagation = NESTED) - fails as unsupported") + @Test + public void callDefaultThatCallsNested() { + UUID id1 = UUID.randomUUID(); + + assertThrowsWithCause(() -> { + personService.propagationDefault(ops -> { + ops.insertById(Person.class).one(new Person(id1, "Ada", "Lovelace")); + + personService.propagationNested(ops2 -> {}); + }); + }, TransactionSystemUnambiguousException.class, UnsupportedOperationException.class); + + // Validate everything rolled back + assertNull(operations.findById(Person.class).one(id1.toString())); + } + + @DisplayName("Call @Transactional that calls @Transactional(propagation = DEFAULT) - check retries act correct") + @Test + public void callDefaultThatCallsDefaultRetries() { + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + AtomicInteger attempts = new AtomicInteger(); + + personService.propagationDefault(ops -> { + ops.insertById(Person.class).one(new Person(id1, "Ada", "Lovelace")); + + personService.propagationDefault(ops2 -> { + ops2.insertById(Person.class).one(new Person(id2, "Grace", "Hopper")); + assertInTransaction(); + + if (attempts.incrementAndGet() < 3) { + throw new RetryTransactionException(); + } + }); + }); + + // Validate everything committed + assertNotNull(operations.findById(Person.class).one(id1.toString())); + assertNotNull(operations.findById(Person.class).one(id2.toString())); + assertEquals(3, attempts.get()); + } + + @Service + @Component + @EnableTransactionManagement + static class PersonService { + final CouchbaseOperations ops; + + public PersonService(CouchbaseOperations ops) { + this.ops = ops; + } + + @Transactional + public void propagationDefault(@Nullable Consumer callback) { + LOGGER.info("propagationDefault"); + if (callback != null) + callback.accept(ops); + } + + @Transactional(propagation = Propagation.REQUIRED) + public void propagationRequired(@Nullable Consumer callback) { + LOGGER.info("propagationRequired"); + if (callback != null) + callback.accept(ops); + } + + @Transactional(propagation = Propagation.MANDATORY) + public void propagationMandatory(@Nullable Consumer callback) { + LOGGER.info("propagationMandatory"); + if (callback != null) + callback.accept(ops); + } + + @Transactional(propagation = Propagation.NESTED) + public void propagationNested(@Nullable Consumer callback) { + LOGGER.info("propagationNever"); + if (callback != null) + callback.accept(ops); + } + + @Transactional(propagation = Propagation.SUPPORTS) + public void propagationSupports(@Nullable Consumer callback) { + LOGGER.info("propagationSupports"); + if (callback != null) + callback.accept(ops); + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public void propagationNotSupported(@Nullable Consumer callback) { + LOGGER.info("propagationNotSupported"); + if (callback != null) + callback.accept(ops); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void propagationRequiresNew(@Nullable Consumer callback) { + LOGGER.info("propagationRequiresNew"); + if (callback != null) + callback.accept(ops); + } + + @Transactional(propagation = Propagation.NEVER) + public void propagationNever(@Nullable Consumer callback) { + LOGGER.info("propagationNever"); + if (callback != null) + callback.accept(ops); + } + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalRepositoryIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalRepositoryIntegrationTests.java new file mode 100644 index 000000000..a899d65ce --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalRepositoryIntegrationTests.java @@ -0,0 +1,146 @@ +/* + * Copyright 2022 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.transactions; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.springframework.data.couchbase.transactions.util.TransactionTestUtil.assertInTransaction; +import static org.springframework.data.couchbase.transactions.util.TransactionTestUtil.assertNotInTransaction; + +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.domain.User; +import org.springframework.data.couchbase.domain.UserRepository; +import org.springframework.data.couchbase.transaction.error.TransactionSystemUnambiguousException; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; + +/** + * Tests @Transactional with repository methods. + * + * @author Michael Reiche + */ +@IgnoreWhen(clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig( + classes = { TransactionsConfig.class, CouchbaseTransactionalRepositoryIntegrationTests.UserService.class }) +public class CouchbaseTransactionalRepositoryIntegrationTests extends JavaIntegrationTests { + // intellij flags "Could not autowire" when config classes are specified with classes={...}. But they are populated. + @Autowired UserRepository userRepo; + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired UserService userService; + @Autowired CouchbaseTemplate operations; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + @BeforeEach + public void beforeEachTest() { + assertNotInTransaction(); + } + + @AfterEach + public void afterEachTest() { + assertNotInTransaction(); + } + + @Test + public void findByFirstname() { + operations.insertById(User.class).one(new User(UUID.randomUUID().toString(), "Ada", "Lovelace")); + + List users = userService.findByFirstname("Ada"); + + assertNotEquals(0, users.size()); + } + + @Test + public void save() { + String id = UUID.randomUUID().toString(); + + userService.run(repo -> { + assertInTransaction(); + + User user0 = repo.save(new User(id, "Ada", "Lovelace")); + + assertInTransaction(); + + // read your own write + User user1 = operations.findById(User.class).one(id); + assertNotNull(user1); + + assertInTransaction(); + + }); + + User user = operations.findById(User.class).one(id); + assertNotNull(user); + } + + @DisplayName("Test that repo.save() is actually performed transactionally, by forcing a rollback") + @Test + public void saveRolledBack() { + String id = UUID.randomUUID().toString(); + + assertThrowsWithCause(() -> { + ; + userService.run(repo -> { + User user = repo.save(new User(id, "Ada", "Lovelace")); + SimulateFailureException.throwEx("fail"); + }); + }, TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + User user = operations.findById(User.class).one(id); + assertNull(user); + } + + @Service + @Component + @EnableTransactionManagement + static class UserService { + @Autowired UserRepository userRepo; + + @Transactional + public void run(Consumer callback) { + callback.accept(userRepo); + } + + @Transactional + public List findByFirstname(String name) { + return userRepo.findByFirstname(name); + } + + } + +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalTemplateIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalTemplateIntegrationTests.java new file mode 100644 index 000000000..12d324515 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalTemplateIntegrationTests.java @@ -0,0 +1,503 @@ +/* + * Copyright 2022 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.transactions; + +import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.fail; +import static org.springframework.data.couchbase.transactions.util.TransactionTestUtil.assertNotInTransaction; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseOperations; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.ReactiveCouchbaseOperations; +import org.springframework.data.couchbase.core.RemoveResult; +import org.springframework.data.couchbase.core.query.QueryCriteria; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.domain.PersonWithoutVersion; +import org.springframework.data.couchbase.transaction.CouchbaseCallbackTransactionManager; +import org.springframework.data.couchbase.transaction.error.TransactionSystemUnambiguousException; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; + +import com.couchbase.client.core.error.transaction.AttemptExpiredException; + +/** + * Tests for @Transactional, using template methods (findById etc.) + * + * @author Michael Reiche + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig( + classes = { TransactionsConfig.class, CouchbaseTransactionalTemplateIntegrationTests.PersonService.class }) +public class CouchbaseTransactionalTemplateIntegrationTests extends JavaIntegrationTests { + // intellij flags "Could not autowire" when config classes are specified with classes={...}. But they are populated. + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired PersonService personService; + @Autowired CouchbaseTemplate operations; + + Person WalterWhite; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + @AfterAll + public static void afterAll() { + callSuperAfterAll(new Object() {}); + } + + @BeforeEach + public void beforeEachTest() { + WalterWhite = new Person("Walter", "White"); + // Skip this as we just one to track TransactionContext + List pr = operations.removeByQuery(Person.class).withConsistency(REQUEST_PLUS).all(); + List p = operations.findByQuery(Person.class).withConsistency(REQUEST_PLUS).all(); + + List pwovr = operations.removeByQuery(PersonWithoutVersion.class).withConsistency(REQUEST_PLUS).all(); + List pwov = operations.findByQuery(PersonWithoutVersion.class).withConsistency(REQUEST_PLUS) + .all(); + } + + @AfterEach + public void afterEachTest() { + assertNotInTransaction(); + } + + @DisplayName("A basic golden path insert should succeed") + @Test + public void committedInsert() { + AtomicInteger tryCount = new AtomicInteger(0); + Person inserted = personService.doInTransaction(tryCount, (ops) -> { + return ops.insertById(Person.class).one(WalterWhite.withIdFirstname()); + }); + + Person fetched = operations.findById(Person.class).one(inserted.id()); + assertEquals(inserted.getFirstname(), fetched.getFirstname()); + assertEquals(1, tryCount.get()); + } + + @DisplayName("A basic golden path replace should succeed") + @Test + public void committedReplace() { + AtomicInteger tryCount = new AtomicInteger(); + Person person = operations.insertById(Person.class).one(WalterWhite); + + personService.fetchAndReplace(person.id(), tryCount, (p) -> { + p.setFirstname("changed"); + return p; + }); + + Person fetched = operations.findById(Person.class).one(person.id()); + assertEquals("changed", fetched.getFirstname()); + assertEquals(1, tryCount.get()); + } + + @DisplayName("A basic golden path remove should succeed") + @Test + public void committedRemove() { + AtomicInteger tryCount = new AtomicInteger(0); + Person person = operations.insertById(Person.class).one(WalterWhite); + + personService.fetchAndRemove(person.id(), tryCount); + + Person fetched = operations.findById(Person.class).one(person.id()); + assertNull(fetched); + assertEquals(1, tryCount.get()); + } + + @DisplayName("A basic golden path removeByQuery should succeed") + @Test + public void committedRemoveByQuery() { + AtomicInteger tryCount = new AtomicInteger(); + Person person = operations.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + List removed = personService.doInTransaction(tryCount, ops -> { + return ops.removeByQuery(Person.class).matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all(); + }); + + Person fetched = operations.findById(Person.class).one(person.id()); + assertNull(fetched); + assertEquals(1, tryCount.get()); + assertEquals(1, removed.size()); + } + + @DisplayName("A basic golden path findByQuery should succeed (though we don't know for sure it executed transactionally)") + @Test + public void committedFindByQuery() { + AtomicInteger tryCount = new AtomicInteger(0); + Person person = operations.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + List found = personService.doInTransaction(tryCount, ops -> { + return ops.findByQuery(Person.class).matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all(); + }); + + assertEquals(1, found.size()); + } + + @DisplayName("Basic test of doing an insert then rolling back") + @Test + public void rollbackInsert() { + AtomicInteger tryCount = new AtomicInteger(); + AtomicReference id = new AtomicReference<>(); + + assertThrowsWithCause(() -> { + personService.doInTransaction(tryCount, (ops) -> { + ops.insertById(Person.class).one(WalterWhite); + id.set(WalterWhite.id()); + throw new SimulateFailureException(); + }); + }, TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person fetched = operations.findById(Person.class).one(id.get()); + assertNull(fetched); + assertEquals(1, tryCount.get()); + } + + @DisplayName("Basic test of doing a replace then rolling back") + @Test + public void rollbackReplace() { + AtomicInteger tryCount = new AtomicInteger(0); + Person person = operations.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> { + personService.doInTransaction(tryCount, (ops) -> { + Person p = ops.findById(Person.class).one(person.id()); + ops.replaceById(Person.class).one(p.withFirstName("changed")); + throw new SimulateFailureException(); + }); + }, TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person fetched = operations.findById(Person.class).one(person.id()); + assertEquals(person.getFirstname(), fetched.getFirstname()); + assertEquals(1, tryCount.get()); + } + + @DisplayName("Basic test of doing a remove then rolling back") + @Test + public void rollbackRemove() { + AtomicInteger tryCount = new AtomicInteger(0); + Person person = operations.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> { + personService.doInTransaction(tryCount, (ops) -> { + Person p = ops.findById(Person.class).one(person.id()); + ops.removeById(Person.class).oneEntity(p); + throw new SimulateFailureException(); + }); + }, TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person fetched = operations.findById(Person.class).one(person.id()); + assertNotNull(fetched); + assertEquals(1, tryCount.get()); + } + + @DisplayName("Basic test of doing a removeByQuery then rolling back") + @Test + public void rollbackRemoveByQuery() { + AtomicInteger tryCount = new AtomicInteger(); + Person person = operations.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + assertThrowsWithCause(() -> { + personService.doInTransaction(tryCount, ops -> { + ops.removeByQuery(Person.class).matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all(); + throw new SimulateFailureException(); + }); + }, TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person fetched = operations.findById(Person.class).one(person.id()); + assertNotNull(fetched); + assertEquals(1, tryCount.get()); + } + + @DisplayName("Basic test of doing a findByQuery then rolling back") + @Test + public void rollbackFindByQuery() { + AtomicInteger tryCount = new AtomicInteger(); + Person person = operations.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + assertThrowsWithCause(() -> { + personService.doInTransaction(tryCount, ops -> { + ops.findByQuery(Person.class).matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all(); + throw new SimulateFailureException(); + }); + }, TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + assertEquals(1, tryCount.get()); + } + + @Test + public void shouldRollbackAfterException() { + assertThrowsWithCause(() -> { + personService.insertThenThrow(); + }, TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Long count = operations.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count(); + assertEquals(0, count, "should have done roll back and left 0 entries"); + } + + @Test + public void commitShouldPersistTxEntries() { + Person p = personService.declarativeSavePerson(WalterWhite); + Long count = operations.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count(); + assertEquals(1, count, "should have saved and found 1"); + } + + @Test + public void concurrentTxns() { + Runnable r = () -> { + Thread t = Thread.currentThread(); + System.out.printf("Started thread %d %s%n", t.getId(), t.getName()); + Person p = personService.declarativeSavePersonWithThread(WalterWhite, t); + System.out.printf("Finished thread %d %s%n", t.getId(), t.getName()); + }; + List threads = new ArrayList<>(); + for (int i = 0; i < 50; i++) { // somewhere between 50-80 it starts to hang + Thread t = new Thread(r); + t.start(); + threads.add(t); + } + + threads.forEach(t -> { + try { + System.out.printf("Waiting for thread %d %s%n", t.getId(), t.getName()); + t.join(); + System.out.printf("Finished waiting for thread %d %s%n", t.getId(), t.getName()); + } catch (InterruptedException e) { + fail(); // interrupted + } + }); + } + + @DisplayName("Create a Person outside a @Transactional block, modify it, and then replace that person in the @Transactional. The transaction will retry until timeout.") + @Test + public void replacePerson() { + Person person = operations.insertById(Person.class).one(WalterWhite); + Person refetched = operations.findById(Person.class).one(person.id()); + operations.replaceById(Person.class).one(refetched); + assertNotEquals(person.getVersion(), refetched.getVersion()); + AtomicInteger tryCount = new AtomicInteger(0); + assertThrowsWithCause(() -> personService.replace(person, tryCount), TransactionSystemUnambiguousException.class, + AttemptExpiredException.class); + } + + @DisplayName("Entity must have CAS field during replace") + @Test + public void replaceEntityWithoutCas() { + PersonWithoutVersion person = new PersonWithoutVersion(UUID.randomUUID(), "Walter", "White"); + operations.insertById(PersonWithoutVersion.class).one(person); + + assertThrowsWithCause(() -> personService.replaceEntityWithoutVersion(person.id()), + TransactionSystemUnambiguousException.class, IllegalArgumentException.class); + } + + @DisplayName("Entity must have non-zero CAS during replace") + @Test + public void replaceEntityWithCasZero() { + Person person = operations.insertById(Person.class).one(WalterWhite); + // switchedPerson here will have CAS=0, which will fail + Person switchedPerson = new Person("Dave", "Reynolds"); + AtomicInteger tryCount = new AtomicInteger(0); + + assertThrowsWithCause(() -> personService.replacePerson(switchedPerson, tryCount), + TransactionSystemUnambiguousException.class, IllegalArgumentException.class); + } + + @DisplayName("Entity must have CAS field during remove") + @Test + public void removeEntityWithoutCas() { + PersonWithoutVersion person = new PersonWithoutVersion(UUID.randomUUID(), "Walter", "White"); + operations.insertById(PersonWithoutVersion.class).one(person); + + assertThrowsWithCause(() -> personService.removeEntityWithoutVersion(person.id()), + TransactionSystemUnambiguousException.class, IllegalArgumentException.class); + } + + @DisplayName("removeById().one(id) isn't allowed in transactions, since we don't have the CAS") + @Test + public void removeEntityById() { + AtomicInteger tryCount = new AtomicInteger(); + Person person = operations.insertById(Person.class).one(WalterWhite); + assertThrowsWithCause(() -> { + personService.doInTransaction(tryCount, (ops) -> { + Person p = ops.findById(Person.class).one(person.id()); + ops.removeById(Person.class).one(p.id()); + return p; + }); + }, TransactionSystemUnambiguousException.class, IllegalArgumentException.class); + } + + @Service + @Component + @EnableTransactionManagement + static class PersonService { + final CouchbaseOperations personOperations; + final ReactiveCouchbaseOperations personOperationsRx; + + public PersonService(CouchbaseOperations ops, ReactiveCouchbaseOperations opsRx) { + personOperations = ops; + personOperationsRx = opsRx; + } + + @Transactional + public Person declarativeSavePerson(Person person) { + assertInAnnotationTransaction(true); + long currentThreadId = Thread.currentThread().getId(); + System.out + .println(String.format("Thread %d %s", Thread.currentThread().getId(), Thread.currentThread().getName())); + Person ret = personOperations.insertById(Person.class).one(person); + System.out.println(String.format("Thread %d (was %d) %s", Thread.currentThread().getId(), currentThreadId, + Thread.currentThread().getName())); + if (currentThreadId != Thread.currentThread().getId()) { + throw new IllegalStateException(); + } + return ret; + } + + @Transactional + public Person declarativeSavePersonWithThread(Person person, Thread thread) { + assertInAnnotationTransaction(true); + long currentThreadId = Thread.currentThread().getId(); + System.out.printf("Thread %d %s, started from %d %s%n", Thread.currentThread().getId(), + Thread.currentThread().getName(), thread.getId(), thread.getName()); + Person ret = personOperations.insertById(Person.class).one(person); + System.out.printf("Thread %d (was %d) %s, started from %d %s%n", Thread.currentThread().getId(), currentThreadId, + Thread.currentThread().getName(), thread.getId(), thread.getName()); + if (currentThreadId != Thread.currentThread().getId()) { + throw new IllegalStateException(); + } + return ret; + } + + @Transactional + public void insertThenThrow() { + assertInAnnotationTransaction(true); + Person person = personOperations.insertById(Person.class).one(new Person("Walter", "White")); + SimulateFailureException.throwEx(); + } + + @Autowired CouchbaseCallbackTransactionManager callbackTm; + + /** + * to execute while ThreadReplaceloop() is running should force a retry + * + * @param person + * @return + */ + @Transactional + public Person replacePerson(Person person, AtomicInteger tryCount) { + tryCount.incrementAndGet(); + // Note that passing in a Person and replace it in this way, is not supported + return personOperations.replaceById(Person.class).one(person); + } + + @Transactional + public void replaceEntityWithoutVersion(String id) { + PersonWithoutVersion fetched = personOperations.findById(PersonWithoutVersion.class).one(id); + personOperations.replaceById(PersonWithoutVersion.class).one(fetched); + } + + @Transactional + public void removeEntityWithoutVersion(String id) { + PersonWithoutVersion fetched = personOperations.findById(PersonWithoutVersion.class).one(id); + personOperations.removeById(PersonWithoutVersion.class).oneEntity(fetched); + } + + @Transactional + public Person declarativeFindReplaceTwicePersonCallback(Person person, AtomicInteger tryCount) { + assertInAnnotationTransaction(true); + System.err.println("declarativeFindReplacePersonCallback try: " + tryCount.incrementAndGet()); + Person p = personOperations.findById(Person.class).one(person.id()); + Person pUpdated = personOperations.replaceById(Person.class).one(p); + return personOperations.replaceById(Person.class).one(pUpdated); + } + + @Transactional(timeout = 2) + + public Person replace(Person person, AtomicInteger tryCount) { + assertInAnnotationTransaction(true); + tryCount.incrementAndGet(); + return personOperations.replaceById(Person.class).one(person); + } + + @Transactional + public Person fetchAndReplace(String id, AtomicInteger tryCount, Function callback) { + assertInAnnotationTransaction(true); + tryCount.incrementAndGet(); + Person p = personOperations.findById(Person.class).one(id); + Person modified = callback.apply(p); + return personOperations.replaceById(Person.class).one(modified); + } + + @Transactional + public T doInTransaction(AtomicInteger tryCount, Function callback) { + assertInAnnotationTransaction(true); + tryCount.incrementAndGet(); + return callback.apply(personOperations); + } + + @Transactional + public void fetchAndRemove(String id, AtomicInteger tryCount) { + assertInAnnotationTransaction(true); + tryCount.incrementAndGet(); + Person p = personOperations.findById(Person.class).one(id); + personOperations.removeById(Person.class).oneEntity(p); + } + + } + + static void assertInAnnotationTransaction(boolean inTransaction) { + StackTraceElement[] stack = Thread.currentThread().getStackTrace(); + for (StackTraceElement ste : stack) { + if (ste.getClassName().startsWith("org.springframework.transaction.interceptor")) { + if (inTransaction) { + return; + } + } + } + if (!inTransaction) { + return; + } + throw new RuntimeException( + "in transaction = " + (!inTransaction) + " but expected in annotation transaction = " + inTransaction); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalUnsettableParametersIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalUnsettableParametersIntegrationTests.java new file mode 100644 index 000000000..2a1c18c36 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalUnsettableParametersIntegrationTests.java @@ -0,0 +1,170 @@ +/* + * Copyright 2022 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.transactions; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseOperations; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.transaction.error.TransactionSystemUnambiguousException; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; + +import com.couchbase.client.java.kv.InsertOptions; +import com.couchbase.client.java.kv.PersistTo; +import com.couchbase.client.java.kv.RemoveOptions; +import com.couchbase.client.java.kv.ReplaceOptions; +import com.couchbase.client.java.kv.ReplicateTo; + +/** + * Tests for @Transactional methods, where parameters/options are being set that aren't support in a transaction. These + * will be rejected at runtime. + * + * @author Graham Pople + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(classes = { TransactionsConfig.class, + CouchbaseTransactionalUnsettableParametersIntegrationTests.PersonService.class }) +public class CouchbaseTransactionalUnsettableParametersIntegrationTests extends JavaIntegrationTests { + + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired PersonService personService; + + Person WalterWhite; + + @BeforeEach + public void beforeEachTest() { + WalterWhite = new Person("Walter", "White"); + } + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + void test(Consumer r) { + AtomicInteger tryCount = new AtomicInteger(0); + + assertThrowsWithCause(() -> { + personService.doInTransaction(tryCount, (ops) -> { + r.accept(ops); + return null; + }); + }, TransactionSystemUnambiguousException.class, IllegalArgumentException.class); + + assertEquals(1, tryCount.get()); + } + + @DisplayName("Using insertById().withDurability - the PersistTo overload - in a transaction is rejected at runtime") + @Test + public void insertWithDurability() { + test((ops) -> { + ops.insertById(Person.class).withDurability(PersistTo.ONE, ReplicateTo.ONE).one(WalterWhite); + }); + } + + @DisplayName("Using insertById().withExpiry in a transaction is rejected at runtime") + @Test + public void insertWithExpiry() { + test((ops) -> { + ops.insertById(Person.class).withExpiry(Duration.ofSeconds(3)).one(WalterWhite); + }); + } + + @DisplayName("Using insertById().withOptions in a transaction is rejected at runtime") + @Test + public void insertWithOptions() { + test((ops) -> { + ops.insertById(Person.class).withOptions(InsertOptions.insertOptions()).one(WalterWhite); + }); + } + + @DisplayName("Using replaceById().withDurability - the PersistTo overload - in a transaction is rejected at runtime") + @Test + public void replaceWithDurability() { + test((ops) -> { + ops.replaceById(Person.class).withDurability(PersistTo.ONE, ReplicateTo.ONE).one(WalterWhite); + }); + } + + @DisplayName("Using replaceById().withExpiry in a transaction is rejected at runtime") + @Test + public void replaceWithExpiry() { + test((ops) -> { + ops.replaceById(Person.class).withExpiry(Duration.ofSeconds(3)).one(WalterWhite); + }); + } + + @DisplayName("Using replaceById().withOptions in a transaction is rejected at runtime") + @Test + public void replaceWithOptions() { + test((ops) -> { + ops.replaceById(Person.class).withOptions(ReplaceOptions.replaceOptions()).one(WalterWhite); + }); + } + + @DisplayName("Using removeById().withDurability - the PersistTo overload - in a transaction is rejected at runtime") + @Test + public void removeWithDurability() { + test((ops) -> { + ops.removeById(Person.class).withDurability(PersistTo.ONE, ReplicateTo.ONE).oneEntity(WalterWhite); + }); + } + + @DisplayName("Using removeById().withOptions in a transaction is rejected at runtime") + @Test + public void removeWithOptions() { + test((ops) -> { + ops.removeById(Person.class).withOptions(RemoveOptions.removeOptions()).oneEntity(WalterWhite); + }); + } + + @Service + @Component + @EnableTransactionManagement + static class PersonService { + final CouchbaseOperations personOperations; + + public PersonService(CouchbaseOperations ops) { + personOperations = ops; + } + + @Transactional + public T doInTransaction(AtomicInteger tryCount, Function callback) { + tryCount.incrementAndGet(); + return callback.apply(personOperations); + } + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/DirectPlatformTransactionManagerIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/DirectPlatformTransactionManagerIntegrationTests.java new file mode 100644 index 000000000..0e2b56dd4 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/DirectPlatformTransactionManagerIntegrationTests.java @@ -0,0 +1,51 @@ +/* + * Copyright 2022 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.transactions; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.transaction.CouchbaseCallbackTransactionManager; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.DefaultTransactionDefinition; + +/** + * We do not support direct use of the PlatformTransactionManager. + * + * @author Graham Pople + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(TransactionsConfig.class) +public class DirectPlatformTransactionManagerIntegrationTests extends JavaIntegrationTests { + @Autowired CouchbaseClientFactory couchbaseClientFactory; + + @Test + public void directUseAlwaysFails() { + PlatformTransactionManager ptm = new CouchbaseCallbackTransactionManager(couchbaseClientFactory); + + assertThrowsWithCause(() -> { + TransactionDefinition def = new DefaultTransactionDefinition(); + ptm.getTransaction(def); + }, UnsupportedOperationException.class); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/ObjectId.java b/src/test/java/org/springframework/data/couchbase/transactions/ObjectId.java new file mode 100644 index 000000000..efc5f05b4 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/ObjectId.java @@ -0,0 +1,36 @@ +/* + * Copyright 2022 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.transactions; + +import java.util.UUID; + +/** + * ObjectId for Transaction tests + * + * @author Michael Reiche + */ +public class ObjectId { + public ObjectId() { + id = UUID.randomUUID().toString(); + } + + String id; + + public String toString() { + return id.toString(); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/PersonService.java b/src/test/java/org/springframework/data/couchbase/transactions/PersonService.java new file mode 100644 index 000000000..f7d0e43d5 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/PersonService.java @@ -0,0 +1,201 @@ +/* + * Copyright 2022 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.transactions; + +import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; +import static org.springframework.data.couchbase.util.JavaIntegrationTests.throwSimulateFailureException; +import static org.springframework.data.couchbase.util.Util.assertInAnnotationTransaction; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.data.couchbase.config.BeanNames; +import org.springframework.data.couchbase.core.CouchbaseOperations; +import org.springframework.data.couchbase.core.ReactiveCouchbaseOperations; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.reactive.TransactionalOperator; + +/** + * PersonService for tests + * + * @author Michael Reiche + */ +@Service +@Component +@EnableTransactionManagement +class PersonService { + + final CouchbaseOperations personOperations; + final ReactiveCouchbaseOperations personOperationsRx; + final TransactionalOperator transactionalOperator; + + public PersonService(CouchbaseOperations ops, ReactiveCouchbaseOperations opsRx, + TransactionalOperator transactionalOperator) { + personOperations = ops; + personOperationsRx = opsRx; + this.transactionalOperator = transactionalOperator; + } + + public Person savePersonErrors(Person person) { + assertInAnnotationTransaction(false); + + return personOperationsRx.insertById(Person.class).one(person)// + . flatMap(it -> Mono.error(new SimulateFailureException()))// + .as(transactionalOperator::transactional).block(); + } + + public Person savePerson(Person person) { + assertInAnnotationTransaction(false); + return personOperationsRx.insertById(Person.class).one(person)// + .as(transactionalOperator::transactional).block(); + } + + public Long countDuringTx(Person person) { + assertInAnnotationTransaction(false); + return personOperationsRx.insertById(Person.class).one(person)// + .then(personOperationsRx.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count()) + .as(transactionalOperator::transactional).block(); + } + + public List saveWithLogs(Person person) { + assertInAnnotationTransaction(false); + return Flux + .merge( + personOperationsRx.insertById(CouchbasePersonTransactionIntegrationTests.EventLog.class) + .one(new CouchbasePersonTransactionIntegrationTests.EventLog(new ObjectId(), "beforeConvert")), + personOperationsRx.insertById(CouchbasePersonTransactionIntegrationTests.EventLog.class) + .one(new CouchbasePersonTransactionIntegrationTests.EventLog(new ObjectId(), "afterConvert")), + personOperationsRx.insertById(CouchbasePersonTransactionIntegrationTests.EventLog.class) + .one(new CouchbasePersonTransactionIntegrationTests.EventLog(new ObjectId(), "beforeInsert")), + personOperationsRx.insertById(Person.class).one(person), + personOperationsRx.insertById(CouchbasePersonTransactionIntegrationTests.EventLog.class) + .one(new CouchbasePersonTransactionIntegrationTests.EventLog(new ObjectId(), "afterInsert"))) // + .thenMany(personOperationsRx.findByQuery(CouchbasePersonTransactionIntegrationTests.EventLog.class) + .withConsistency(REQUEST_PLUS).all()) // + .as(transactionalOperator::transactional).collectList().block(); + } + + public List saveWithErrorLogs(Person person) { + assertInAnnotationTransaction(false); + + return Flux + .merge( + personOperationsRx.insertById(CouchbasePersonTransactionIntegrationTests.EventLog.class) + .one(new CouchbasePersonTransactionIntegrationTests.EventLog(new ObjectId(), "beforeConvert")), + // + personOperationsRx.insertById(CouchbasePersonTransactionIntegrationTests.EventLog.class) + .one(new CouchbasePersonTransactionIntegrationTests.EventLog(new ObjectId(), "afterConvert")), + // + personOperationsRx.insertById(CouchbasePersonTransactionIntegrationTests.EventLog.class) + .one(new CouchbasePersonTransactionIntegrationTests.EventLog(new ObjectId(), "beforeInsert")), + // + personOperationsRx.insertById(Person.class).one(person), + // + personOperationsRx.insertById(CouchbasePersonTransactionIntegrationTests.EventLog.class) + .one(new CouchbasePersonTransactionIntegrationTests.EventLog(new ObjectId(), "afterInsert"))) // + .thenMany(personOperationsRx.findByQuery(CouchbasePersonTransactionIntegrationTests.EventLog.class) + .withConsistency(REQUEST_PLUS).all()) // + . flatMap(it -> Mono.error(new SimulateFailureException())) + .as(transactionalOperator::transactional).collectList().block(); + + } + + // org.springframework.beans.factory.NoUniqueBeanDefinitionException: + // No qualifying bean of type 'org.springframework.transaction.TransactionManager' available: expected single + // matching bean but found 2: reactiveCouchbaseTransactionManager,couchbaseTransactionManager + @Transactional(transactionManager = BeanNames.COUCHBASE_TRANSACTION_MANAGER) + public Person declarativeSavePerson(Person person) { + assertInAnnotationTransaction(true); + return personOperations.insertById(Person.class).one(person); + } + + @Transactional(transactionManager = BeanNames.COUCHBASE_TRANSACTION_MANAGER) + public Person declarativeSavePersonErrors(Person person) { + assertInAnnotationTransaction(true); + Person p = personOperations.insertById(Person.class).one(person); // + SimulateFailureException.throwEx(); + return p; + } + + /** + * to execute while ThreadReplaceloop() is running should force a retry + * + * @param person + * @return + */ + @Transactional(transactionManager = BeanNames.COUCHBASE_TRANSACTION_MANAGER) + public Person declarativeFindReplacePersonCallback(Person person, AtomicInteger tryCount) { + assertInAnnotationTransaction(true); + System.err.println("declarativeFindReplacePersonCallback try: " + tryCount.incrementAndGet()); + Person p = personOperations.findById(Person.class).one(person.id()); + ReplaceLoopThread.updateOutOfTransaction(personOperations, person, tryCount.get()); + return personOperations.replaceById(Person.class).one(p.withFirstName(person.getFirstname())); + } + + /** + * The ReactiveCouchbaseTransactionManager does not retry on write-write conflict. Instead it will throw + * RetryTransactionException to execute while ThreadReplaceloop() is running should force a retry + * + * @param person + * @return + */ + // @Transactional(transactionManager = BeanNames.REACTIVE_COUCHBASE_TRANSACTION_MANAGER) + // must use transactionalOperator + public Mono declarativeFindReplacePersonReactive(Person person, AtomicInteger tryCount) { + // assertInAnnotationTransaction(true); + return personOperationsRx.findById(Person.class).one(person.id()) + .map((p) -> ReplaceLoopThread.updateOutOfTransaction(personOperations, p, tryCount.incrementAndGet())) + .flatMap(p -> personOperationsRx.replaceById(Person.class).one(p.withFirstName(person.getFirstname()))) + .as(transactionalOperator::transactional); + } + + /** + * @param person + * @return + */ + @Transactional(transactionManager = BeanNames.COUCHBASE_TRANSACTION_MANAGER) + public Person declarativeFindReplacePerson(Person person, AtomicInteger tryCount) { + assertInAnnotationTransaction(true); + System.err.println("declarativeFindReplacePerson try: " + tryCount.incrementAndGet()); + Person p = personOperations.findById(Person.class).one(person.getId().toString()); + ReplaceLoopThread.updateOutOfTransaction(personOperations, person, tryCount.get()); + return personOperations.replaceById(Person.class).one(p.withFirstName(person.getFirstname())); + } + + // @Transactional(transactionManager = BeanNames.REACTIVE_COUCHBASE_TRANSACTION_MANAGER) + // must use transactionalOperator + public Mono declarativeSavePersonReactive(Person person) { + // assertInAnnotationTransaction(true); + return personOperationsRx.insertById(Person.class).one(person).as(transactionalOperator::transactional); + } + + // @Transactional(transactionManager = BeanNames.REACTIVE_COUCHBASE_TRANSACTION_MANAGER) + // must use transactionalOperator + public Mono declarativeSavePersonErrorsReactive(Person person) { + // assertInAnnotationTransaction(true); + return personOperationsRx.insertById(Person.class).one(person).map((pp) -> throwSimulateFailureException(pp)) + .as(transactionalOperator::transactional); // + } + +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/PersonServiceReactive.java b/src/test/java/org/springframework/data/couchbase/transactions/PersonServiceReactive.java new file mode 100644 index 000000000..9442687be --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/PersonServiceReactive.java @@ -0,0 +1,113 @@ +/* + * Copyright 2022 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.transactions; + +import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.data.couchbase.config.BeanNames; +import org.springframework.data.couchbase.core.CouchbaseOperations; +import org.springframework.data.couchbase.core.ReactiveCouchbaseOperations; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.reactive.TransactionalOperator; + +/** + * reactive PersonService for tests + * + * @author Michael Reiche + */ +class PersonServiceReactive { + + final ReactiveCouchbaseOperations personOperationsRx; + final CouchbaseOperations personOperations; + final TransactionalOperator transactionalOperator; + + public PersonServiceReactive(CouchbaseOperations ops, ReactiveCouchbaseOperations opsRx, + TransactionalOperator transactionalOperator) { + this.personOperations = ops; + this.personOperationsRx = opsRx; + this.transactionalOperator = transactionalOperator; + return; + } + + public Mono savePersonErrors(Person person) { + return personOperationsRx.insertById(Person.class).one(person) // + . flatMap(it -> Mono.error(new SimulateFailureException())) // + .as(transactionalOperator::transactional); + } + + public Mono savePerson(Person person) { + return personOperationsRx.insertById(Person.class).one(person) // + .flatMap(Mono::just) // + .as(transactionalOperator::transactional); + } + + public Mono countDuringTx(Person person) { + return personOperationsRx.save(person) // + .then(personOperationsRx.findByQuery(Person.class).withConsistency(REQUEST_PLUS).count()) // + .as(transactionalOperator::transactional); + } + + public Flux saveWithLogs(Person person) { + return Flux + .merge( + personOperationsRx.save(new CouchbasePersonTransactionReactiveIntegrationTests.EventLog( + new ObjectId().toString(), "beforeConvert")), + personOperationsRx + .save(new CouchbasePersonTransactionReactiveIntegrationTests.EventLog(new ObjectId(), "afterConvert")), + personOperationsRx + .save(new CouchbasePersonTransactionReactiveIntegrationTests.EventLog(new ObjectId(), "beforeInsert")), + personOperationsRx.save(person), + personOperationsRx + .save(new CouchbasePersonTransactionReactiveIntegrationTests.EventLog(new ObjectId(), "afterInsert"))) // + .thenMany(personOperationsRx.findByQuery(CouchbasePersonTransactionReactiveIntegrationTests.EventLog.class) + .withConsistency(REQUEST_PLUS).all()) // + .as(transactionalOperator::transactional); + } + + public Flux saveWithErrorLogs(Person person) { + return Flux + .merge( + personOperationsRx + .save(new CouchbasePersonTransactionReactiveIntegrationTests.EventLog(new ObjectId(), "beforeConvert")), + personOperationsRx + .save(new CouchbasePersonTransactionReactiveIntegrationTests.EventLog(new ObjectId(), "afterConvert")), + personOperationsRx + .save(new CouchbasePersonTransactionReactiveIntegrationTests.EventLog(new ObjectId(), "beforeInsert")), + personOperationsRx.save(person), + personOperationsRx + .save(new CouchbasePersonTransactionReactiveIntegrationTests.EventLog(new ObjectId(), "afterInsert"))) // + . flatMap(it -> Mono.error(new SimulateFailureException())) // + .as(transactionalOperator::transactional); + } + + // @Transactional(transactionManager = BeanNames.COUCHBASE_TRANSACTION_MANAGER) + public Flux declarativeSavePerson(Person person) { + return transactionalOperator.execute(reactiveTransaction -> personOperationsRx.save(person)); + } + + @Transactional(transactionManager = BeanNames.COUCHBASE_TRANSACTION_MANAGER) + public Flux declarativeSavePersonErrors(Person person) { + Person p = personOperations.insertById(Person.class).one(person); + Person pp = personOperations.findByQuery(Person.class).withConsistency(REQUEST_PLUS).all().get(0); + SimulateFailureException.throwEx(); // so the following lines is not flagged as unreachable + return Flux.just(p); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/ReactiveTransactionalTemplateIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/ReactiveTransactionalTemplateIntegrationTests.java new file mode 100644 index 000000000..b451a89b9 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/ReactiveTransactionalTemplateIntegrationTests.java @@ -0,0 +1,206 @@ +/* + * Copyright 2022 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.transactions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.data.couchbase.transactions.util.TransactionTestUtil.assertNotInTransaction; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.ReactiveCouchbaseOperations; +import org.springframework.data.couchbase.core.TransactionalSupport; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.transaction.error.TransactionSystemUnambiguousException; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; + +/** + * Tests for reactive @Transactional, using the CouchbaseTransactionInterceptor. + * + * @author Graham Pople + */ +@IgnoreWhen(clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig( + classes = { TransactionsConfig.class, ReactiveTransactionalTemplateIntegrationTests.PersonService.class }) +public class ReactiveTransactionalTemplateIntegrationTests extends JavaIntegrationTests { + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired PersonService personService; + @Autowired CouchbaseTemplate blocking; + Person WalterWhite; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + @AfterAll + public static void afterAll() { + callSuperAfterAll(new Object() {}); + } + + @BeforeEach + public void beforeEachTest() { + WalterWhite = new Person("Walter", "White"); + assertNotInTransaction(); + } + + @AfterEach + public void afterEachTest() { + assertNotInTransaction(); + } + + @DisplayName("A basic golden path insert should succeed") + @Test + public void committedInsert() { + AtomicInteger tryCount = new AtomicInteger(0); + + personService.doInTransaction(tryCount, (ops) -> { + return Mono.defer(() -> { + return ops.insertById(Person.class).one(WalterWhite); + }); + }).block(); + + Person fetched = blocking.findById(Person.class).one(WalterWhite.id()); + assertEquals(WalterWhite.getFirstname(), fetched.getFirstname()); + assertEquals(1, tryCount.get()); + } + + @DisplayName("Basic test of doing an insert then rolling back") + @Test + public void rollbackInsert() { + AtomicInteger tryCount = new AtomicInteger(); + + assertThrowsWithCause(() -> { + personService.doInTransaction(tryCount, (ops) -> { + return Mono.defer(() -> { + return ops.insertById(Person.class).one(WalterWhite).then(Mono.error(new SimulateFailureException())); + }); + }).block(); + }, TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person fetched = blocking.findById(Person.class).one(WalterWhite.id()); + assertNull(fetched); + assertEquals(1, tryCount.get()); + } + + @DisplayName("Forcing CAS mismatch causes a transaction retry") + @Test + public void casMismatchCausesRetry() { + Person person = blocking.insertById(Person.class).one(WalterWhite); + AtomicInteger attempts = new AtomicInteger(); + + personService.doInTransaction(attempts, ops -> { + return ops.findById(Person.class).one(person.id()).flatMap(fetched -> Mono.fromRunnable(() -> { + ReplaceLoopThread.updateOutOfTransaction(blocking, person.withFirstName("ChangedExternally"), attempts.get()); + }).then(ops.replaceById(Person.class).one(fetched.withFirstName("Changed by transaction")))); + }).block(); + + Person fetched = blocking.findById(Person.class).one(person.getId().toString()); + assertEquals("Changed by transaction", fetched.getFirstname()); + assertEquals(2, attempts.get()); + } + + @Test + public void returnMono() { + AtomicInteger tryCount = new AtomicInteger(0); + + Person fromLambda = personService.doInTransactionReturningMono(tryCount, (ops) -> { + return Mono.defer(() -> { + return ops.insertById(Person.class).one(WalterWhite).log("source"); + }).log("returnMono test"); + }).block(); + + assertNotNull(fromLambda); + assertEquals(WalterWhite.getFirstname(), fromLambda.getFirstname()); + } + + @Test + public void returnFlux() { + AtomicInteger tryCount = new AtomicInteger(0); + + List fromLambda = personService.doInTransactionReturningFlux(tryCount, (ops) -> { + return Flux.defer(() -> { + return ops.insertById(Person.class).one(WalterWhite) + .thenMany(Flux.fromIterable(Arrays.asList(1, 2, 3)).log("1")); + }); + }).collectList().block(); + + assertEquals(3, fromLambda.size()); + } + + @Service + @Component + @EnableTransactionManagement + static class PersonService { + final ReactiveCouchbaseOperations ops; + + public PersonService(ReactiveCouchbaseOperations ops) { + this.ops = ops; + } + + @Transactional + public Mono doInTransaction(AtomicInteger tryCount, Function> callback) { + return TransactionalSupport.checkForTransactionInThreadLocalStorage().flatMap(stat -> { + assertTrue(stat.isPresent(), "Not in transaction"); + tryCount.incrementAndGet(); + return callback.apply(ops).then(); + }); + } + + @Transactional + public Mono doInTransactionReturningMono(AtomicInteger tryCount, + Function> callback) { + return Mono.defer(() -> { + tryCount.incrementAndGet(); + return callback.apply(ops); + }); + } + + @Transactional + public Flux doInTransactionReturningFlux(AtomicInteger tryCount, + Function> callback) { + return Flux.defer(() -> { + tryCount.incrementAndGet(); + return callback.apply(ops); + }); + } + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/ReplaceLoopThread.java b/src/test/java/org/springframework/data/couchbase/transactions/ReplaceLoopThread.java new file mode 100644 index 000000000..361ac78e0 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/ReplaceLoopThread.java @@ -0,0 +1,85 @@ +/* + * Copyright 2022 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.transactions; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.Assert; +import org.springframework.data.couchbase.core.CouchbaseOperations; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.util.JavaIntegrationTests; + +/** + * For testing transactions + * + * @author Michael Reiche + */ +public class ReplaceLoopThread extends Thread { + private final CouchbaseOperations couchbaseOperations; + AtomicBoolean stop = new AtomicBoolean(false); + UUID id; + int maxIterations = 100; + + public ReplaceLoopThread(CouchbaseOperations couchbaseOperations, UUID id, int... iterations) { + Assert.assertNotNull("couchbaseOperations cannot be null", couchbaseOperations); + this.couchbaseOperations = couchbaseOperations; + this.id = id; + if (iterations != null && iterations.length == 1) { + this.maxIterations = iterations[0]; + } + } + + public void run() { + for (int i = 0; i < maxIterations && !stop.get(); i++) { + JavaIntegrationTests.sleepMs(10); + try { + // note that this does not go through spring-data, therefore it does not have the @Field , @Version etc. + // annotations processed so we just check getFirstname().equals() + // switchedPerson has version=0, so it doesn't check CAS + Person fetched = couchbaseOperations.findById(Person.class).one(id.toString()); + couchbaseOperations.replaceById(Person.class).one(fetched.withFirstName("Changed externally")); + System.out.println("********** replace thread: " + i + " success"); + } catch (Exception e) { + System.out.println("********** replace thread: " + i + " " + e.getClass().getName()); + e.printStackTrace(); + } + } + + } + + public void setStopFlag() { + stop.set(true); + } + + public static Person updateOutOfTransaction(CouchbaseOperations couchbaseOperations, Person pp, int tryCount) { + System.err.println("updateOutOfTransaction: " + tryCount); + if (tryCount < 1) { + throw new RuntimeException("increment before calling updateOutOfTransactions"); + } + if (tryCount > 1) { + return pp; + } + ReplaceLoopThread t = new ReplaceLoopThread(couchbaseOperations, pp.getId(), 1); + t.start(); + try { + t.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + return pp; + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/SimulateFailureException.java b/src/test/java/org/springframework/data/couchbase/transactions/SimulateFailureException.java new file mode 100644 index 000000000..fdacc3045 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/SimulateFailureException.java @@ -0,0 +1,36 @@ +/* + * Copyright 2022 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.transactions; + +/** + * A recognizable exception for testing transactions + * + * @author Michael Reiche + */ +public class SimulateFailureException extends RuntimeException { + + public SimulateFailureException(String... s) { + super(s != null && s.length > 0 ? s[0] : null); + } + + public SimulateFailureException() {} + + public static void throwEx(String... s) { + throw new SimulateFailureException(s); + } + +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/TransactionTemplateIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/TransactionTemplateIntegrationTests.java new file mode 100644 index 000000000..07725cac9 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/TransactionTemplateIntegrationTests.java @@ -0,0 +1,377 @@ +/* + * Copyright 2022 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.transactions; + +import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.data.couchbase.transactions.util.TransactionTestUtil.assertNotInTransaction; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.RemoveResult; +import org.springframework.data.couchbase.core.query.QueryCriteria; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.domain.PersonWithoutVersion; +import org.springframework.data.couchbase.transaction.CouchbaseCallbackTransactionManager; +import org.springframework.data.couchbase.transaction.error.TransactionSystemUnambiguousException; +import org.springframework.data.couchbase.transactions.util.TransactionTestUtil; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Tests for Spring's TransactionTemplate, used CouchbaseCallbackTransactionManager, using template methods (findById + * etc.) + * + * @author Graham Pople + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(TransactionsConfig.class) +public class TransactionTemplateIntegrationTests extends JavaIntegrationTests { + TransactionTemplate template; + @Autowired CouchbaseCallbackTransactionManager transactionManager; + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired CouchbaseTemplate ops; + Person WalterWhite; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + @AfterAll + public static void afterAll() { + callSuperAfterAll(new Object() {}); + } + + @BeforeEach + public void beforeEachTest() { + WalterWhite = new Person("Walter", "White"); + assertNotInTransaction(); + List rp0 = ops.removeByQuery(Person.class).withConsistency(REQUEST_PLUS).all(); + List rp1 = ops.removeByQuery(PersonWithoutVersion.class).withConsistency(REQUEST_PLUS).all(); + + template = new TransactionTemplate(transactionManager); + } + + @AfterEach + public void afterEachTest() { + assertNotInTransaction(); + } + + static class RunResult { + public final int attempts; + + public RunResult(int attempts) { + this.attempts = attempts; + } + } + + private RunResult doInTransaction(Consumer lambda) { + AtomicInteger tryCount = new AtomicInteger(); + + template.executeWithoutResult(status -> { + TransactionTestUtil.assertInTransaction(); + assertFalse(status.hasSavepoint()); + assertFalse(status.isRollbackOnly()); + assertFalse(status.isCompleted()); + assertTrue(status.isNewTransaction()); + + tryCount.incrementAndGet(); + lambda.accept(status); + }); + + TransactionTestUtil.assertNotInTransaction(); + + return new RunResult(tryCount.get()); + } + + @DisplayName("A basic golden path insert should succeed") + @Test + public void committedInsert() { + RunResult rr = doInTransaction(status -> { + ops.insertById(Person.class).one(WalterWhite); + }); + + Person fetched = ops.findById(Person.class).one(WalterWhite.id()); + assertEquals(WalterWhite.getFirstname(), fetched.getFirstname()); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path replace should succeed") + @Test + public void committedReplace() { + Person person = ops.insertById(Person.class).one(WalterWhite); + + RunResult rr = doInTransaction(status -> { + Person p = ops.findById(Person.class).one(person.id()); + ops.replaceById(Person.class).one(p.withFirstName("changed")); + }); + + Person fetched = ops.findById(Person.class).one(person.id()); + assertEquals("changed", fetched.getFirstname()); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path remove should succeed") + @Test + public void committedRemove() { + Person person = ops.insertById(Person.class).one(WalterWhite); + + RunResult rr = doInTransaction(status -> { + Person fetched = ops.findById(Person.class).one(person.id()); + ops.removeById(Person.class).oneEntity(fetched); + }); + + Person fetched = ops.findById(Person.class).one(person.id()); + assertNull(fetched); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path removeByQuery should succeed") + @Test + public void committedRemoveByQuery() { + Person person = ops.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + RunResult rr = doInTransaction(status -> { + List removed = ops.removeByQuery(Person.class).withConsistency(REQUEST_PLUS) + .matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all(); + assertEquals(1, removed.size()); + }); + + Person fetched = ops.findById(Person.class).one(person.id()); + assertNull(fetched); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path findByQuery should succeed") + @Test + public void committedFindByQuery() { + Person person = ops.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + RunResult rr = doInTransaction(status -> { + List found = ops.findByQuery(Person.class).withConsistency(REQUEST_PLUS) + .matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all(); + assertEquals(1, found.size()); + }); + + assertEquals(1, rr.attempts); + } + + @DisplayName("Basic test of doing an insert then rolling back") + @Test + public void rollbackInsert() { + AtomicInteger attempts = new AtomicInteger(); + + assertThrowsWithCause(() -> doInTransaction(status -> { + attempts.incrementAndGet(); + Person person = ops.insertById(Person.class).one(WalterWhite); + throw new SimulateFailureException(); + }), TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person fetched = ops.findById(Person.class).one(WalterWhite.id()); + assertNull(fetched); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a replace then rolling back") + @Test + public void rollbackReplace() { + AtomicInteger attempts = new AtomicInteger(); + Person person = ops.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> doInTransaction(status -> { + attempts.incrementAndGet(); + Person p = ops.findById(Person.class).one(person.id()); + p.setFirstname("changed"); + ops.replaceById(Person.class).one(p); + throw new SimulateFailureException(); + }), TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person fetched = ops.findById(Person.class).one(person.id()); + assertEquals(person.getFirstname(), fetched.getFirstname()); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a remove then rolling back") + @Test + public void rollbackRemove() { + AtomicInteger attempts = new AtomicInteger(); + Person person = ops.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> doInTransaction(status -> { + attempts.incrementAndGet(); + Person p = ops.findById(Person.class).one(person.id()); + ops.removeById(Person.class).oneEntity(p); + throw new SimulateFailureException(); + }), TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person fetched = ops.findById(Person.class).one(person.id()); + assertNotNull(fetched); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a removeByQuery then rolling back") + @Test + public void rollbackRemoveByQuery() { + AtomicInteger attempts = new AtomicInteger(); + Person person = ops.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + assertThrowsWithCause(() -> doInTransaction(status -> { + attempts.incrementAndGet(); + ops.removeByQuery(Person.class).withConsistency(REQUEST_PLUS) + .matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all(); + throw new SimulateFailureException(); + }), TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + Person fetched = ops.findById(Person.class).one(person.id()); + assertNotNull(fetched); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a findByQuery then rolling back") + @Test + public void rollbackFindByQuery() { + AtomicInteger attempts = new AtomicInteger(); + Person person = ops.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + assertThrowsWithCause(() -> doInTransaction(status -> { + attempts.incrementAndGet(); + ops.findByQuery(Person.class).withConsistency(REQUEST_PLUS) + .matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all(); + throw new SimulateFailureException(); + }), TransactionSystemUnambiguousException.class, SimulateFailureException.class); + + assertEquals(1, attempts.get()); + } + + @DisplayName("Entity must have CAS field during replace") + @Test + public void replaceEntityWithoutCas() { + PersonWithoutVersion person = ops.insertById(PersonWithoutVersion.class) + .one(new PersonWithoutVersion(UUID.randomUUID(), "Walter", "White")); + assertThrowsWithCause(() -> { + doInTransaction(status -> { + PersonWithoutVersion fetched = ops.findById(PersonWithoutVersion.class).one(person.id()); + ops.replaceById(PersonWithoutVersion.class).one(fetched); + }); + }, TransactionSystemUnambiguousException.class, IllegalArgumentException.class); + + } + + @DisplayName("Entity must have non-zero CAS during replace") + @Test + public void replaceEntityWithCasZero() { + Person person = ops.insertById(Person.class).one(WalterWhite); + + // switchedPerson here will have CAS=0, which will fail + Person switchedPerson = new Person(person.getId(), "Dave", "Reynolds"); + + assertThrowsWithCause(() -> doInTransaction(status -> { + ops.replaceById(Person.class).one(switchedPerson); + }), TransactionSystemUnambiguousException.class, IllegalArgumentException.class); + } + + @DisplayName("Entity must have CAS field during remove") + @Test + public void removeEntityWithoutCas() { + PersonWithoutVersion person = ops.insertById(PersonWithoutVersion.class) + .one(new PersonWithoutVersion(UUID.randomUUID(), "Walter", "White")); + assertThrowsWithCause(() -> doInTransaction(status -> { + PersonWithoutVersion fetched = ops.findById(PersonWithoutVersion.class).one(person.id()); + ops.removeById(PersonWithoutVersion.class).oneEntity(fetched); + }), TransactionSystemUnambiguousException.class, IllegalArgumentException.class); + } + + @DisplayName("removeById().one(id) isn't allowed in transactions, since we don't have the CAS") + @Test + public void removeEntityById() { + Person person = ops.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> doInTransaction(status -> { + Person p = ops.findById(Person.class).one(person.id()); + ops.removeById(Person.class).one(p.id()); + }), TransactionSystemUnambiguousException.class, IllegalArgumentException.class); + } + + @DisplayName("setRollbackOnly should cause a rollback") + @Test + public void setRollbackOnly() { + + assertThrowsWithCause(() -> doInTransaction(status -> { + status.setRollbackOnly(); + Person person = ops.insertById(Person.class).one(WalterWhite); + }), TransactionSystemUnambiguousException.class); + + Person fetched = ops.findById(Person.class).one(WalterWhite.id()); + assertNull(fetched); + } + + @DisplayName("Setting an unsupported isolation level should fail") + @Test + public void unsupportedIsolationLevel() { + template.setIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE); + + assertThrowsWithCause(() -> doInTransaction(status -> {}), IllegalArgumentException.class); + } + + @DisplayName("Setting PROPAGATION_MANDATORY should fail, as not in a transaction") + @Test + public void propagationMandatoryOutsideTransaction() { + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_MANDATORY); + + assertThrowsWithCause(() -> doInTransaction(status -> {}), IllegalTransactionStateException.class); + } + + @Test + public void nestedTransactionTemplates() { + TransactionTemplate template2 = new TransactionTemplate(transactionManager); + template2.setPropagationBehavior(TransactionDefinition.PROPAGATION_MANDATORY); + + template.executeWithoutResult(status -> { + template2.executeWithoutResult(status2 -> { + Person person = ops.insertById(Person.class).one(WalterWhite); + }); + }); + + Person fetched = ops.findById(Person.class).one(WalterWhite.id()); + assertEquals("Walter", fetched.getFirstname()); + } + +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/TransactionsConfig.java b/src/test/java/org/springframework/data/couchbase/transactions/TransactionsConfig.java new file mode 100644 index 000000000..268d6a5ba --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/TransactionsConfig.java @@ -0,0 +1,68 @@ +/* + * Copyright 2022 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.transactions; + +import java.time.Duration; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.couchbase.config.AbstractCouchbaseConfiguration; +import org.springframework.data.couchbase.repository.config.EnableCouchbaseRepositories; +import org.springframework.data.couchbase.repository.config.EnableReactiveCouchbaseRepositories; +import org.springframework.data.couchbase.util.ClusterAwareIntegrationTests; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import com.couchbase.client.java.env.ClusterEnvironment; + +/** + * For testing transactions + * + * @author Michael Reiche + */ +@Configuration +@EnableCouchbaseRepositories("org.springframework.data.couchbase") +@EnableReactiveCouchbaseRepositories("org.springframework.data.couchbase") +@EnableTransactionManagement +public class TransactionsConfig extends AbstractCouchbaseConfiguration { + + @Override + public String getConnectionString() { + return ClusterAwareIntegrationTests.connectionString(); + } + + @Override + public String getUserName() { + return ClusterAwareIntegrationTests.config().adminUsername(); + } + + @Override + public String getPassword() { + return ClusterAwareIntegrationTests.config().adminPassword(); + } + + @Override + public String getBucketName() { + return ClusterAwareIntegrationTests.bucketName(); + } + + @Override + public void configureEnvironment(ClusterEnvironment.Builder builder) { + // twenty minutes for debugging in the debugger. + builder.transactionsConfig( + com.couchbase.client.java.transactions.config.TransactionsConfig.builder().timeout(Duration.ofMinutes(20))); + } + +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKReactiveTransactionsNonAllowableOperationsIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKReactiveTransactionsNonAllowableOperationsIntegrationTests.java new file mode 100644 index 000000000..881dd4ef8 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKReactiveTransactionsNonAllowableOperationsIntegrationTests.java @@ -0,0 +1,134 @@ +/* + * Copyright 2022 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.transactions.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import reactor.core.publisher.Mono; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.ReactiveCouchbaseOperations; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.transactions.TransactionsConfig; +import org.springframework.data.couchbase.transactions.util.TransactionTestUtil; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import com.couchbase.client.java.transactions.error.TransactionFailedException; + +/** + * Tests for regular reactive SDK transactions, where Spring operations that aren't supported in a transaction are being + * used. They should be prevented at runtime. + * + * @author Graham Pople + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(classes = { TransactionsConfig.class, + SDKReactiveTransactionsNonAllowableOperationsIntegrationTests.PersonService.class }) +public class SDKReactiveTransactionsNonAllowableOperationsIntegrationTests extends JavaIntegrationTests { + + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired PersonService personService; + + Person WalterWhite; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + @BeforeEach + public void beforeEachTest() { + WalterWhite = new Person("Walter", "White"); + TransactionTestUtil.assertNotInTransaction(); + } + + void test(Function> r) { + AtomicInteger tryCount = new AtomicInteger(0); + + assertThrowsWithCause(() -> { + couchbaseClientFactory.getCluster().reactive().transactions().run(ignored -> { + return personService.doInService(tryCount, (ops) -> { + return r.apply(ops); + }); + }).block(); + }, TransactionFailedException.class, IllegalArgumentException.class); + + assertEquals(1, tryCount.get()); + } + + @DisplayName("Using existsById() in a transaction is rejected at runtime") + @Test + public void existsById() { + test((ops) -> { + return ops.existsById(Person.class).one(WalterWhite.id()); + }); + } + + @DisplayName("Using findByAnalytics() in a transaction is rejected at runtime") + @Test + public void findByAnalytics() { + test((ops) -> { + return ops.findByAnalytics(Person.class).one(); + }); + } + + @DisplayName("Using findFromReplicasById() in a transaction is rejected at runtime") + @Test + public void findFromReplicasById() { + test((ops) -> { + return ops.findFromReplicasById(Person.class).any(WalterWhite.id()); + }); + } + + @DisplayName("Using upsertById() in a transaction is rejected at runtime") + @Test + public void upsertById() { + test((ops) -> { + return ops.upsertById(Person.class).one(WalterWhite); + }); + } + + // This is intentionally not a @Transactional service + @Service + @Component + static class PersonService { + final ReactiveCouchbaseOperations personOperations; + + public PersonService(ReactiveCouchbaseOperations ops) { + personOperations = ops; + } + + public Mono doInService(AtomicInteger tryCount, Function> callback) { + tryCount.incrementAndGet(); + return callback.apply(personOperations); + } + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKReactiveTransactionsPersonIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKReactiveTransactionsPersonIntegrationTests.java new file mode 100644 index 000000000..3f38ba720 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKReactiveTransactionsPersonIntegrationTests.java @@ -0,0 +1,273 @@ +/* + * Copyright 2022 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.transactions.sdk; + +import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.springframework.data.couchbase.transactions.ReplaceLoopThread.updateOutOfTransaction; + +import reactor.core.publisher.Mono; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; +import org.springframework.data.couchbase.core.RemoveResult; +import org.springframework.data.couchbase.core.query.Query; +import org.springframework.data.couchbase.core.query.QueryCriteria; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.domain.PersonRepository; +import org.springframework.data.couchbase.domain.ReactivePersonRepository; +import org.springframework.data.couchbase.transactions.ReplaceLoopThread; +import org.springframework.data.couchbase.transactions.SimulateFailureException; +import org.springframework.data.couchbase.transactions.TransactionsConfig; +import org.springframework.data.couchbase.transactions.util.TransactionTestUtil; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import com.couchbase.client.java.transactions.TransactionResult; +import com.couchbase.client.java.transactions.error.TransactionFailedException; + +/** + * Tests for ReactiveTransactionsWrapper, moved from CouchbasePersonTransactionIntegrationTests. Now + * ReactiveTransactionsWrapper is removed, these are testing the same operations inside a regular SDK transaction. + * + * @author Graham Pople + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(TransactionsConfig.class) +public class SDKReactiveTransactionsPersonIntegrationTests extends JavaIntegrationTests { + // intellij flags "Could not autowire" when config classes are specified with classes={...}. But they are populated. + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired ReactivePersonRepository rxRepo; + @Autowired PersonRepository repo; + @Autowired CouchbaseTemplate cbTmpl; + @Autowired ReactiveCouchbaseTemplate rxCBTmpl; + @Autowired CouchbaseTemplate operations; + + String sName = "_default"; + String cName = "_default"; + + Person WalterWhite; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + @AfterAll + public static void afterAll() { + callSuperAfterAll(new Object() {}); + } + + @BeforeEach + public void beforeEachTest() { + WalterWhite = new Person("Walter", "White"); + TransactionTestUtil.assertNotInTransaction(); + List rp0 = operations.removeByQuery(Person.class).withConsistency(REQUEST_PLUS).all(); + List rp1 = operations.removeByQuery(Person.class).withConsistency(REQUEST_PLUS).inScope(sName) + .inCollection(cName).all(); + + List p0 = operations.findByQuery(Person.class).withConsistency(REQUEST_PLUS).all(); + List p1 = operations.findByQuery(Person.class).withConsistency(REQUEST_PLUS).inScope(sName) + .inCollection(cName).all(); + } + + @AfterEach + public void afterEachTest() { + TransactionTestUtil.assertNotInTransaction(); + } + + @Test + // need to fix this to make it deliberately have the CasMismatch by synchronization. + // And to *not* do any out-of-tx updates after the tx update has succeeded. + // And to have the tx update to a different name than the out-of-tx update + public void wrapperReplaceWithCasConflictResolvedViaRetryReactive() { + AtomicInteger tryCount = new AtomicInteger(); + Person person = cbTmpl.insertById(Person.class).one(WalterWhite); + Mono result = couchbaseClientFactory.getCluster().reactive().transactions() + .run(ctx -> rxCBTmpl.findById(Person.class).one(person.id()) // + .map((pp) -> updateOutOfTransaction(cbTmpl, pp, tryCount.incrementAndGet())) + .flatMap(ppp -> rxCBTmpl.replaceById(Person.class).one(ppp.withFirstName("Dave")))); + TransactionResult txResult = result.block(); + + Person pFound = cbTmpl.findById(Person.class).one(person.id()); + System.err.println("pFound " + pFound); + assertEquals(2, tryCount.get(), "should have been two tries. tries: " + tryCount.get()); + assertEquals("Dave", pFound.getFirstname(), "should have been changed"); + + } + + @DisplayName("Forcing CAS mismatch causes a transaction retry") + @Test + public void casMismatchCausesRetry() { + Person person = operations.insertById(Person.class).one(WalterWhite); + AtomicInteger attempts = new AtomicInteger(); + + couchbaseClientFactory.getCluster().reactive().transactions() + .run(ctx -> rxCBTmpl.findById(Person.class).one(person.id()).flatMap(fetched -> { + ReplaceLoopThread.updateOutOfTransaction(cbTmpl, fetched, attempts.incrementAndGet()); + return rxCBTmpl.replaceById(Person.class).one(fetched.withFirstName("Changed by transaction")); + })).block(); + + Person fetched = operations.findById(Person.class).one(person.id()); + assertEquals("Changed by transaction", fetched.getFirstname()); + assertEquals(2, attempts.get()); + } + + @Test + public void replacePersonCBTransactionsRxTmplRollback() { + String newName = "Walt"; + Person person = cbTmpl.insertById(Person.class).one(WalterWhite); + Mono result = couchbaseClientFactory.getCluster().reactive().transactions().run(ctx -> { // + return rxCBTmpl.findById(Person.class).one(person.id()) // + .flatMap(pp -> rxCBTmpl.replaceById(Person.class).one(pp.withFirstName(newName))).then(Mono.empty()); + }); + result.block(); + Person pFound = cbTmpl.findById(Person.class).one(person.id()); + System.err.println(pFound); + assertEquals(newName, pFound.getFirstname()); + } + + @Test + public void deletePersonCBTransactionsRxTmpl() { + Person person = cbTmpl.insertById(Person.class).inCollection(cName).one(WalterWhite); + Mono result = couchbaseClientFactory.getCluster().reactive().transactions().run(ctx -> { // get + // the + // ctx + return rxCBTmpl.removeById(Person.class).inCollection(cName).oneEntity(person).then(); + }); + result.block(); + Person pFound = cbTmpl.findById(Person.class).inCollection(cName).one(person.id()); + assertNull(pFound, "Should not have found " + pFound); + } + + @Test // ok + public void deletePersonCBTransactionsRxTmplFail() { + Person person = cbTmpl.insertById(Person.class).inCollection(cName).one(WalterWhite); + Mono result = couchbaseClientFactory.getCluster().reactive().transactions().run(ctx -> { // get + // the + // ctx + return rxCBTmpl.removeById(Person.class).inCollection(cName).oneEntity(person) + .then(rxCBTmpl.removeById(Person.class).inCollection(cName).oneEntity(person)); + }); + assertThrowsWithCause(result::block, TransactionFailedException.class, DataRetrievalFailureException.class); + Person pFound = cbTmpl.findById(Person.class).inCollection(cName).one(person.id()); + assertEquals(pFound, person, "Should have found " + person); + } + + @Test + public void deletePersonCBTransactionsRxRepo() { + Person person = repo.withCollection(cName).save(WalterWhite); + Mono result = couchbaseClientFactory.getCluster().reactive().transactions().run(ctx -> { // get + // the + // ctx + return rxRepo.withCollection(cName).delete(person).then(); + }); + result.block(); + Person pFound = cbTmpl.findById(Person.class).inCollection(cName).one(person.id()); + assertNull(pFound, "Should not have found " + pFound); + } + + @Test + public void deletePersonCBTransactionsRxRepoFail() { + Person person = repo.withCollection(cName).save(WalterWhite); + Mono result = couchbaseClientFactory.getCluster().reactive().transactions().run(ctx -> { // get + // the + // ctx + return rxRepo.withCollection(cName).findById(person.id()) + .flatMap(pp -> rxRepo.withCollection(cName).delete(pp).then(rxRepo.withCollection(cName).delete(pp))).then(); + }); + assertThrowsWithCause(result::block, TransactionFailedException.class, DataRetrievalFailureException.class); + Person pFound = cbTmpl.findById(Person.class).inCollection(cName).one(person.id()); + assertEquals(pFound, person, "Should have found " + person + " instead of " + pFound); + } + + @Test + public void findPersonCBTransactions() { + Person person = cbTmpl.insertById(Person.class).inScope(sName).inCollection(cName).one(WalterWhite); + List docs = new LinkedList<>(); + Query q = Query.query(QueryCriteria.where("meta().id").eq(person.getId())); + Mono result = couchbaseClientFactory.getCluster().reactive().transactions().run(ctx -> { + return rxCBTmpl.findByQuery(Person.class).withConsistency(REQUEST_PLUS).inScope(sName).inCollection(cName) + .matching(q).one().doOnSuccess(doc -> { + System.err.println("doc: " + doc); + docs.add(doc); + }); + }); + result.block(); + assertFalse(docs.isEmpty(), "Should have found " + person); + for (Object o : docs) { + assertEquals(o, person, "Should have found " + person + " instead of " + o); + } + } + + @Test + public void insertPersonRbCBTransactions() { + Person person = WalterWhite; + Mono result = couchbaseClientFactory.getCluster().reactive().transactions() + .run(ctx -> rxCBTmpl.insertById(Person.class).inScope(sName).inCollection(cName).one(person) + . flatMap(it -> Mono.error(new SimulateFailureException()))); + assertThrowsWithCause(() -> result.block(), TransactionFailedException.class, SimulateFailureException.class); + Person pFound = cbTmpl.findById(Person.class).inCollection(cName).one(person.id()); + assertNull(pFound, "Should not have found " + pFound); + } + + @Test + public void replacePersonRbCBTransactions() { + Person person = cbTmpl.insertById(Person.class).inScope(sName).inCollection(cName).one(WalterWhite); + Mono result = couchbaseClientFactory.getCluster().reactive().transactions().run(ctx -> // + rxCBTmpl.findById(Person.class).inScope(sName).inCollection(cName).one(person.id()) // + .flatMap(pFound -> rxCBTmpl.replaceById(Person.class).inScope(sName).inCollection(cName) + .one(pFound.withFirstName("Walt"))) + . flatMap(it -> Mono.error(new SimulateFailureException()))); + assertThrowsWithCause(() -> result.block(), TransactionFailedException.class, SimulateFailureException.class); + Person pFound = cbTmpl.findById(Person.class).inScope(sName).inCollection(cName).one(person.id()); + assertEquals(person, pFound, "Should have found " + person + " instead of " + pFound); + } + + @Test + public void findPersonSpringTransactions() { + Person person = cbTmpl.insertById(Person.class).inScope(sName).inCollection(cName).one(WalterWhite); + List docs = new LinkedList<>(); + Query q = Query.query(QueryCriteria.where("meta().id").eq(person.getId())); + Mono result = couchbaseClientFactory.getCluster().reactive().transactions().run(ctx -> rxCBTmpl + .findByQuery(Person.class).inScope(sName).inCollection(cName).matching(q).one().doOnSuccess(r -> docs.add(r))); + result.block(); + assertFalse(docs.isEmpty(), "Should have found " + person); + for (Object o : docs) { + assertEquals(o, person, "Should have found " + person); + } + } + +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKReactiveTransactionsTemplateIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKReactiveTransactionsTemplateIntegrationTests.java new file mode 100644 index 000000000..7b7842bff --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKReactiveTransactionsTemplateIntegrationTests.java @@ -0,0 +1,357 @@ +/* + * Copyright 2022 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.transactions.sdk; + +import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.springframework.data.couchbase.transactions.util.TransactionTestUtil.assertNotInTransaction; + +import reactor.core.publisher.Mono; +import reactor.util.annotation.Nullable; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; +import org.springframework.data.couchbase.core.TransactionalSupport; +import org.springframework.data.couchbase.core.query.QueryCriteria; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.domain.PersonWithoutVersion; +import org.springframework.data.couchbase.transactions.ReplaceLoopThread; +import org.springframework.data.couchbase.transactions.SimulateFailureException; +import org.springframework.data.couchbase.transactions.TransactionsConfig; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import com.couchbase.client.java.transactions.ReactiveTransactionAttemptContext; +import com.couchbase.client.java.transactions.TransactionResult; +import com.couchbase.client.java.transactions.config.TransactionOptions; +import com.couchbase.client.java.transactions.error.TransactionFailedException; + +/** + * Tests using template methods (findById etc.) inside a regular reactive SDK transaction. + * + * @author Graham Pople + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(TransactionsConfig.class) +public class SDKReactiveTransactionsTemplateIntegrationTests extends JavaIntegrationTests { + // intellij flags "Could not autowire" when config classes are specified with classes={...}. But they are populated. + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired ReactiveCouchbaseTemplate ops; + @Autowired CouchbaseTemplate blocking; + + Person WalterWhite; + + @BeforeEach + public void beforeEachTest() { + WalterWhite = new Person("Walter", "White"); + } + + @AfterEach + public void afterEachTest() { + assertNotInTransaction(); + } + + static class RunResult { + public final TransactionResult result; + public final int attempts; + + public RunResult(TransactionResult result, int attempts) { + this.result = result; + this.attempts = attempts; + } + } + + private RunResult doInTransaction(Function> lambda) { + return doInTransaction(lambda, null); + } + + private RunResult doInTransaction(Function> lambda, + @Nullable TransactionOptions options) { + AtomicInteger attempts = new AtomicInteger(); + + TransactionResult result = couchbaseClientFactory.getCluster().reactive().transactions().run(ctx -> { + return TransactionalSupport.checkForTransactionInThreadLocalStorage().then(Mono.defer(() -> { + attempts.incrementAndGet(); + return lambda.apply(ctx); + })); + }, options).block(); + + assertNotInTransaction(); + + return new RunResult(result, attempts.get()); + } + + @DisplayName("A basic golden path insert should succeed") + @Test + public void committedInsert() { + + RunResult rr = doInTransaction(ctx -> { + return Mono.defer(() -> { + return ops.insertById(Person.class).one(WalterWhite); + }); + }); + + Person fetched = blocking.findById(Person.class).one(WalterWhite.id()); + assertEquals("Walter", fetched.getFirstname()); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path replace should succeed") + @Test + public void committedReplace() { + + Person initial = blocking.insertById(Person.class).one(WalterWhite); + + RunResult rr = doInTransaction(ctx -> { + return ops.findById(Person.class).one(WalterWhite.id()).flatMap(person -> { + person.setFirstname("changed"); + return ops.replaceById(Person.class).one(person); + }); + }); + + Person fetched = blocking.findById(Person.class).one(initial.id()); + assertEquals("changed", fetched.getFirstname()); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path remove should succeed") + @Test + public void committedRemove() { + Person person = blocking.insertById(Person.class).one(WalterWhite); + + RunResult rr = doInTransaction(ctx -> { + return ops.findById(Person.class).one(person.id()) + .flatMap(fetched -> ops.removeById(Person.class).oneEntity(fetched)); + }); + + Person fetched = blocking.findById(Person.class).one(person.id()); + assertNull(fetched); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path removeByQuery should succeed") + @Test + public void committedRemoveByQuery() { + + Person person = blocking.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + RunResult rr = doInTransaction(ctx -> { + return ops.removeByQuery(Person.class).withConsistency(REQUEST_PLUS) + .matching(QueryCriteria.where("firstname").eq(WalterWhite.id())).all().then(); + }); + + Person fetched = blocking.findById(Person.class).one(person.id()); + assertNull(fetched); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path findByQuery should succeed") + @Test + public void committedFindByQuery() { + Person person = blocking.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + RunResult rr = doInTransaction(ctx -> { + return ops.findByQuery(Person.class).matching(QueryCriteria.where("firstname").eq(WalterWhite.getFirstname())) + .all().then(); + }); + + assertEquals(1, rr.attempts); + } + + @DisplayName("Basic test of doing an insert then rolling back") + @Test + public void rollbackInsert() { + AtomicInteger attempts = new AtomicInteger(); + + assertThrowsWithCause(() -> doInTransaction(ctx -> { + attempts.incrementAndGet(); + return ops.insertById(Person.class).one(WalterWhite).map((p) -> throwSimulateFailureException(p)); + }), TransactionFailedException.class, SimulateFailureException.class); + + Person fetched = blocking.findById(Person.class).one(WalterWhite.id()); + assertNull(fetched); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a replace then rolling back") + @Test + public void rollbackReplace() { + AtomicInteger attempts = new AtomicInteger(); + Person person = blocking.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> doInTransaction(ctx -> { + attempts.incrementAndGet(); + return ops.findById(Person.class).one(person.id()) // + .flatMap(p -> ops.replaceById(Person.class).one(p.withFirstName("changed"))) // + .map(p -> throwSimulateFailureException(p)); + }), TransactionFailedException.class, SimulateFailureException.class); + + Person fetched = blocking.findById(Person.class).one(person.id()); + assertEquals("Walter", fetched.getFirstname()); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a remove then rolling back") + @Test + public void rollbackRemove() { + AtomicInteger attempts = new AtomicInteger(); + Person person = blocking.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> doInTransaction(ctx -> { + attempts.incrementAndGet(); + return ops.findById(Person.class).one(person.id()).flatMap(p -> ops.removeById(Person.class).oneEntity(p)) // + .doOnSuccess(p -> throwSimulateFailureException(p)); // remove has no result + }), TransactionFailedException.class, SimulateFailureException.class); + + Person fetched = blocking.findById(Person.class).one(person.id()); + assertNotNull(fetched); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a removeByQuery then rolling back") + @Test + public void rollbackRemoveByQuery() { + AtomicInteger attempts = new AtomicInteger(); + Person person = blocking.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> doInTransaction(ctx -> { + attempts.incrementAndGet(); + return ops.removeByQuery(Person.class).matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all() + .elementAt(0).map(p -> throwSimulateFailureException(p)); + }), TransactionFailedException.class, SimulateFailureException.class); + + Person fetched = blocking.findById(Person.class).one(person.id()); + assertNotNull(fetched); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a findByQuery then rolling back") + @Test + public void rollbackFindByQuery() { + AtomicInteger attempts = new AtomicInteger(); + Person person = blocking.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> doInTransaction(ctx -> { + attempts.incrementAndGet(); + return ops.findByQuery(Person.class).matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all() + .elementAt(0).map(p -> throwSimulateFailureException(p)); + }), TransactionFailedException.class, SimulateFailureException.class); + + assertEquals(1, attempts.get()); + } + + @DisplayName("Create a Person outside a @Transactional block, modify it, and then replace that person in the @Transactional. The transaction will retry until timeout.") + @Test + public void replacePerson() { + Person person = blocking.insertById(Person.class).one(WalterWhite); + + Person refetched = blocking.findById(Person.class).one(person.id()); + refetched = blocking.replaceById(Person.class).one(refetched); // new cas + + assertNotEquals(person.getVersion(), refetched.getVersion()); + + assertThrowsWithCause(() -> doInTransaction(ctx -> ops.replaceById(Person.class).one(person), // old cas + TransactionOptions.transactionOptions().timeout(Duration.ofSeconds(2))), TransactionFailedException.class, + Exception.class); + + } + + @DisplayName("Entity must have CAS field during replace") + @Test + public void replaceEntityWithoutCas() { + ; + PersonWithoutVersion person = blocking.insertById(PersonWithoutVersion.class) + .one(new PersonWithoutVersion("Walter", "White")); + assertThrowsWithCause( + () -> doInTransaction(ctx -> ops.findById(PersonWithoutVersion.class).one(person.id()) + .flatMap(fetched -> ops.replaceById(PersonWithoutVersion.class).one(fetched))), + TransactionFailedException.class, IllegalArgumentException.class); + + } + + @DisplayName("Entity must have non-zero CAS during replace") + @Test + public void replaceEntityWithCasZero() { + Person person = blocking.insertById(Person.class).one(WalterWhite); + + // switchedPerson here will have CAS=0, which will fail + Person switchedPerson = new Person(person.getId(), "Dave", "Reynolds"); + + assertThrowsWithCause(() -> doInTransaction(ctx -> { + return ops.replaceById(Person.class).one(switchedPerson); + }), TransactionFailedException.class, IllegalArgumentException.class); + + } + + @DisplayName("Entity must have CAS field during remove") + @Test + public void removeEntityWithoutCas() { + PersonWithoutVersion person = blocking.insertById(PersonWithoutVersion.class) + .one(new PersonWithoutVersion("Walter", "White")); + assertThrowsWithCause(() -> doInTransaction(ctx -> { + return ops.findById(PersonWithoutVersion.class).one(person.id()) + .flatMap(fetched -> ops.removeById(PersonWithoutVersion.class).oneEntity(fetched)); + }), TransactionFailedException.class, IllegalArgumentException.class); + + } + + @DisplayName("removeById().one(id) isn't allowed in transactions, since we don't have the CAS") + @Test + public void removeEntityById() { + Person person = blocking.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> doInTransaction(ctx -> { + return ops.findById(Person.class).one(person.id()).flatMap(p -> ops.removeById(Person.class).one(p.id())); + }), TransactionFailedException.class, IllegalArgumentException.class); + + } + + @DisplayName("Forcing CAS mismatch causes a transaction retry") + @Test + public void casMismatchCausesRetry() { + Person person = blocking.insertById(Person.class).one(WalterWhite); + AtomicInteger attempts = new AtomicInteger(); + + doInTransaction(ctx -> { + return ops.findById(Person.class).one(person.id()).flatMap(fetched -> Mono.defer(() -> { + ReplaceLoopThread.updateOutOfTransaction(blocking, person.withFirstName("Changed externally"), + attempts.incrementAndGet()); + return ops.replaceById(Person.class).one(fetched.withFirstName("Changed by transaction")); + })); + }); + + Person fetched = blocking.findById(Person.class).one(person.id()); + assertEquals("Changed by transaction", fetched.getFirstname()); + assertEquals(2, attempts.get()); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKTransactionsNonAllowableOperationsIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKTransactionsNonAllowableOperationsIntegrationTests.java new file mode 100644 index 000000000..c1a91a211 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKTransactionsNonAllowableOperationsIntegrationTests.java @@ -0,0 +1,134 @@ +/* + * Copyright 2022 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.transactions.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseOperations; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.transactions.TransactionsConfig; +import org.springframework.data.couchbase.transactions.util.TransactionTestUtil; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import com.couchbase.client.java.transactions.error.TransactionFailedException; + +/** + * Tests for regular SDK transactions, where Spring operations that aren't supported in a transaction are being used. + * They should be prevented at runtime. + * + * @Graham Pople + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig( + classes = { TransactionsConfig.class, SDKTransactionsNonAllowableOperationsIntegrationTests.PersonService.class }) +public class SDKTransactionsNonAllowableOperationsIntegrationTests extends JavaIntegrationTests { + + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired PersonService personService; + + Person WalterWhite; + + @BeforeAll + public static void beforeAll() { + callSuperBeforeAll(new Object() {}); + } + + @BeforeEach + public void beforeEachTest() { + WalterWhite = new Person("Walter", "White"); + TransactionTestUtil.assertNotInTransaction(); + } + + void test(Consumer r) { + AtomicInteger tryCount = new AtomicInteger(0); + + assertThrowsWithCause(() -> { + couchbaseClientFactory.getCluster().transactions().run(ignored -> { + personService.doInService(tryCount, (ops) -> { + r.accept(ops); + return null; + }); + }); + }, TransactionFailedException.class, IllegalArgumentException.class); + + assertEquals(1, tryCount.get()); + } + + @DisplayName("Using existsById() in a transaction is rejected at runtime") + @Test + public void existsById() { + test((ops) -> { + ops.existsById(Person.class).one(WalterWhite.id()); + }); + } + + @DisplayName("Using findByAnalytics() in a transaction is rejected at runtime") + @Test + public void findByAnalytics() { + test((ops) -> { + ops.findByAnalytics(Person.class).one(); + }); + } + + @DisplayName("Using findFromReplicasById() in a transaction is rejected at runtime") + @Test + public void findFromReplicasById() { + test((ops) -> { + ops.findFromReplicasById(Person.class).any(WalterWhite.id()); + }); + } + + @DisplayName("Using upsertById() in a transaction is rejected at runtime") + @Test + public void upsertById() { + test((ops) -> { + ops.upsertById(Person.class).one(WalterWhite); + }); + } + + // This is intentionally not a @Transactional service + @Service + @Component + static class PersonService { + final CouchbaseOperations personOperations; + + public PersonService(CouchbaseOperations ops) { + personOperations = ops; + } + + public T doInService(AtomicInteger tryCount, Function callback) { + tryCount.incrementAndGet(); + return callback.apply(personOperations); + } + } +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKTransactionsTemplateIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKTransactionsTemplateIntegrationTests.java new file mode 100644 index 000000000..9c96c2a0c --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKTransactionsTemplateIntegrationTests.java @@ -0,0 +1,421 @@ +/* + * Copyright 2022 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.transactions.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.data.couchbase.transactions.util.TransactionTestUtil.assertInTransaction; +import static org.springframework.data.couchbase.transactions.util.TransactionTestUtil.assertNotInTransaction; + +import reactor.util.annotation.Nullable; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.RemoveResult; +import org.springframework.data.couchbase.core.query.QueryCriteria; +import org.springframework.data.couchbase.domain.Person; +import org.springframework.data.couchbase.domain.PersonWithoutVersion; +import org.springframework.data.couchbase.transaction.error.UncategorizedTransactionDataAccessException; +import org.springframework.data.couchbase.transactions.ReplaceLoopThread; +import org.springframework.data.couchbase.transactions.SimulateFailureException; +import org.springframework.data.couchbase.transactions.TransactionsConfig; +import org.springframework.data.couchbase.util.Capabilities; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.data.couchbase.util.JavaIntegrationTests; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import com.couchbase.client.core.error.transaction.PreviousOperationFailedException; +import com.couchbase.client.core.error.transaction.TransactionOperationFailedException; +import com.couchbase.client.java.json.JsonObject; +import com.couchbase.client.java.transactions.TransactionAttemptContext; +import com.couchbase.client.java.transactions.TransactionGetResult; +import com.couchbase.client.java.transactions.TransactionResult; +import com.couchbase.client.java.transactions.config.TransactionOptions; +import com.couchbase.client.java.transactions.error.TransactionFailedException; + +/** + * Tests for using template methods (findById etc.) inside a regular SDK transaction. + * + * @author Graham Pople + */ +@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(TransactionsConfig.class) +public class SDKTransactionsTemplateIntegrationTests extends JavaIntegrationTests { + // intellij flags "Could not autowire" when config classes are specified with classes={...}. But they are populated. + @Autowired CouchbaseClientFactory couchbaseClientFactory; + @Autowired CouchbaseTemplate ops; + + Person WalterWhite; + + @BeforeEach + public void beforeEachTest() { + WalterWhite = new Person("Walter", "White"); + assertNotInTransaction(); + } + + @AfterEach + public void afterEachTest() { + assertNotInTransaction(); + } + + static class RunResult { + public final TransactionResult result; + public final int attempts; + + public RunResult(TransactionResult result, int attempts) { + this.result = result; + this.attempts = attempts; + } + } + + private RunResult doInTransaction(Consumer lambda) { + return doInTransaction(lambda, null); + } + + private RunResult doInTransaction(Consumer lambda, @Nullable TransactionOptions options) { + AtomicInteger attempts = new AtomicInteger(); + + TransactionResult result = couchbaseClientFactory.getCluster().transactions().run(ctx -> { + assertInTransaction(); + attempts.incrementAndGet(); + lambda.accept(ctx); + }, options); + + assertNotInTransaction(); + + return new RunResult(result, attempts.get()); + } + + @DisplayName("A basic golden path insert should succeed") + @Test + public void committedInsert() { + + RunResult rr = doInTransaction(ctx -> { + ops.insertById(Person.class).one(WalterWhite); + }); + + Person fetched = ops.findById(Person.class).one(WalterWhite.id()); + assertEquals(WalterWhite.getFirstname(), fetched.getFirstname()); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path replace should succeed") + @Test + public void committedReplace() { + Person p = ops.insertById(Person.class).one(WalterWhite); + + RunResult rr = doInTransaction(ctx -> { + Person person = ops.findById(Person.class).one(p.id()); + person.setFirstname("changed"); + ops.replaceById(Person.class).one(person); + }); + + Person fetched = ops.findById(Person.class).one(p.id()); + assertEquals("changed", fetched.getFirstname()); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path remove should succeed") + @Test + public void committedRemove() { + Person person = ops.insertById(Person.class).one(WalterWhite); + + RunResult rr = doInTransaction(ctx -> { + Person fetched = ops.findById(Person.class).one(person.id()); + ops.removeById(Person.class).oneEntity(fetched); + }); + + Person fetched = ops.findById(Person.class).one(person.id()); + assertNull(fetched); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path removeByQuery should succeed") + @Test + public void committedRemoveByQuery() { + Person person = ops.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + RunResult rr = doInTransaction(ctx -> { + List removed = ops.removeByQuery(Person.class) + .matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all(); + assertEquals(1, removed.size()); + }); + + Person fetched = ops.findById(Person.class).one(person.id()); + assertNull(fetched); + assertEquals(1, rr.attempts); + } + + @DisplayName("A basic golden path findByQuery should succeed") + @Test + public void committedFindByQuery() { + Person person = ops.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + RunResult rr = doInTransaction(ctx -> { + List found = ops.findByQuery(Person.class) + .matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all(); + assertEquals(1, found.size()); + }); + + assertEquals(1, rr.attempts); + } + + @DisplayName("Basic test of doing an insert then rolling back") + @Test + public void rollbackInsert() { + AtomicInteger attempts = new AtomicInteger(); + + assertThrowsWithCause(() -> { + doInTransaction(ctx -> { + attempts.incrementAndGet(); + Person person = ops.insertById(Person.class).one(WalterWhite); + throw new SimulateFailureException(); + }); + }, TransactionFailedException.class, SimulateFailureException.class); + + Person fetched = ops.findById(Person.class).one(WalterWhite.id()); + assertNull(fetched); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a replace then rolling back") + @Test + public void rollbackReplace() { + AtomicInteger attempts = new AtomicInteger(); + Person person = ops.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> { + doInTransaction(ctx -> { + attempts.incrementAndGet(); + Person p = ops.findById(Person.class).one(person.id()); + p.setFirstname("changed"); + ops.replaceById(Person.class).one(p); + throw new SimulateFailureException(); + }); + }, TransactionFailedException.class, SimulateFailureException.class); + + Person fetched = ops.findById(Person.class).one(person.id()); + assertEquals(WalterWhite.getFirstname(), fetched.getFirstname()); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a remove then rolling back") + @Test + public void rollbackRemove() { + AtomicInteger attempts = new AtomicInteger(); + Person person = ops.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> { + doInTransaction(ctx -> { + attempts.incrementAndGet(); + Person p = ops.findById(Person.class).one(person.id()); + ops.removeById(Person.class).oneEntity(p); + throw new SimulateFailureException(); + }); + }, TransactionFailedException.class, SimulateFailureException.class); + + Person fetched = ops.findById(Person.class).one(person.getId().toString()); + assertNotNull(fetched); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a removeByQuery then rolling back") + @Test + public void rollbackRemoveByQuery() { + AtomicInteger attempts = new AtomicInteger(); + Person person = ops.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + assertThrowsWithCause(() -> { + doInTransaction(ctx -> { + attempts.incrementAndGet(); + ops.removeByQuery(Person.class).matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all(); + throw new SimulateFailureException(); + }); + }, TransactionFailedException.class, SimulateFailureException.class); + + Person fetched = ops.findById(Person.class).one(person.id()); + assertNotNull(fetched); + assertEquals(1, attempts.get()); + } + + @DisplayName("Basic test of doing a findByQuery then rolling back") + @Test + public void rollbackFindByQuery() { + AtomicInteger attempts = new AtomicInteger(); + Person person = ops.insertById(Person.class).one(WalterWhite.withIdFirstname()); + + assertThrowsWithCause(() -> { + doInTransaction(ctx -> { + attempts.incrementAndGet(); + ops.findByQuery(Person.class).matching(QueryCriteria.where("firstname").eq(person.getFirstname())).all(); + throw new SimulateFailureException(); + }); + }, TransactionFailedException.class, SimulateFailureException.class); + + assertEquals(1, attempts.get()); + } + + @DisplayName("Create a Person outside a @Transactional block, modify it, and then replace that person in the @Transactional. The transaction will retry until timeout.") + @Test + public void replacePerson() { + Person person = ops.insertById(Person.class).one(WalterWhite); + + Person refetched = ops.findById(Person.class).one(person.id()); + ops.replaceById(Person.class).one(refetched); + + assertNotEquals(person.getVersion(), refetched.getVersion()); + + assertThrowsWithCause(() -> { + doInTransaction(ctx -> { + ops.replaceById(Person.class).one(person); + }, TransactionOptions.transactionOptions().timeout(Duration.ofSeconds(2))); + }, TransactionFailedException.class); + + } + + @DisplayName("Entity must have CAS field during replace") + @Test + public void replaceEntityWithoutCas() { + PersonWithoutVersion person = ops.insertById(PersonWithoutVersion.class) + .one(new PersonWithoutVersion(UUID.randomUUID(), "Walter", "White")); + assertThrowsWithCause(() -> { + doInTransaction(ctx -> { + PersonWithoutVersion fetched = ops.findById(PersonWithoutVersion.class).one(person.id()); + ops.replaceById(PersonWithoutVersion.class).one(fetched); + }); + }, TransactionFailedException.class, IllegalArgumentException.class); + } + + @DisplayName("Entity must have non-zero CAS during replace") + @Test + public void replaceEntityWithCasZero() { + Person person = ops.insertById(Person.class).one(WalterWhite); + + // switchedPerson here will have CAS=0, which will fail + Person switchedPerson = new Person(person.getId(), "Dave", "Reynolds"); + + assertThrowsWithCause(() -> { + doInTransaction(ctx -> { + ops.replaceById(Person.class).one(switchedPerson); + }); + }, TransactionFailedException.class, IllegalArgumentException.class); + } + + @DisplayName("Entity must have CAS field during remove") + @Test + public void removeEntityWithoutCas() { + PersonWithoutVersion person = ops.insertById(PersonWithoutVersion.class) + .one(new PersonWithoutVersion(UUID.randomUUID(), "Walter", "White")); + assertThrowsWithCause(() -> { + doInTransaction(ctx -> { + PersonWithoutVersion fetched = ops.findById(PersonWithoutVersion.class).one(person.id()); + ops.removeById(PersonWithoutVersion.class).oneEntity(fetched); + }); + }, TransactionFailedException.class, IllegalArgumentException.class); + } + + @DisplayName("removeById().one(id) isn't allowed in transactions, since we don't have the CAS") + @Test + public void removeEntityById() { + Person person = ops.insertById(Person.class).one(WalterWhite); + + assertThrowsWithCause(() -> { + doInTransaction(ctx -> { + Person p = ops.findById(Person.class).one(person.id()); + ops.removeById(Person.class).one(p.getId().toString()); + }); + }, TransactionFailedException.class, IllegalArgumentException.class); + } + + @DisplayName("Forcing CAS mismatch causes a transaction retry") + @Test + public void casMismatchCausesRetry() { + Person person = ops.insertById(Person.class).one(WalterWhite); + AtomicInteger attempts = new AtomicInteger(); + + doInTransaction(ctx -> { + Person fetched = ops.findById(Person.class).one(person.getId().toString()); + ReplaceLoopThread.updateOutOfTransaction(ops, person.withFirstName("Changed externally"), + attempts.incrementAndGet()); + try { + ops.replaceById(Person.class).one(fetched.withFirstName("Changed by transaction")); + } catch (RuntimeException err) { + assertTrue(err instanceof UncategorizedTransactionDataAccessException); + } + + if (attempts.get() == 1) { + // Subsequent operations in this attempt should fast-fail + try { + ops.findById(Person.class).one(person.getId().toString()); + } catch (RuntimeException err) { + assertTrue(err instanceof UncategorizedTransactionDataAccessException); + assertTrue(err.getCause() instanceof PreviousOperationFailedException); + } + } + }); + + Person fetched = ops.findById(Person.class).one(person.getId().toString()); + assertEquals("Changed by transaction", fetched.getFirstname()); + assertEquals(2, attempts.get()); + } + + @DisplayName("Using the standard ctx.get(), ctx.replace() API works as expected") + @Test + public void casMismatchUsingRegularTransactionOperations() { + Person person = ops.insertById(Person.class).one(WalterWhite); + AtomicInteger attempts = new AtomicInteger(); + + doInTransaction(ctx -> { + TransactionGetResult gr = ctx.get(couchbaseClientFactory.getDefaultCollection(), WalterWhite.id()); + ReplaceLoopThread.updateOutOfTransaction(ops, person.withFirstName("Changed externally"), + attempts.incrementAndGet()); + try { + ctx.replace(gr, JsonObject.create()); + } catch (RuntimeException err) { + assertTrue(err instanceof TransactionOperationFailedException); + } + + if (attempts.get() == 1) { + // Subsequent operations in this attempt should fast-fail + try { + ctx.get(couchbaseClientFactory.getDefaultCollection(), WalterWhite.id()); + } catch (RuntimeException err) { + assertTrue(err instanceof TransactionOperationFailedException); + assertTrue(err.getCause() instanceof PreviousOperationFailedException); + } + } + }); + + assertEquals(2, attempts.get()); + } + +} diff --git a/src/test/java/org/springframework/data/couchbase/transactions/util/TransactionTestUtil.java b/src/test/java/org/springframework/data/couchbase/transactions/util/TransactionTestUtil.java new file mode 100644 index 000000000..83324cf53 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/transactions/util/TransactionTestUtil.java @@ -0,0 +1,38 @@ +/* + * Copyright 2022 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.transactions.util; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.springframework.data.couchbase.core.TransactionalSupport; + +/** + * Utility methods for transaction tests. + * + * @author Graham Pople + */ +public class TransactionTestUtil { + private TransactionTestUtil() {} + + public static void assertInTransaction() { + assertTrue(TransactionalSupport.checkForTransactionInThreadLocalStorage().block().isPresent()); + } + + public static void assertNotInTransaction() { + assertFalse(TransactionalSupport.checkForTransactionInThreadLocalStorage().block().isPresent()); + } +} diff --git a/src/test/java/org/springframework/data/couchbase/util/ClusterAwareIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/util/ClusterAwareIntegrationTests.java index 1399e0e3a..2d1e6c9a5 100644 --- a/src/test/java/org/springframework/data/couchbase/util/ClusterAwareIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/util/ClusterAwareIntegrationTests.java @@ -46,10 +46,14 @@ import com.couchbase.client.java.env.ClusterEnvironment; import com.couchbase.client.java.manager.query.CreatePrimaryQueryIndexOptions; import com.couchbase.client.java.manager.query.CreateQueryIndexOptions; +import com.couchbase.client.java.transactions.config.TransactionsCleanupConfig; +import com.couchbase.client.java.transactions.config.TransactionsConfig; /** * Parent class which drives all dynamic integration tests based on the configured cluster setup. * + * @author Michael Reiche + * * @since 2.0.0 */ @ExtendWith(ClusterInvocationProvider.class) @@ -61,8 +65,13 @@ public abstract class ClusterAwareIntegrationTests { @BeforeAll static void setup(TestClusterConfig config) { testClusterConfig = config; - try (CouchbaseClientFactory couchbaseClientFactory = new SimpleCouchbaseClientFactory(connectionString(), - authenticator(), bucketName(), null, environment().build())) { + // Disabling cleanupLostAttempts to simplify output during development + ClusterEnvironment env = ClusterEnvironment.builder() + .transactionsConfig(TransactionsConfig.cleanupConfig(TransactionsCleanupConfig.cleanupLostAttempts(false))) + .build(); + String connectString = connectionString(); + try (CouchbaseClientFactory couchbaseClientFactory = new SimpleCouchbaseClientFactory(connectString, + authenticator(), bucketName(), null, env)) { couchbaseClientFactory.getCluster().queryIndexes().createPrimaryIndex(bucketName(), CreatePrimaryQueryIndexOptions .createPrimaryQueryIndexOptions().ignoreIfExists(true).timeout(Duration.ofSeconds(300))); // this is for the N1qlJoin test diff --git a/src/test/java/org/springframework/data/couchbase/util/JavaIntegrationTests.java b/src/test/java/org/springframework/data/couchbase/util/JavaIntegrationTests.java index b63a7493c..89b313202 100644 --- a/src/test/java/org/springframework/data/couchbase/util/JavaIntegrationTests.java +++ b/src/test/java/org/springframework/data/couchbase/util/JavaIntegrationTests.java @@ -27,12 +27,6 @@ import static org.springframework.data.couchbase.config.BeanNames.REACTIVE_COUCHBASE_TEMPLATE; import static org.springframework.data.couchbase.util.Util.waitUntilCondition; -import okhttp3.Credentials; -import okhttp3.FormBody; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; - import java.io.IOException; import java.time.Duration; import java.util.Collections; @@ -47,11 +41,17 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.function.Executable; +import org.junit.platform.commons.util.UnrecoverableExceptions; +import org.opentest4j.AssertionFailedError; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.SimpleCouchbaseClientFactory; import org.springframework.data.couchbase.core.CouchbaseTemplate; import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; import org.springframework.data.couchbase.domain.Config; +import org.springframework.data.couchbase.transactions.SimulateFailureException; import com.couchbase.client.core.diagnostics.PingResult; import com.couchbase.client.core.diagnostics.PingState; @@ -88,7 +88,7 @@ /** * Extends the {@link ClusterAwareIntegrationTests} with java-client specific code. * - * @Author Michael Reiche + * @author Michael Reiche */ // Temporarily increased timeout to (possibly) workaround MB-37011 when Developer Preview enabled @Timeout(value = 10, unit = TimeUnit.MINUTES) // Safety timer so tests can't block CI executors @@ -100,10 +100,18 @@ public class JavaIntegrationTests extends ClusterAwareIntegrationTests { @BeforeAll public static void beforeAll() { + Config.setScopeName(null); callSuperBeforeAll(new Object() {}); ApplicationContext ac = new AnnotationConfigApplicationContext(Config.class); couchbaseTemplate = (CouchbaseTemplate) ac.getBean(COUCHBASE_TEMPLATE); reactiveCouchbaseTemplate = (ReactiveCouchbaseTemplate) ac.getBean(REACTIVE_COUCHBASE_TEMPLATE); + try (CouchbaseClientFactory couchbaseClientFactory = new SimpleCouchbaseClientFactory(connectionString(), + authenticator(), bucketName())) { + couchbaseClientFactory.getCluster().queryIndexes().createPrimaryIndex(bucketName(), + CreatePrimaryQueryIndexOptions.createPrimaryQueryIndexOptions().ignoreIfExists(true)); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } } /** @@ -203,9 +211,8 @@ protected static void waitForQueryIndexerToHaveBucket(final Cluster cluster, fin } if (!ready) { - createAndDeleteBucket();// need to do this because of https://issues.couchbase.com/browse/MB-50132 try { - Thread.sleep(50); + Thread.sleep(100); } catch (InterruptedException e) {} } } @@ -215,33 +222,6 @@ protected static void waitForQueryIndexerToHaveBucket(final Cluster cluster, fin } } - private static void createAndDeleteBucket() { - final OkHttpClient httpClient = new OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS).writeTimeout(30, TimeUnit.SECONDS).build(); - String hostPort = connectionString().split("=")[0].replace("11210", "8091").replace("11207", "18091"); - String protocol = hostPort.equals("18091") ? "https" : "http"; - String bucketname = UUID.randomUUID().toString(); - try { - - Response postResponse = httpClient.newCall(new Request.Builder() - .header("Authorization", Credentials.basic(config().adminUsername(), config().adminPassword())) - .url(protocol + "://" + hostPort + "/pools/default/buckets/") - .post(new FormBody.Builder().add("name", bucketname).add("bucketType", "membase").add("ramQuotaMB", "100") - .add("replicaNumber", Integer.toString(0)).add("flushEnabled", "1").build()) - .build()).execute(); - - if (postResponse.code() != 202) { - throw new IOException("Could not create bucket: " + postResponse + ", Reason: " + postResponse.body().string()); - } - Response deleteResponse = httpClient.newCall(new Request.Builder() - .header("Authorization", Credentials.basic(config().adminUsername(), config().adminPassword())) - .url(protocol + "://" + hostPort + "/pools/default/buckets/" + bucketname).delete().build()).execute(); - System.out.println("deleteResponse: " + deleteResponse); - } catch (IOException ioe) { - ioe.printStackTrace(); - } - } - /** * Improve test stability by waiting for a given service to report itself ready. */ @@ -401,4 +381,61 @@ public static void sleepMs(long ms) { Thread.sleep(ms); } catch (InterruptedException ie) {} } + + public static Throwable assertThrowsOneOf(Executable executable, Class... expectedTypes) { + + try { + executable.execute(); + } catch (Throwable actualException) { + for (Class expectedType : expectedTypes) { + if (actualException.getClass().isAssignableFrom(expectedType)) { + return actualException; + } + } + UnrecoverableExceptions.rethrowIfUnrecoverable(actualException); + String message = "Expected one of " + toString(expectedTypes) + " but was : " + actualException.getClass(); + throw new AssertionFailedError(message, actualException); + } + + String message = "Expected one of " + toString(expectedTypes) + " to be thrown, but nothing was thrown."; + throw new AssertionFailedError(message); + } + + private static String toString(Object[] array) { + StringBuffer sb = new StringBuffer(); + sb.append("["); + for (int i = 0; i < array.length; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(array[i]); + } + sb.append("]"); + return sb.toString(); + } + + public static void assertThrowsWithCause(Executable executable, Class... expectedTypes) { + try { + executable.execute(); + } catch (Throwable actualException) { + for (Class expectedType : expectedTypes) { + if (actualException == null || !expectedType.isAssignableFrom(actualException.getClass())) { + String message = "Expected " + expectedType + " to be thrown/cause, but found " + actualException; + throw new AssertionFailedError(message, actualException); + } + actualException = actualException.getCause(); + } + UnrecoverableExceptions.rethrowIfUnrecoverable(actualException); + return; + } + + String message = "Expected " + expectedTypes[0] + " to be thrown, but nothing was thrown."; + throw new AssertionFailedError(message); + } + + // Use this to still rely on the return type + public static T throwSimulateFailureException(T entity) { + throw new SimulateFailureException(); + } + } diff --git a/src/test/java/org/springframework/data/couchbase/util/TestCluster.java b/src/test/java/org/springframework/data/couchbase/util/TestCluster.java index 776283df9..74b1fe372 100644 --- a/src/test/java/org/springframework/data/couchbase/util/TestCluster.java +++ b/src/test/java/org/springframework/data/couchbase/util/TestCluster.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors + * Copyright 2012-2022 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. @@ -16,7 +16,7 @@ package org.springframework.data.couchbase.util; -import static java.nio.charset.StandardCharsets.*; +import static java.nio.charset.StandardCharsets.UTF_8; import java.io.IOException; import java.net.URL; @@ -83,7 +83,7 @@ private static Properties loadProperties() { defaults.load(url.openStream()); } } catch (Exception ex) { - throw new RuntimeException("Could not load properties", ex); + throw new RuntimeException("Could not load properties - maybe is pom instead of jar?", ex); } Properties all = new Properties(System.getProperties()); diff --git a/src/test/java/org/springframework/data/couchbase/util/TestClusterConfig.java b/src/test/java/org/springframework/data/couchbase/util/TestClusterConfig.java index 2c7c2c8ea..968c4b1c2 100644 --- a/src/test/java/org/springframework/data/couchbase/util/TestClusterConfig.java +++ b/src/test/java/org/springframework/data/couchbase/util/TestClusterConfig.java @@ -25,6 +25,8 @@ * tests for bootstrapping their own code. * * @since 2.0.0 + * + * @author Michael Reiche */ public class TestClusterConfig { @@ -83,6 +85,7 @@ public Optional clusterCert() { * Finds the first node with a given service enabled in the config. *

    * This method can be used to find bootstrap nodes and similar. + *

    * * @param service the service to find. * @return a node config if found, empty otherwise. diff --git a/src/test/java/org/springframework/data/couchbase/util/Util.java b/src/test/java/org/springframework/data/couchbase/util/Util.java index 31c07db84..3a91d3718 100644 --- a/src/test/java/org/springframework/data/couchbase/util/Util.java +++ b/src/test/java/org/springframework/data/couchbase/util/Util.java @@ -146,4 +146,27 @@ public static Pair, List> comprisesNot(Iterable source, T[] al return Pair.of(unexpected, missing); } } + + /** + * check if we are/are not in an @Transactional transaction + * + * @param inTransaction + */ + public static void assertInAnnotationTransaction(boolean inTransaction) { + StackTraceElement[] stack = Thread.currentThread().getStackTrace(); + for (StackTraceElement ste : stack) { + if (ste.getClassName().startsWith("org.springframework.transaction.interceptor") + || ste.getClassName().startsWith("org.springframework.data.couchbase.transaction.interceptor")) { + if (inTransaction) { + return; + } + } + } + if (!inTransaction) { + return; + } + throw new RuntimeException("in-annotation-transaction = " + (!inTransaction) + + " but expected in-annotation-transaction = " + inTransaction); + } + } diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml index f8aa95196..6efee2b37 100644 --- a/src/test/resources/logback.xml +++ b/src/test/resources/logback.xml @@ -22,10 +22,12 @@ - log additional debug info during automatic index creation --> - " + " + " +