From c5efd9a89e1f5e4e1a8c0806b00964dcce53aa26 Mon Sep 17 00:00:00 2001 From: Michael Reiche <48999328+mikereiche@users.noreply.github.com> Date: Thu, 30 Jun 2022 12:14:28 -0700 Subject: [PATCH] Transaction Support for Spring Data Couchbase. Closes #1145. commit e2df8e73777a80ea71c15d77dd51252f6766fa94 Author: Michael Reiche <48999328+mikereiche@users.noreply.github.com> Date: Mon Jun 27 18:29:23 2022 -0700 Rebased and tidied transaction branch. In addition to transaction support, some inconsitencies in fluent APIs were remedied. Documentation to follow. Closes #1145. commit e20ed0814e770e26ec34634429f3ac7710acd8d3 Author: Michael Reiche <48999328+mikereiche@users.noreply.github.com> Date: Fri Jun 24 17:21:45 2022 -0700 Rebase with main. commit edadede7a01997a364c1f5169a36e8bb3e4eee11 Author: Michael Reiche <48999328+mikereiche@users.noreply.github.com> Date: Fri Jun 24 10:45:21 2022 -0700 Add support for Couchbase Transactions. The fluent operations are common for options that are common to operations with and without transactions. Once there is a transaction(ctx), or an option specific to without-transactions (such as scanConsistency), the interfaces are bifurcated, so that an non-transaction option cannot be applied to a transaction operation, and a transaction(ctx) cannot be applied where a non-transaction option has already been applied. Closes #1145. Support for @Transactional for blocking and reactive Transactionmanager. Closes 1145. Transaction Support. Transaction Support. Porting to SDK-integrated version of transactions The transactions logic exists in the Java SDK as of 3.3.0, with a slightly different API. This is the first effort at the port, which literally just compiles. It will not run as crucial code has been commented and todo-ed. There is work remaining to figure out how to complete the port, as some crucial parts (such as ctx.commit() and ctx.rollback()) have been intentionally removed. Continuing work to get the ExtSDKIntegration port working Trying to transition to CallbackPreferring manager. Added CouchbaseSimpleCallbackTransactionManager, the simplest possible implementation of CallbackPreferringTransactionManager, combined with a simpler approach to ThreadLocal storage in ReactiveInsertByIdSupport. Test 'commitShouldPersistTxEntriesOfTxAnnotatedMethod' is now passing. Adding WIP get-and-replace @Transactional support (Not yet working as CAS/version field in Person is not populated correctly.) Datacouch 1145 transaction support (#1423) * Porting to SDK-integrated version of transactions The transactions logic exists in the Java SDK as of 3.3.0, with a slightly different API. This is the first effort at the port, which literally just compiles. It will not run as crucial code has been commented and todo-ed. There is work remaining to figure out how to complete the port, as some crucial parts (such as ctx.commit() and ctx.rollback()) have been intentionally removed. * Continuing work to get the ExtSDKIntegration port working Trying to transition to CallbackPreferring manager. * Added CouchbaseSimpleCallbackTransactionManager, the simplest possible implementation of CallbackPreferringTransactionManager, combined with a simpler approach to ThreadLocal storage in ReactiveInsertByIdSupport. Test 'commitShouldPersistTxEntriesOfTxAnnotatedMethod' is now passing. * Adding WIP get-and-replace @Transactional support (Not yet working as CAS/version field in Person is not populated correctly.) Commit before pulling Graham's changes. Merge branch 'datacouch_1145_transaction_support' of github.com:spring-projects/spring-data-couchbase into datacouch_1145_transaction_support Transitioning to use CoreTransactionAttemptContext. Tests may fail. Removing AttemptContextReactiveAccessor Don't think we need this, as we can pass around CoreTransactionAttemptContext instead, which gives access to a lot of internals. Removing TransactionsReactive Would prefer not to C&P a huge class out of the transaction internals, and don't think we need it. Removing some files not currently used To reduce & simplify the amount of code to look at. Some don't seem to be used in any branch, some just aren't used in this branch. Removing CouchbaseTransactionInterceptor As per offline discussion, CallbackPreferringPlatformTransactionManager is perhaps the optimal solution. Copying @Transactional tests out into separate class Tidyup Tidyup test names Verify GenericSupport is on same thread before and after transactional operation Refactoring CouchbaseSimpleCallbackTransactionManager ThreadLocalStorage management Using latest java-client ReactiveReplaceByIdSupport - Fixing use of CAS now have CoreTransactionAttemptContext. Removing unused code. ReactiveInsertByIdSupport - fixing use of reactive vs non-reactive, and CAS Merging upstream Remove incorrect thread check (.doOnNext could execute on a different thread) Get scope and collection from pseudoArgs and some cleanup. Completing merge from upstream Removing unused classes Give GenericSupport a better name Reject at runtime options that aren't supported in a transaction Fixing some small todos, partly by removing unused coe Fix runtime option checks Simplifying CouchbaseSimpleCallbackTransactionManager ThreadLocalStorage Standardising on ReactiveCouchbaseResourceHolder rather than holding CoreTransactionAttemptContext too Removing version from CouchbaseDocument Can't recall why I added this, and tests pass without it Improving CouchbaseTransactionalIntegrationTests and adding more tests Reject operations that aren't allowed in a transaction (upsertById etc.) Improve handling of CAS mismatch By calling CoreTransactionAttemptContext.operationFailed, it ensures that internal state is set. So even if the user catches the exception, the transaction still behaves as it should. Removing a now-redundant non-transactional check on upsertById I missed this when adding TransactionalSupport.verifyNotInTransaction here. Support @Transactional options timeout and isolation level Add ReactiveTransactionWrapper/TransactionWrapper and a bunch of cleanup. Merge branch 'datacouch_1145_transaction_support' of https://github.com/programmatix/spring-data-couchbase into programmatix-datacouch_1145_transaction_support Fixed up merge issues. Datacouch 1145 transaction support (#1447) * Move CouchbaseTransactionalOperator to use SLF4J, same as rest of the code. * Handle all propagation levels * Adding new tests for repository calls inside @Transactional One test is failure due to what looks like a bug elsewhere. * Rename CouchbaseTransactionalIntegrationTests, and check after each test that we're not in a transaction. Remove unnecessary methods From ReactiveCouchbaseClientFactory. Also rationalized naming of methods and other changes. Cleanup of test classes. Datacouch 1145 transaction support (#1448) * Move CouchbaseTransactionalOperator to use SLF4J, same as rest of the code. * Handle all propagation levels * Adding new tests for repository calls inside @Transactional One test is failure due to what looks like a bug elsewhere. * Rename CouchbaseTransactionalIntegrationTests, and check after each test that we're not in a transaction. * Remove unnecessary methods From ReactiveCouchbaseClientFactory. Also rationalized naming of methods and other changes. * Cleanup of test classes. * Removing unused classes (Reducing the cognitive burden) * Removing version from CouchbaseDocument This change was done previously - it must have slipped back in a merge. * Adding and removing TODOs * Adding and removing TODOs * DRYing CouchbaseTransactionalPropagationIntegrationTests * Check propagation tests retry as expected * Tidy up PersonWithoutVersion to the minimum required * Removing unused code This should all be non-destructive. Just removing code IntelliJ declares unused. Intent is to make it easier to figure out how the CoreTransactionAttemptContext TLS is working. * Adding tests for @Transactional removeByQuery and findByQuery Failing as they aren't being executed transactionally - investigating why. Co-authored-by: Michael Reiche <48999328+mikereiche@users.noreply.github.com> change references to resource holder change refs to resource holder Merge branch 'programmatix-datacouch_1145_transaction_support' of github.com:spring-projects/spring-data-couchbase into programmatix-datacouch_1145_transaction_support Removing core transaction attempt context bound couchbase client factory rebased (#1449) * Move ReactiveTransactionsWrapper tests into a new file * (Temporarily?) disabling tests using CouchbaseTransactionOperation or TransactionalOperator As I feel we should be removing/not-supporting these, and on this branch I've broken them. * Make all transaction tests call assertNotInTransaction * Removing unused code * Instead of binding the transaction AttemptContext to a CouchbaseClientFactory, fetch it from ThreadLocalStorage (or the reactive context) instead. This allows a lot of simplifying: * The non-trivial ReactiveCouchbaseClientUtils can be removed * As can CoreTransactionAttemptContextBoundCouchbaseClientFactory Also removing TransactionalSupport.one as it wasn't providing as much DRY utility as I thought it would - only used in two places. This change won't compile on its own. To reduce the complexity of this patchset, the Reactive*OperationSupport changes will go into a separate commit. * Reactive*OperationSupport changes to support the previous commit. * Fixing ReactiveRemoveByQuerySupport. Both to support the changes to TransactionalSupport. And to fix the TODO where the query resuls were not being handled. * Disabling a test * Adding CouchbaseTransactionsWrapperTemplateIntegrationTests * Another advantage of removing CoreTransactionAttemptContextBoundCouchbaseClientFactory is we can remove Cluster and ClusterInterface. * Adding CouchbaseReactiveTransactionsWrapperTemplateIntegrationTests Some of these tests are currently failing - tracking down where the issue is. Merge branch 'programmatix-datacouch_1145_transaction_support' of github.com:spring-projects/spring-data-couchbase into programmatix-datacouch_1145_transaction_support manual merges for PR manual merges for PR Fix a bunch of test cases and remove unused bits. Removing CouchbaseTransactionInterceptor As discussed on Slack. Reenabling some tests that are passing (Unclear why these were disabled?) Verified that CallbackPreferringPlatformTransactionManager getTransaction/commit/rollback are never called Adding tests for TransactionTemplate, which works fine with CouchbaseSimpleCallbackTransactionManager Whether we actually document this support is another matter - it's yet another way of doing transactions. Small fixes to support TransactionTemplate * Handle if the user has set isRollbackOnly on the TransactionStatus (which is only available - I think - when using TransactionTemplate) * Supply a `transaction` object to CouchbaseTransactionStatus so that status.isNewTransaction() correctly returns true. (This method requires that transaction to be set.) Clarifying that direct use of PlatformTransactionManager is not supported Adding further TransactionTemplateIntegrationTests tests Fixing removeByQuery queryOptions creation Removing now-fixed TODO (no longer key off Cluster) Adding overload to CouchbaseSimpleCallbackTransactionManager to allow it to be constructed without a TransactionOptions. Adding CouchbaseSimpleTransactionalOperator, the simplest possible implementation of TransactionalOperator. Adding retry tests Just making sure that error handling and retries are done correctly throughout. Updating and adding some TODOs Have CouchbaseTransactionManager support CouchbaseResourceHolder.class binding Adding more tests for CouchbaseTransactionManager. These tests fail, and are known to fail. I'm adding them as a solid demonstration of why I don't feel we can have this CouchbaseTransactionManager: it doesn't provide the crucial 'core loop' functionality, including error handling and retries. We should standardise on CouchbaseSimpleCallbackTransactionManager instead. CouchbaseSimpleCallbackTransactionManager.executeNewReactiveTransaction now buffers results rather than trying to stream a Flux from out of a completed lambda (which I doubt is even possible.) Removing comment that has been resolved. (As per Slack, we will live with this limitation.) Adding CouchbaseSimpleTransactionInterceptor, a very simple TransactionInterceptor implemention that defers to CouchbaseSimpleCallbackTransactionManager if that is the provided TransactionManager, and otherwise just calls super. This allows reactive @Transactional - though all @Transactional methods including blocking will now flow through it. There are two rather divergent approaches in the code currently: 1. CouchbaseTransactionManager, ReactiveTransactionManager, CouchbaseTransactionalOperator, CouchbaseTransactionInterceptor 2. CouchbaseSimpleCallbackTransactionManager, CouchbaseSimpleTransactionalOperator, CouchbaseSimpleTransactionInterceptor I know the intent is to remove some aspects of (1), but until that's done it's proving tricky to have tests for both concurrently - I've hit several issues on adding CouchbaseSimpleTransactionInterceptor, with 'multiple transaction manager beans in config' being common. So, temporarily moving some beans from AbstractCouchbaseConfiguration into the test Config class, renaming it, and having two separately TransactionsConfig classes for the two approaches. Once we've aligned the approaches more, can move what beans survive back into AbstractCouchbaseConfiguration. Safety check in CouchbaseSimpleCallbackTransactionManager that the blocking run is not accidentally running a reactive @Transactional somehow. Adding tests for reactive @Transactional, which now works (including error handling and retries) as of the CouchbaseSimpleTransactionInterceptor. With TransactionsConfigCouchbaseSimpleTransactionManager change, can now simplify @Transactional(transactionManager = ...) to just @Transactional. Tidying TODOs I saw in a PR comment that getResources no longer uses TransactionOptions Removing configureTransactions from config As per PR discussion Removing some code that has now been refactored into 3.3.1 SDK Removing TODOs that are TODONE already Removing transactionsOptions() bean from AbstractCouchbaseConfiguration. As per comment, this feels unnecessary: any options you'd configure at this config level, you'd surely provide at the global (Cluster) level instead? Reinstating some commented-out code This looks pretty crucial - can't recall why I commented it in first place Removing TransactionResult This was from a now-abandoned idea of storing transactional metadata in the entity class. Fix recent removal of TransactionOptions bean Switch some IllegalStateException for more accurate UnsupportedOperationException Tidying tests to remove old GenericApplicationContext method Tidying some TODOs (todo gp == in code that I think we should remove) (todo gpx == needs looking at) Tidying TransactionsConfigCouchbaseSimpleTransactionManager Provide SpringTransactionAttemptContext and ReactiveSpringTransactionAttemptContext wrappers For use by TransactionsWrapper and reactive equivalent. Pro: it's an abstraction layer. It lets us add Spring-specific functionality, or hide functionality that for whatever reason doesn't work with Spring (which is why it's composition rather than inheritance, beyond that being a best practice anyway). Con: any new API added will also have to be added to these wrappers. But that's a small amount of work and API is added very infrequently. Cleanup tests mostly. Temporary fix for CouchbaseSimpleCallbackTransactionManager. Merge branch 'programmatix-datacouch_1145_transaction_support' of github.com:spring-projects/spring-data-couchbase into programmatix-datacouch_1145_transaction_support Tidying up test cases. Fixes to over-zealous manual merging. - keep CouchbaseTransactionManager as Graham is still using it. - fix tests that were expecting SimulateFailure to be nested. Add ReactiveTransactionWrapper and enable tests that use it. More tidying. Removed CouchbaseTransactionManager. Only wrap exceptions in CouchbaseSimpleTransactionInterceptor if they are not RuntimeExceptions. Removed CouchbaseTransactionalOperator and ReactiveCouchbaseTransactionalOperator. Fixed non-transactions regression introduced in previous commit. Removing the two transactions config classes Now we've landed on a single agreed approach there's no need for this separation any more, and any beans can be moved back into AbstractCouchbaseConfiguration. Rename CouchbaseSimpleTransactionInterceptor The "Simple" moniker is no longer useful since we now only have one of these. Rename CouchbaseSimpleCallbackTransactionManager The "Simple" moniker is no longer useful since we now only have one of these. Rename CouchbaseSimpleTransactionalOperator The "Simple" moniker is no longer useful since we now only have one of these. Change CouchbaseTransactionalOperator construction to static As per Slack discussion. Remove comments related to another database Removing a test comment that doesn't seem to apply anymore Test passes for me at least Simplify TransactionsWrapper and AttemptContextReactiveAccessor newCoreTranactionAttemptContext is reimplementing some code from core plus has some config bugs to resolve. I think it's simpler to remove it and replace TransactionsWrapper (the only code still using this method) with the simple code seen now. (Note this is similar to how I had it before https://github.com/spring-projects/spring-data-couchbase/commit/1701183b63876c01cd4b012cbce7c783fc374e6b - not sure if that commit intentionally reverted things?) This change also petmits a lot of tidyup & simplification throughout the codebase. We can just create CouchbaseResourceHolders directly now. Tidying a test No longer needs retryWhen now using the new approaches Remove ReactiveCouchbaseClientFactory. It was added to support getting the transaction from the TransactionSynchronizationManager.forCurrent() and providing a template with a session containing the transaction. Fixing some code warnings Mostly removing unused code Mark internal classes @Stability.Internal Removing some tests that have already been previously moved into another file. Removing the transaction wrappers Requires JVMCBC-1105 and 3.3.2 Add more tests for native SDK transactions Move all tests related to native SDK transactions into their own package It makes it easier to test just that functionality while iterating. Removing CouchbaseTransactionManagerTransactionalTemplateIntegrationTests This test is now redundant. It was created to show why the original CouchbaseTransactionManager couldn't work (no retries). Now that has been replaced, this test is just duplicating others. Adding some minimal JavaDocs and comments. Tidying up after moving ThreadLocalStorage into SDK Deleting CouchbaseTemplateTransactionIntegrationTests As this relies on Spring test @Transactional, which we do not support as that Spring logic is not aware of CallbackPreferringTransactionManager. Removed some redundant bean names Tidying up tests and comments Removing some now-unused reflection code Starting with mapping TransactionFailedException and TransactionCommitAmbiguousException, into new errors TransactionSystemUnambiguousException and TransactionSystemAmbiguousException. These will be raised from an @Transactional transaction. E.g. to do error handling the user would do: ``` try { service.transactionalMethod(); } catch (TransactionSystemAmbiguousException ex) { // app-specific handling } catch (TransactionSystemUnambiguousException ex) { // app-specific handling } class Service { @Transactional void transactionalMethod() { // ... } } ``` Mapping TransactionOperationFailedException, which is an opaque signal raised from transaction operations, to new exception UncategorizedTransactionDataAccessException. This depends on some new functionality added into Java SDK 3.3.2, WrappedTransactionOperationFailedException. Minor tidyuo Improving tests for correct operation-level errors commit b02959dded4cf19b2f8b6f78b016b5bb6afb9ac8 Author: Michael Reiche <48999328+mikereiche@users.noreply.github.com> Date: Mon Mar 14 09:37:51 2022 -0700 Support derived queries on repositories defined with an abstract entity class. (#1366) Motivation: Currently an abstract entity class specified in the repository definition can be used for the predicate typeKey = typeAlias(of abstract entity class) in queries. Since documents are stored with typeKey = typeAlias(of concrete class) those queries will never match any documents. To allow this to work, all of the abstract entity class an all concrete subclasses must use the same typeAlias. Once those documents are found, regardless of their concrete class, they will all have the same typeKey = typeAlias, instead of having the typeAlias specific to the concrete class. Additional information in the stored document is needed to identify the concrete class (subtype in the example test case), as well as a TypeMapper to interpret that information. Changes: This allows a common TypeAlias to be used for the purpose of the predicate typeKey = typeAlias, and the determination of the concrete type by implementing an AbstractingMappingCouchbaseConverter that inspects the 'subtype' property. Closes #1365. Co-authored-by: Michael Reiche commit 5f03d1e0ce80e820ff8a3322b2ebfcf6f1bebb8f Author: Michael Reiche <48999328+mikereiche@users.noreply.github.com> Date: Tue Feb 22 10:09:25 2022 -0800 Fix update to cache documentation. (#1359) Closes #1358. commit d5ca390d84a281b31854aa201cc53b4093af075d Author: Michael Reiche <48999328+mikereiche@users.noreply.github.com> Date: Tue Feb 22 09:56:53 2022 -0800 Reinstate CouchbaseCache documentation. (#1357) Closes #1356. commit 9af8ece3708d805c03db8784bfd9cd3c77e76db9 Author: Mark Paluch Date: Tue Feb 22 08:44:34 2022 +0100 Use Java 17 to build snapshots for Artifactory. Closes #1352 commit e83bd41a86528ceb99d2291a23ac07fe6250b75f Author: Mark Paluch Date: Fri Feb 18 11:15:41 2022 +0100 After release cleanups. See #1307 commit 13d266e6f7ff06901c9359ef7aac5143858d22f1 Author: Mark Paluch Date: Fri Feb 18 11:08:48 2022 +0100 Prepare 4.4 M3 (2021.2.0). See #1307 commit 4a044197aa5d7c4bfc5d9b15f0dc7139bb4b4fc5 Author: Michael Reiche <48999328+mikereiche@users.noreply.github.com> Date: Mon Feb 14 09:10:04 2022 -0800 Add mechanism for save to do one of insert, replace or upsert. (#1316) Closes #1277. commit 1fdbf6d5d091225d3297b3aab892013155000e06 Author: Christoph Strobl Date: Tue Jan 18 09:21:34 2022 +0100 After release cleanups. See #1301 commit da7d241e3927412b5ce32b9a1d017a2412c1891e Author: Christoph Strobl Date: Tue Jan 18 09:09:02 2022 +0100 Prepare 4.4 M2 (2021.2.0). See #1301 commit ebe33cf138970ad47575452bd8b1fc181964fba7 Author: Christoph Strobl Date: Fri Jan 14 11:08:04 2022 +0100 After release cleanups. See #1298 commit ecadff644c31a834d83c2061cb45edb43f2e8983 Author: Christoph Strobl Date: Fri Jan 14 10:57:51 2022 +0100 Prepare 4.4 M1 (2021.2.0). See #1298 commit 5dbf182fd10c5405b7a9361f50188144b76239b8 Author: Michael Reiche <48999328+mikereiche@users.noreply.github.com> Date: Thu Jan 6 15:00:49 2022 -0800 Incorporate changes from 5.0.x and bump Couchbase SDK. (#1287) Closes #1286. Co-authored-by: mikereiche commit ef22b3d7967f805d74ddb92e571899570cd80762 Author: Michael Reiche <48999328+mikereiche@users.noreply.github.com> Date: Fri Nov 12 07:15:45 2021 -0800 Handle Collection<> parameters to repository query methods. (#1271) Closes #1270. Co-authored-by: mikereiche commit 00e05e4b41414afd815cf08127da13c2bc3481d1 Author: Jens Schauder Date: Fri Nov 12 10:59:45 2021 +0100 After release cleanups. See #1257 commit af66ada42cd19ce8d6ac989188686155d3a8181a Author: Jens Schauder Date: Fri Nov 12 10:49:16 2021 +0100 Prepare 4.3 GA (2021.1.0). See #1257 commit 6ec138182da008c7be465fb5e31f0e210e3b00ca Author: yyfMichaelYan <54760415+yyfMichaelYan@users.noreply.github.com> Date: Mon Nov 8 13:40:24 2021 -0600 fix flaky test writesAndReadsCustomFieldsConvertedClass (#1264) --- pom.xml | 5 +- .../AttemptContextReactiveAccessor.java | 34 ++ .../SimpleCouchbaseClientFactory.java | 10 +- .../AbstractCouchbaseConfiguration.java | 52 +- .../data/couchbase/config/BeanNames.java | 8 +- .../core/AbstractTemplateSupport.java | 234 ++++++++ .../core/CouchbaseExceptionTranslator.java | 35 +- .../couchbase/core/CouchbaseOperations.java | 10 +- .../couchbase/core/CouchbaseTemplate.java | 17 +- .../core/CouchbaseTemplateSupport.java | 193 +------ .../core/ExecutableRemoveByIdOperation.java | 17 + .../ExecutableRemoveByIdOperationSupport.java | 28 +- .../core/NonReactiveSupportWrapper.java | 28 +- .../core/ReactiveCouchbaseOperations.java | 9 +- .../core/ReactiveCouchbaseTemplate.java | 61 ++- .../ReactiveCouchbaseTemplateSupport.java | 198 +------ .../ReactiveExistsByIdOperationSupport.java | 11 +- ...activeFindByAnalyticsOperationSupport.java | 20 +- .../ReactiveFindByIdOperationSupport.java | 69 ++- .../ReactiveFindByQueryOperationSupport.java | 111 ++-- ...eFindFromReplicasByIdOperationSupport.java | 13 +- .../ReactiveInsertByIdOperationSupport.java | 64 ++- .../core/ReactiveRemoveByIdOperation.java | 17 + .../ReactiveRemoveByIdOperationSupport.java | 76 ++- ...ReactiveRemoveByQueryOperationSupport.java | 51 +- .../ReactiveReplaceByIdOperationSupport.java | 82 ++- .../core/ReactiveTemplateSupport.java | 17 +- .../ReactiveUpsertByIdOperationSupport.java | 38 +- .../data/couchbase/core/TemplateSupport.java | 18 +- .../couchbase/core/TransactionalSupport.java | 75 +++ .../couchbase/core/query/OptionsBuilder.java | 60 ++- .../couchbase/core/support/PseudoArgs.java | 11 +- .../support/CouchbaseRepositoryBase.java | 10 +- .../support/DynamicInvocationHandler.java | 24 +- .../support/SimpleCouchbaseRepository.java | 25 +- .../SimpleReactiveCouchbaseRepository.java | 26 +- .../CouchbaseCallbackTransactionManager.java | 289 ++++++++++ .../transaction/CouchbaseResourceHolder.java | 61 +++ .../CouchbaseTransactionDefinition.java | 33 ++ .../CouchbaseTransactionInterceptor.java | 95 ++++ .../CouchbaseTransactionStatus.java | 50 ++ .../CouchbaseTransactionalOperator.java | 56 ++ ...TransactionRollbackRequestedException.java | 29 + .../TransactionSystemAmbiguousException.java | 42 ++ .../TransactionSystemCouchbaseException.java | 45 ++ ...TransactionSystemUnambiguousException.java | 31 ++ ...gorizedTransactionDataAccessException.java | 46 ++ ...chbaseCacheCollectionIntegrationTests.java | 12 +- .../cache/CouchbaseCacheIntegrationTests.java | 7 +- ...hbaseTemplateKeyValueIntegrationTests.java | 13 +- ...mplateQueryCollectionIntegrationTests.java | 127 ++--- ...ouchbaseTemplateQueryIntegrationTests.java | 19 +- ...hbaseTemplateKeyValueIntegrationTests.java | 25 +- ...mplateQueryCollectionIntegrationTests.java | 114 ++-- .../data/couchbase/domain/AbstractEntity.java | 7 +- .../data/couchbase/domain/Airport.java | 11 +- .../couchbase/domain/AirportRepository.java | 7 + .../couchbase/domain/CollectionsConfig.java | 29 + .../domain/FluxIntegrationTests.java | 27 +- .../data/couchbase/domain/Person.java | 79 ++- .../couchbase/domain/PersonRepository.java | 9 +- .../domain/PersonWithoutVersion.java | 49 ++ .../domain/ReactiveAirportRepository.java | 4 + .../domain/ReactivePersonRepository.java | 27 + .../data/couchbase/domain/UserRepository.java | 3 + ...chbaseRepositoryQueryIntegrationTests.java | 77 ++- ...aseRepositoryKeyValueIntegrationTests.java | 11 +- ...chbaseRepositoryQueryIntegrationTests.java | 26 +- ...sitoryQueryCollectionIntegrationTests.java | 29 +- ...sitoryQueryCollectionIntegrationTests.java | 24 +- .../AfterTransactionAssertion.java | 49 ++ ...basePersonTransactionIntegrationTests.java | 348 ++++++++++++ ...onTransactionReactiveIntegrationTests.java | 228 ++++++++ ...tiveTransactionNativeIntegrationTests.java | 231 ++++++++ ...baseTransactionNativeIntegrationTests.java | 188 +++++++ ...onAllowableOperationsIntegrationTests.java | 133 +++++ ...ionalOperatorTemplateIntegrationTests.java | 329 ++++++++++++ ...eTransactionalOptionsIntegrationTests.java | 133 +++++ ...nsactionalPropagationIntegrationTests.java | 354 ++++++++++++ ...ansactionalRepositoryIntegrationTests.java | 146 +++++ ...TransactionalTemplateIntegrationTests.java | 503 ++++++++++++++++++ ...lUnsettableParametersIntegrationTests.java | 170 ++++++ ...ormTransactionManagerIntegrationTests.java | 51 ++ .../data/couchbase/transactions/ObjectId.java | 36 ++ .../couchbase/transactions/PersonService.java | 201 +++++++ .../transactions/PersonServiceReactive.java | 113 ++++ ...TransactionalTemplateIntegrationTests.java | 206 +++++++ .../transactions/ReplaceLoopThread.java | 85 +++ .../SimulateFailureException.java | 36 ++ .../TransactionTemplateIntegrationTests.java | 377 +++++++++++++ .../transactions/TransactionsConfig.java | 68 +++ ...onAllowableOperationsIntegrationTests.java | 134 +++++ ...iveTransactionsPersonIntegrationTests.java | 273 ++++++++++ ...eTransactionsTemplateIntegrationTests.java | 357 +++++++++++++ ...onAllowableOperationsIntegrationTests.java | 134 +++++ ...KTransactionsTemplateIntegrationTests.java | 421 +++++++++++++++ .../util/TransactionTestUtil.java | 38 ++ .../util/ClusterAwareIntegrationTests.java | 13 +- .../couchbase/util/JavaIntegrationTests.java | 109 ++-- .../data/couchbase/util/TestCluster.java | 6 +- .../couchbase/util/TestClusterConfig.java | 3 + .../data/couchbase/util/Util.java | 23 + src/test/resources/logback.xml | 4 +- 103 files changed, 7879 insertions(+), 891 deletions(-) create mode 100644 src/main/java/com/couchbase/client/java/transactions/AttemptContextReactiveAccessor.java create mode 100644 src/main/java/org/springframework/data/couchbase/core/AbstractTemplateSupport.java create mode 100644 src/main/java/org/springframework/data/couchbase/core/TransactionalSupport.java create mode 100644 src/main/java/org/springframework/data/couchbase/transaction/CouchbaseCallbackTransactionManager.java create mode 100644 src/main/java/org/springframework/data/couchbase/transaction/CouchbaseResourceHolder.java create mode 100644 src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionDefinition.java create mode 100644 src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionInterceptor.java create mode 100644 src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionStatus.java create mode 100644 src/main/java/org/springframework/data/couchbase/transaction/CouchbaseTransactionalOperator.java create mode 100644 src/main/java/org/springframework/data/couchbase/transaction/error/TransactionRollbackRequestedException.java create mode 100644 src/main/java/org/springframework/data/couchbase/transaction/error/TransactionSystemAmbiguousException.java create mode 100644 src/main/java/org/springframework/data/couchbase/transaction/error/TransactionSystemCouchbaseException.java create mode 100644 src/main/java/org/springframework/data/couchbase/transaction/error/TransactionSystemUnambiguousException.java create mode 100644 src/main/java/org/springframework/data/couchbase/transaction/error/UncategorizedTransactionDataAccessException.java create mode 100644 src/test/java/org/springframework/data/couchbase/domain/CollectionsConfig.java create mode 100644 src/test/java/org/springframework/data/couchbase/domain/PersonWithoutVersion.java create mode 100644 src/test/java/org/springframework/data/couchbase/domain/ReactivePersonRepository.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/AfterTransactionAssertion.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/CouchbasePersonTransactionIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/CouchbasePersonTransactionReactiveIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/CouchbaseReactiveTransactionNativeIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionNativeIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalNonAllowableOperationsIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalOperatorTemplateIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalOptionsIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalPropagationIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalRepositoryIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalTemplateIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/CouchbaseTransactionalUnsettableParametersIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/DirectPlatformTransactionManagerIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/ObjectId.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/PersonService.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/PersonServiceReactive.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/ReactiveTransactionalTemplateIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/ReplaceLoopThread.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/SimulateFailureException.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/TransactionTemplateIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/TransactionsConfig.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKReactiveTransactionsNonAllowableOperationsIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKReactiveTransactionsPersonIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKReactiveTransactionsTemplateIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKTransactionsNonAllowableOperationsIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/sdk/SDKTransactionsTemplateIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/couchbase/transactions/util/TransactionTestUtil.java diff --git a/pom.xml b/pom.xml index 0d3301d9..70d34fac 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 00000000..a200f963 --- /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 b97b57f9..10d9cb14 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 class SimpleCouchbaseClientFactory implements CouchbaseClientFactory { @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 6982d64f..7145f870 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.beans.factory.config.BeanDefinition; 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.CouchbaseMappingContext; 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 @@ public abstract class AbstractCouchbaseConfiguration { * @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 abstract class AbstractCouchbaseConfiguration { /** * 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 abstract class AbstractCouchbaseConfiguration { 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 @@ public abstract class AbstractCouchbaseConfiguration { 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 cb9bf63e..d5b68d01 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 00000000..64371dad --- /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 e4d22669..5418182c 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.DataIntegrityViolationException; 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 org.springframework.data.couchbase.transaction.error.UncategorizedTransactionDataAccessException; -import com.couchbase.client.core.error.*; +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 @@ import com.couchbase.client.core.error.*; * * @author Michael Nitschinger * @author Simon Baslé + * @author Michael Reiche + * @author Graham Pople */ public class CouchbaseExceptionTranslator implements PersistenceExceptionTranslator { @@ -71,7 +93,7 @@ public class CouchbaseExceptionTranslator implements PersistenceExceptionTransla 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 class CouchbaseExceptionTranslator implements PersistenceExceptionTransla 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 33ce0791..b85b05e8 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 @@ package org.springframework.data.couchbase.core; 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 4bf78121..e7a38811 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.index.CouchbasePersistentEntityIn 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 class CouchbaseTemplate implements CouchbaseOperations, ApplicationContex 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 class CouchbaseTemplate implements CouchbaseOperations, ApplicationContex } } + @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 @@ public class CouchbaseTemplate implements CouchbaseOperations, ApplicationContex 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 c6ec8a72..bd4b5d57 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 @@ import com.couchbase.client.core.error.CouchbaseException; * @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,141 +62,22 @@ class CouchbaseTemplateSupport implements ApplicationContextAware, TemplateSuppo } @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(); + 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); } - CouchbasePersistentEntity couldBePersistentEntity(Class entityClass) { - if (ClassUtils.isPrimitiveOrWrapper(entityClass) || entityClass == String.class) { - return null; - } - try { - return mappingContext.getPersistentEntity(entityClass); - } catch (InaccessibleObjectException t) { + @Override + 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 Integer getTxResultHolder(T source) { 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; - } - - @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; - } - - @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()); - } - @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; @@ -246,24 +101,6 @@ class CouchbaseTemplateSupport implements ApplicationContextAware, TemplateSuppo 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 @@ class CouchbaseTemplateSupport implements ApplicationContextAware, TemplateSuppo 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 02381669..b52b4e2f 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 @@ import com.couchbase.client.java.kv.ReplicateTo; * Remove Operations on KV service. * * @author Christoph Strobl + * @author Michael Reiche * @since 2.0 */ public interface ExecutableRemoveByIdOperation { @@ -61,6 +62,14 @@ public interface ExecutableRemoveByIdOperation { @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 @@ public interface ExecutableRemoveByIdOperation { @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 717d11a9..eb77a545 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.PersistTo; 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 class ExecutableRemoveByIdOperationSupport implements ExecutableRemoveByI 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 class ExecutableRemoveByIdOperationSupport implements ExecutableRemoveByI 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 class ExecutableRemoveByIdOperationSupport implements ExecutableRemoveByI @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 class ExecutableRemoveByIdOperationSupport implements ExecutableRemoveByI 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 7cb19f82..6e1aa710 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 @@ package org.springframework.data.couchbase.core; 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 class NonReactiveSupportWrapper implements ReactiveTemplateSupport { } @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 81b8cfde..2b2973a0 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 a120b528..c86e154e 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.CouchbaseClientFactory; 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 class ReactiveCouchbaseTemplate implements ReactiveCouchbaseOperations, A 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 class ReactiveCouchbaseTemplate implements ReactiveCouchbaseOperations, A * * @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 6cfefd64..e886655d 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 @@ -86,147 +63,22 @@ class ReactiveCouchbaseTemplateSupport implements ApplicationContextAware, React .doOnNext(converted -> maybeEmitEvent(new BeforeSaveEvent<>(entityToEncode, converted))); } + @Override + ReactiveCouchbaseTemplate getReactiveTemplate() { + return template; + } + @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; + String collection, Object txResultHolder, CouchbaseResourceHolder holder) { + return Mono + .fromSupplier(() -> decodeEntityBase(id, source, cas, entityClass, scope, collection, txResultHolder, holder)); } @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; - }); - } - - @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; - } - - @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 @@ class ReactiveCouchbaseTemplateSupport implements ApplicationContextAware, React 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 8436417d..f39013de 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 org.springframework.util.Assert; 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 @@ public class ReactiveExistsByIdOperationSupport implements ReactiveExistsByIdOpe @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 a1599ac6..cb01cae1 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 @@ package org.springframework.data.couchbase.core; 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 class ReactiveFindByAnalyticsOperationSupport implements ReactiveFindByAn 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 class ReactiveFindByAnalyticsOperationSupport implements ReactiveFindByAn } 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 class ReactiveFindByAnalyticsOperationSupport implements ReactiveFindByAn 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 a8391df0..065a150c 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.codec.RawJsonTranscoder; 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 class ReactiveFindByIdOperationSupport implements ReactiveFindByIdOperati 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(); - 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()); + 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 reactive.get(docId, (GetOptions) pArgs.getOptions()); + 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)); } - }).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; - } - }); + }); + + 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 throwable; + } + }); + } @Override @@ -115,7 +135,7 @@ public class ReactiveFindByIdOperationSupport implements ReactiveFindByIdOperati } @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 class ReactiveFindByIdOperationSupport implements ReactiveFindByIdOperati } @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 @@ public class ReactiveFindByIdOperationSupport implements ReactiveFindByIdOperati } 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 42895c99..5c961d3a 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 reactor.core.publisher.Mono; 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 class ReactiveFindByQueryOperationSupport implements ReactiveFindByQueryO 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 class ReactiveFindByQueryOperationSupport implements ReactiveFindByQueryO 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 0fee3b9e..f2f0880c 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 org.springframework.util.Assert; 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 class ReactiveFindFromReplicasByIdOperationSupport implements ReactiveFin 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 516d41c0..daa6287d 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.InsertOptions; 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 @@ public class ReactiveInsertByIdOperationSupport implements ReactiveInsertByIdOpe @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 class ReactiveInsertByIdOperationSupport implements ReactiveInsertByIdOpe 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 a9abaa17..ecdf6a06 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 @@ import com.couchbase.client.java.kv.ReplicateTo; * Remove Operations on KV service. * * @author Christoph Strobl + * @author Michael Reiche * @since 2.0 */ public interface ReactiveRemoveByIdOperation { @@ -63,6 +64,14 @@ public interface ReactiveRemoveByIdOperation { @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 @@ public interface ReactiveRemoveByIdOperation { @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 d59de09c..9149f361 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 java.util.Collection; 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 @@ public class ReactiveRemoveByIdOperationSupport implements ReactiveRemoveByIdOpe @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 class ReactiveRemoveByIdOperationSupport implements ReactiveRemoveByIdOpe 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 class ReactiveRemoveByIdOperationSupport implements ReactiveRemoveByIdOpe 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 4358294b..f5f74059 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 @@ public class ReactiveRemoveByQueryOperationSupport implements ReactiveRemoveByQu 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 class ReactiveRemoveByQueryOperationSupport implements ReactiveRemoveByQu 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 246d76f4..5cb54ce0 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.query.OptionsBuilder; 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 @@ public class ReactiveReplaceByIdOperationSupport implements ReactiveReplaceByIdO @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 69d5db01..6acaa2f4 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 @@ package org.springframework.data.couchbase.core; 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 decodeEntity(String id, String source, Long cas, Class entityClass, String scope, String collection, + Object txResultHolder, CouchbaseResourceHolder holder); - Mono applyUpdatedCas(T entity, CouchbaseDocument converted, long cas); - - 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 99760598..dbf27bb3 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.PersistTo; 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 @@ public class ReactiveUpsertByIdOperationSupport implements ReactiveUpsertByIdOpe @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 bd5ab0ae..273c67d8 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 00000000..6b56e864 --- /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 20fe310d..6214bcbc 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.AnnotatedElement; 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.ReplicateTo; 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 @@ public class OptionsBuilder { 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 @@ public class OptionsBuilder { 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 class OptionsBuilder { } 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 class OptionsBuilder { } 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 class OptionsBuilder { 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 class OptionsBuilder { 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 class OptionsBuilder { return null; } + /** + * collection annotation + * + * @param domainType + * @return + */ public static String getCollectionFrom(Class domainType) { if (domainType == null) { return null; @@ -423,4 +456,5 @@ public class OptionsBuilder { 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 cb9ac9c9..d70f6390 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 static org.springframework.data.couchbase.core.query.OptionsBuilder.fromF 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 class PseudoArgs { // 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 class PseudoArgs { 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 class PseudoArgs { 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 777aa0d6..0fd7f5cb 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 org.springframework.data.couchbase.repository.query.CouchbaseEntityInform 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 @@ public class CouchbaseRepositoryBase { } 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 dedba009..783593a6 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 class DynamicInvocationHandler implements InvocationHandler { 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 class DynamicInvocationHandler implements InvocationHandler { 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 4632e33e..4ce408c8 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.domain.Sort; 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 class SimpleCouchbaseRepository extends CouchbaseRepositoryBase 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 d986fd74..b23f9d3b 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 class SimpleReactiveCouchbaseRepository extends CouchbaseRepositor @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 class SimpleReactiveCouchbaseRepository extends CouchbaseRepositor 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 class SimpleReactiveCouchbaseRepository extends CouchbaseRepositor @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 00000000..e6befd9d --- /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 00000000..dee868b8 --- /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 00000000..58ab56f7 --- /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 00000000..74697391 --- /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 00000000..af4a3eec --- /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 00000000..c6bc295b --- /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 00000000..ae7a0f9c --- /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 00000000..eb424e97 --- /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 00000000..e66097c4 --- /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 00000000..c21fcab4 --- /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 00000000..9d915977 --- /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 ec6026ca..a12962e8 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.ClusterType; 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 f34df744..e9fa46ce 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.domain.UserRepository; 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 @@ import com.couchbase.client.java.query.QueryOptions; * @author Michael Reiche */ @IgnoreWhen(clusterTypes = ClusterType.MOCKED) +@SpringJUnitConfig(Config.class) class CouchbaseCacheIntegrationTests extends JavaIntegrationTests { volatile CouchbaseCache cache; @@ -68,8 +68,6 @@ class CouchbaseCacheIntegrationTests extends JavaIntegrationTests { super.afterEach(); } - - @Test void cachePutGet() { CacheUser user1 = new CacheUser(UUID.randomUUID().toString(), "first1", "last1"); @@ -125,7 +123,6 @@ class CouchbaseCacheIntegrationTests extends JavaIntegrationTests { 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 2e9f14d1..38af1b2a 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 java.util.UUID; 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.OneAndAllId; 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.domain.UserSubmission; 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.kv.ReplicateTo; 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 @@ import com.couchbase.client.java.query.QueryScanConsistency; * @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 @@ class CouchbaseTemplateKeyValueIntegrationTests extends JavaIntegrationTests { 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 4c8814f7..131670a9 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.BeforeAll; 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.Capabilities; 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.RemoveOptions; 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 @@ import com.couchbase.client.java.query.QueryScanConsistency; * @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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn // first call the super method super.beforeEach(); // then do processing for this class - couchbaseTemplate.removeByQuery(User.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) + 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(User.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) + couchbaseTemplate.findByQuery(Airport.class).withConsistency(REQUEST_PLUS).inScope(scopeName) .inCollection(collectionName).all(); - couchbaseTemplate.removeByQuery(Airport.class).withConsistency(QueryScanConsistency.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).withConsistency(REQUEST_PLUS).inScope(otherScope) + .inCollection(otherCollection).all(); + couchbaseTemplate.findByQuery(Airport.class).withConsistency(REQUEST_PLUS).inScope(otherScope) .inCollection(otherCollection).all(); } @@ -123,8 +128,7 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn // 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn // 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn // 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn @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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn 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 @@ class CouchbaseTemplateQueryCollectionIntegrationTests extends CollectionAwareIn 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 e6e749f1..d91d0d35 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 java.util.stream.Collectors; 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.couchbase.util.JavaIntegrationTests; 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 @@ import org.springframework.data.domain.Sort; * @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 @@ class CouchbaseTemplateQueryIntegrationTests extends JavaIntegrationTests { .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 @@ class CouchbaseTemplateQueryIntegrationTests extends JavaIntegrationTests { 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 8fd709ec..f57f4625 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 java.util.UUID; 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.OneAndAllEntityReactive; 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.domain.UserAnnotated3; 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 @@ import com.couchbase.client.java.query.QueryScanConsistency; * @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 @@ class ReactiveCouchbaseTemplateKeyValueIntegrationTests extends JavaIntegrationT .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 42edc672..7a35aa70 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.BeforeAll; 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.Capabilities; 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.RemoveOptions; 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 @@ import com.couchbase.client.java.query.QueryScanConsistency; * @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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio // first call the super method super.beforeEach(); // then do processing for this class - couchbaseTemplate.removeByQuery(User.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) + 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(User.class).withConsistency(QueryScanConsistency.REQUEST_PLUS) - .inCollection(collectionName).all(); - couchbaseTemplate.removeByQuery(Airport.class).withConsistency(QueryScanConsistency.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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio // 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio // 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio // 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio 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 @@ class ReactiveCouchbaseTemplateQueryCollectionIntegrationTests extends Collectio 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 6b4c7aa9..e46dae10 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 @@ import org.springframework.data.couchbase.core.mapping.id.GenerationStrategy; /** * @author Oliver Gierke + * @author Michael Reiche */ @Document public class AbstractEntity { @@ -39,6 +40,10 @@ public class AbstractEntity { 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 8046e8a8..cda300bf 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 class Airport extends ComparableEntity { 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 435c82bf..f9f520f2 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 @@ public interface AirportRepository extends CouchbaseRepository, @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 00000000..d9a926b2 --- /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 510aabae..d38ffca1 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.Random; 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 @@ import com.couchbase.client.java.query.QueryScanConsistency; @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 class FluxIntegrationTests extends JavaIntegrationTests { 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 eb2a4dd7..639ae4b5 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.CreatedBy; 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 class Person extends AbstractEntity { 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 class Person extends AbstractEntity { this.firstname = firstname; } - public Optional getLastname() { - return lastname; + public String getLastname() { + return lastname.get(); } public void setLastname(String lastname) { @@ -131,7 +148,7 @@ public class Person extends AbstractEntity { 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 class Person extends AbstractEntity { 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 2c9985d7..baa832a8 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 @@ package org.springframework.data.couchbase.domain; 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 @@ import com.couchbase.client.java.query.QueryScanConsistency; /** * @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 00000000..bb4fdaf3 --- /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 005b3f51..786c4ea7 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 @@ import com.couchbase.client.java.query.QueryScanConsistency; 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 00000000..e87974e0 --- /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 99a11fb5..20fe84ee 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 @@ public interface UserRepository extends CouchbaseRepository { } 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 fbb94582..4c09a973 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.env.ClusterEnvironment; 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 @@ public class CouchbaseRepositoryQueryIntegrationTests extends ClusterAwareIntegr 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 class CouchbaseRepositoryQueryIntegrationTests extends ClusterAwareIntegr @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 class CouchbaseRepositoryQueryIntegrationTests extends ClusterAwareIntegr 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 class CouchbaseRepositoryQueryIntegrationTests extends ClusterAwareIntegr 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 @@ public class CouchbaseRepositoryQueryIntegrationTests extends ClusterAwareIntegr 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 @@ public class CouchbaseRepositoryQueryIntegrationTests extends ClusterAwareIntegr 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 @@ public class CouchbaseRepositoryQueryIntegrationTests extends ClusterAwareIntegr () -> 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 @@ public class CouchbaseRepositoryQueryIntegrationTests extends ClusterAwareIntegr 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 class CouchbaseRepositoryQueryIntegrationTests extends ClusterAwareIntegr // 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 class CouchbaseRepositoryQueryIntegrationTests extends ClusterAwareIntegr 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 @@ public class CouchbaseRepositoryQueryIntegrationTests extends ClusterAwareIntegr } } + @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 @@ public class CouchbaseRepositoryQueryIntegrationTests extends ClusterAwareIntegr 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 cd8d4258..d15268e9 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 @@ import com.couchbase.client.java.env.ClusterEnvironment; 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 @@ public class ReactiveCouchbaseRepositoryKeyValueIntegrationTests extends Cluster // 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 4c0e1819..815f9a4c 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 @@ public class ReactiveCouchbaseRepositoryQueryIntegrationTests extends JavaIntegr } } + @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 @@ public class ReactiveCouchbaseRepositoryQueryIntegrationTests extends JavaIntegr 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 @@ public class ReactiveCouchbaseRepositoryQueryIntegrationTests extends JavaIntegr 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 cf1a6315..027eb33c 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.BeforeEach; 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 @@ import com.couchbase.client.java.query.QueryOptions; * * @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 class CouchbaseRepositoryQueryCollectionIntegrationTests extends Collecti // 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 be2a1486..3ae641c2 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.BeforeEach; 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.Capabilities; 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 @@ public class ReactiveCouchbaseRepositoryQueryCollectionIntegrationTests extends 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 @@ public class ReactiveCouchbaseRepositoryQueryCollectionIntegrationTests extends 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 @@ public class ReactiveCouchbaseRepositoryQueryCollectionIntegrationTests extends 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 00000000..54484ff9 --- /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 00000000..34fa22e9 --- /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 00000000..088841fa --- /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 00000000..cf20134f --- /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 00000000..b37fd692 --- /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 00000000..6ff59cc8 --- /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 00000000..55bdbbc1 --- /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 00000000..90ecde15 --- /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 00000000..dcb00c5e --- /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 00000000..a899d65c --- /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 00000000..12d32451 --- /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 00000000..2a1c18c3 --- /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 00000000..0e2b56dd --- /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 00000000..efc5f05b --- /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 00000000..f7d0e43d --- /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 00000000..9442687b --- /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 00000000..b451a89b --- /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 00000000..361ac78e --- /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 00000000..fdacc304 --- /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 00000000..07725cac --- /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 00000000..268d6a5b --- /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 00000000..881dd4ef --- /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 00000000..3f38ba72 --- /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 00000000..7b7842bf --- /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 00000000..c1a91a21 --- /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 00000000..9c96c2a0 --- /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 00000000..83324cf5 --- /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 1399e0e3..2d1e6c9a 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.core.error.IndexFailureException; 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 b63a7493..89b31320 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.COUCHBASE_TEMP 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 java.util.function.Predicate; 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 @@ import com.couchbase.client.java.search.result.SearchResult; /** * 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 @@ public class JavaIntegrationTests extends ClusterAwareIntegrationTests { } 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 @@ public class JavaIntegrationTests extends ClusterAwareIntegrationTests { } } - 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 class JavaIntegrationTests extends ClusterAwareIntegrationTests { 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 776283df..74b1fe37 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 @@ abstract class TestCluster implements ExtensionContext.Store.CloseableResource { 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 2c7c2c8e..968c4b1c 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 @@ import java.util.Set; * tests for bootstrapping their own code. * * @since 2.0.0 + * + * @author Michael Reiche */ public class TestClusterConfig { @@ -83,6 +85,7 @@ public class TestClusterConfig { * 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 31c07db8..3a91d371 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 class Util { 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 f8aa9519..6efee2b3 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 --> - " + " + " +