diff --git a/README.md b/README.md index 1425c0827..c7b64f93f 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,42 @@ public class MyService { } ``` +### MongoDB 4.0 Transactions + +As of version 4 MongoDB supports [Transactions](https://www.mongodb.com/transactions). Transactions are built on top of + `ClientSessions` and therefore require an active session. + +`MongoTransactionManager` is the gateway to the well known Spring transaction support. It allows applications to use +[managed transaction features of Spring](http://docs.spring.io/spring/docs/current/spring-framework-reference/html/transaction.html). +The `MongoTransactionManager` binds a `ClientSession` to the thread. `MongoTemplate` automatically detects those and operates on them accordingly. + +```java +@Configuration +static class Config extends AbstractMongoConfiguration { + + @Bean + MongoTransactionManager transactionManager(MongoDbFactory dbFactory) { + return new MongoTransactionManager(dbFactory); + } + + // ... +} + +@Component +public class StateService { + + @Transactional + void someBusinessFunction(Step step) { + + template.insert(step); + + process(step); + + template.update(Step.class).apply(Update.set("state", // ... + }; +}); +``` + ## Contributing to Spring Data Here are some ways for you to get involved in the community: diff --git a/pom.xml b/pom.xml index 6ccfbc2f3..f87fb5d28 100644 --- a/pom.xml +++ b/pom.xml @@ -28,8 +28,8 @@ multi spring-data-mongodb 2.1.0.BUILD-SNAPSHOT - 3.6.3 - 1.7.1 + 3.8.0-beta1 + 1.8.0 1.19 diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index 274ca0484..b86dc2808 100644 --- a/spring-data-mongodb/pom.xml +++ b/spring-data-mongodb/pom.xml @@ -253,6 +253,13 @@ test + + javax.transaction + jta + 1.1 + test + + org.jetbrains.kotlin diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoDatabaseUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoDatabaseUtils.java new file mode 100644 index 000000000..37155a314 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoDatabaseUtils.java @@ -0,0 +1,233 @@ +/* + * Copyright 2018 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 + * + * http://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.mongodb; + +import com.mongodb.ReadPreference; +import com.mongodb.TransactionOptions; +import com.mongodb.WriteConcern; +import org.springframework.lang.Nullable; +import org.springframework.transaction.support.ResourceHolderSynchronization; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import com.mongodb.ClientSessionOptions; +import com.mongodb.client.ClientSession; +import com.mongodb.client.MongoDatabase; + +/** + * Helper class for managing a {@link MongoDatabase} instances via {@link MongoDbFactory}. Used for obtaining + * {@link ClientSession session bound} resources, such as {@link MongoDatabase} and + * {@link com.mongodb.client.MongoCollection} suitable for transactional usage. + *

+ * Note: Intended for internal usage only. + * + * @author Christoph Strobl + * @currentRead Shadow's Edge - Brent Weeks + * @since 2.1 + */ +public class MongoDatabaseUtils { + + /** + * Obtain the default {@link MongoDatabase database} form the given {@link MongoDbFactory factory} using + * {@link SessionSynchronization#NATIVE native session synchronization}. + *

+ * Registers a {@link MongoSessionSynchronization MongoDB specific transaction synchronization} within the current + * {@link Thread} if {@link TransactionSynchronizationManager#isSynchronizationActive() snychronization is active}. + * + * @param factory the {@link MongoDbFactory} to get the {@link MongoDatabase} from. + * @return must not be {@literal null}. + */ + public static MongoDatabase getDatabase(MongoDbFactory factory) { + return doGetMongoDatabase(null, factory, SessionSynchronization.NATIVE); + } + + /** + * Obtain the default {@link MongoDatabase database} form the given {@link MongoDbFactory factory}. + *

+ * Registers a {@link MongoSessionSynchronization MongoDB specific transaction synchronization} within the current + * {@link Thread} if {@link TransactionSynchronizationManager#isSynchronizationActive() snychronization is active}. + * + * @param factory the {@link MongoDbFactory} to get the {@link MongoDatabase} from. + * @param sessionSynchronization the synchronization to use. Must not be {@literal null}. + * @return must not be {@literal null}. + */ + public static MongoDatabase getDatabase(MongoDbFactory factory, SessionSynchronization sessionSynchronization) { + return doGetMongoDatabase(null, factory, sessionSynchronization); + } + + /** + * Obtain the {@link MongoDatabase database} with given name form the given {@link MongoDbFactory factory} using + * {@link SessionSynchronization#NATIVE native session synchronization}. + *

+ * Registers a {@link MongoSessionSynchronization MongoDB specific transaction synchronization} within the current + * {@link Thread} if {@link TransactionSynchronizationManager#isSynchronizationActive() snychronization is active}. + * + * @param dbName the name of the {@link MongoDatabase} to get. + * @param factory the {@link MongoDbFactory} to get the {@link MongoDatabase} from. + * @return must not be {@literal null}. + */ + public static MongoDatabase getDatabase(String dbName, MongoDbFactory factory) { + return doGetMongoDatabase(dbName, factory, SessionSynchronization.NATIVE); + } + + /** + * Obtain the {@link MongoDatabase database} with given name form the given {@link MongoDbFactory factory}. + *

+ * Registers a {@link MongoSessionSynchronization MongoDB specific transaction synchronization} within the current + * {@link Thread} if {@link TransactionSynchronizationManager#isSynchronizationActive() snychronization is active}. + * + * @param dbName the name of the {@link MongoDatabase} to get. + * @param factory the {@link MongoDbFactory} to get the {@link MongoDatabase} from. + * @param sessionSynchronization the synchronization to use. Must not be {@literal null}. + * @return must not be {@literal null}. + */ + public static MongoDatabase getDatabase(String dbName, MongoDbFactory factory, + SessionSynchronization sessionSynchronization) { + return doGetMongoDatabase(dbName, factory, sessionSynchronization); + } + + private static MongoDatabase doGetMongoDatabase(@Nullable String dbName, MongoDbFactory factory, + SessionSynchronization sessionSynchronization) { + + Assert.notNull(factory, "Factory must not be null!"); + + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + return StringUtils.hasText(dbName) ? factory.getDb(dbName) : factory.getDb(); + } + + ClientSession session = doGetSession(factory, sessionSynchronization); + + if(session == null) { + return StringUtils.hasText(dbName) ? factory.getDb(dbName) : factory.getDb(); + } + + MongoDbFactory factoryToUse = factory.withSession(session); + return StringUtils.hasText(dbName) ? factoryToUse.getDb(dbName) : factoryToUse.getDb(); + } + + @Nullable + private static ClientSession doGetSession(MongoDbFactory dbFactory, SessionSynchronization sessionSynchronization) { + + MongoResourceHolder resourceHolder = (MongoResourceHolder) TransactionSynchronizationManager.getResource(dbFactory); + + // check for native MongoDB transaction + if (resourceHolder != null && (resourceHolder.hasSession() || resourceHolder.isSynchronizedWithTransaction())) { + + resourceHolder.requested(); + if (!resourceHolder.hasSession()) { + resourceHolder.setSession(createClientSession(dbFactory)); + } + return resourceHolder.getSession(); + } + + if (SessionSynchronization.NATIVE.equals(sessionSynchronization)) { + return null; + } + + // init a non native MongoDB transaction by registering a MongoSessionSynchronization + + resourceHolder = new MongoResourceHolder(createClientSession(dbFactory), dbFactory); + resourceHolder.requested(); + resourceHolder.getSession().startTransaction(); + + TransactionSynchronizationManager + .registerSynchronization(new MongoSessionSynchronization(resourceHolder, dbFactory)); + resourceHolder.setSynchronizedWithTransaction(true); + TransactionSynchronizationManager.bindResource(dbFactory, resourceHolder); + + return resourceHolder.getSession(); + } + + private static ClientSession createClientSession(MongoDbFactory dbFactory) { + return dbFactory.getSession(ClientSessionOptions.builder().causallyConsistent(true).build()); + } + + /** + * MongoDB specific {@link ResourceHolderSynchronization} for resource cleanup at the end of a transaction when + * participating in a non-native MongoDB transaction, such as a Jta or JDBC transaction. + * + * @author Christoph Strobl + * @since 2.1 + */ + private static class MongoSessionSynchronization extends ResourceHolderSynchronization { + + private final MongoResourceHolder resourceHolder; + + MongoSessionSynchronization(MongoResourceHolder resourceHolder, MongoDbFactory dbFactory) { + + super(resourceHolder, dbFactory); + this.resourceHolder = resourceHolder; + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.support.ResourceHolderSynchronization#shouldReleaseBeforeCompletion() + */ + @Override + protected boolean shouldReleaseBeforeCompletion() { + return false; + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.support.ResourceHolderSynchronization#processResourceAfterCommit(java.lang.Object) + */ + @Override + protected void processResourceAfterCommit(MongoResourceHolder resourceHolder) { + + if (isTransactionActive(resourceHolder)) { + resourceHolder.getSession().commitTransaction(); + } + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.support.ResourceHolderSynchronization#afterCompletion(int) + */ + @Override + public void afterCompletion(int status) { + + if (status == TransactionSynchronization.STATUS_ROLLED_BACK && isTransactionActive(this.resourceHolder)) { + resourceHolder.getSession().abortTransaction(); + } + + super.afterCompletion(status); + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.support.ResourceHolderSynchronization#releaseResource(java.lang.Object, java.lang.Object) + */ + @Override + protected void releaseResource(MongoResourceHolder resourceHolder, Object resourceKey) { + + if (resourceHolder.hasActiveSession()) { + resourceHolder.getSession().close(); + } + } + + private boolean isTransactionActive(MongoResourceHolder resourceHolder) { + + if (!resourceHolder.hasSession()) { + return false; + } + + return resourceHolder.getSession().hasActiveTransaction(); + } + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoDbFactory.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoDbFactory.java index 21cef8a8d..19ca9fe79 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoDbFactory.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoDbFactory.java @@ -22,8 +22,8 @@ import org.springframework.data.mongodb.core.MongoExceptionTranslator; import com.mongodb.ClientSessionOptions; import com.mongodb.DB; +import com.mongodb.client.ClientSession; import com.mongodb.client.MongoDatabase; -import com.mongodb.session.ClientSession; /** * Interface for factories creating {@link MongoDatabase} instances. @@ -32,7 +32,7 @@ import com.mongodb.session.ClientSession; * @author Thomas Darimont * @author Christoph Strobl */ -public interface MongoDbFactory extends CodecRegistryProvider { +public interface MongoDbFactory extends CodecRegistryProvider, MongoSessionProvider { /** * Creates a default {@link MongoDatabase} instance. diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoResourceHolder.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoResourceHolder.java new file mode 100644 index 000000000..f77d0ea6c --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoResourceHolder.java @@ -0,0 +1,122 @@ +/* + * Copyright 2018 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 + * + * http://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.mongodb; + +import org.springframework.lang.Nullable; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.ResourceHolderSupport; + +import com.mongodb.client.ClientSession; + +/** + * MongoDB specific {@link ResourceHolderSupport resource holder}, wrapping a {@link ClientSession}. + * {@link MongoTransactionManager} binds instances of this class to the thread. + *

+ * Note: Intended for internal usage only. + * + * @author Christoph Strobl + * @since 2.1 + * @see MongoTransactionManager + * @see org.springframework.data.mongodb.core.MongoTemplate + */ +class MongoResourceHolder extends ResourceHolderSupport { + + private @Nullable ClientSession session; + private MongoDbFactory dbFactory; + + /** + * Create a new {@link MongoResourceHolder} for a given {@link ClientSession session}. + * + * @param session the associated {@link ClientSession}. Can be {@literal null}. + * @param dbFactory the associated {@link MongoDbFactory}. must not be {@literal null}. + */ + MongoResourceHolder(@Nullable ClientSession session, MongoDbFactory dbFactory) { + + this.session = session; + this.dbFactory = dbFactory; + } + + /** + * @return the associated {@link ClientSession}. Can be {@literal null}. + */ + @Nullable + ClientSession getSession() { + return session; + } + + /** + * @return the associated {@link MongoDbFactory}. + */ + public MongoDbFactory getDbFactory() { + return dbFactory; + } + + /** + * Set the {@link ClientSession} to guard. + * + * @param session can be {@literal null}. + */ + public void setSession(@Nullable ClientSession session) { + this.session = session; + } + + /** + * Only set the timeout if it does not match the {@link TransactionDefinition#TIMEOUT_DEFAULT default timeout}. + * + * @param seconds + */ + void setTimeoutIfNotDefaulted(int seconds) { + + if (seconds != TransactionDefinition.TIMEOUT_DEFAULT) { + setTimeoutInSeconds(seconds); + } + } + + /** + * @return {@literal true} if session is not {@literal null}. + */ + boolean hasSession() { + return session != null; + } + + /** + * @return {@literal true} if the session is active and has not been closed. + */ + boolean hasActiveSession() { + + if (!hasSession()) { + return false; + } + + return hasServerSession() && !getSession().getServerSession().isClosed(); + } + + /** + * @return {@literal true} if the {@link ClientSession} has a {@link com.mongodb.session.ServerSession} associated + * that is accessible via {@link ClientSession#getServerSession()}. + */ + boolean hasServerSession() { + + try { + return getSession().getServerSession() != null; + } catch (IllegalStateException serverSessionClosed) { + // ignore + } + + return false; + } + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoSessionProvider.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoSessionProvider.java new file mode 100644 index 000000000..45ca6c2ab --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoSessionProvider.java @@ -0,0 +1,41 @@ +/* + * Copyright 2018 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 + * + * http://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.mongodb; + +import com.mongodb.ClientSessionOptions; +import com.mongodb.client.ClientSession; + +/** + * A simple interface for obtaining a {@link ClientSession} to be consumed by + * {@link org.springframework.data.mongodb.core.MongoOperations} and MongoDB native operations that support causal + * consistency and transactions. + * + * @author Christoph Strobl + * @currentRead Shadow's Edge - Brent Weeks + * @since 2.1 + */ +@FunctionalInterface +public interface MongoSessionProvider { + + /** + * Obtain a {@link ClientSession} with with given options. + * + * @param options must not be {@literal null}. + * @return never {@literal null}. + * @throws org.springframework.dao.DataAccessException + */ + ClientSession getSession(ClientSessionOptions options); +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoTransactionManager.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoTransactionManager.java new file mode 100644 index 000000000..dbe612aa9 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoTransactionManager.java @@ -0,0 +1,461 @@ +/* + * Copyright 2018 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 + * + * http://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.mongodb; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.lang.Nullable; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionException; +import org.springframework.transaction.TransactionSystemException; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.transaction.support.ResourceTransactionManager; +import org.springframework.transaction.support.SmartTransactionObject; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionSynchronizationUtils; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +import com.mongodb.ClientSessionOptions; +import com.mongodb.MongoException; +import com.mongodb.TransactionOptions; +import com.mongodb.client.ClientSession; + +/** + * A {@link org.springframework.transaction.PlatformTransactionManager} implementation that manages + * {@link ClientSession} based transactions for a single {@link MongoDbFactory}. + *

+ * Binds a {@link ClientSession} from the specified {@link MongoDbFactory} to the thread. + *

+ * {@link TransactionDefinition#isReadOnly() Readonly} transactions operate on a {@link ClientSession} and enable causal + * consistency, and also {@link ClientSession#startTransaction() start}, {@link ClientSession#commitTransaction() + * commit} or {@link ClientSession#abortTransaction() abort} a transaction. + * + * @author Christoph Strobl + * @currentRead Shadow's Edge - Brent Weeks + * @since 2.1 + * @see MongoDB Transaction Documentation + */ +public class MongoTransactionManager extends AbstractPlatformTransactionManager + implements ResourceTransactionManager, InitializingBean { + + private @Nullable MongoDbFactory dbFactory; + private @Nullable TransactionOptions options; + + /** + * Create a new {@link MongoTransactionManager} for bean-style usage. + *

+ * Note:The {@link MongoDbFactory db factory} has to be {@link #setDbFactory(MongoDbFactory) set} + * before using the instance. Use this constructor to prepare a {@link MongoTransactionManager} via a + * {@link org.springframework.beans.factory.BeanFactory}. + *

+ * Optionally it is possible to set default {@link TransactionOptions transaction options} defining eg. + * {@link com.mongodb.ReadConcern} and {@link com.mongodb.WriteConcern}. + * + * @see #setDbFactory(MongoDbFactory) + * @see #setTransactionSynchronization(int) + */ + public MongoTransactionManager() {} + + /** + * Create a new {@link MongoTransactionManager} obtaining sessions from the given {@link MongoDbFactory}. + * + * @param dbFactory must not be {@literal null}. + */ + public MongoTransactionManager(MongoDbFactory dbFactory) { + this(dbFactory, null); + } + + /** + * Create a new {@link MongoTransactionManager} obtaining sessions from the given {@link MongoDbFactory} applying the + * given {@link TransactionOptions options}, if present, when starting a new transaction. + * + * @param dbFactory must not be {@literal null}. + * @param options can be {@literal null}. + */ + public MongoTransactionManager(MongoDbFactory dbFactory, @Nullable TransactionOptions options) { + + Assert.notNull(dbFactory, "DbFactory must not be null!"); + + this.dbFactory = dbFactory; + this.options = options; + } + + /* + * (non-Javadoc) + * org.springframework.transaction.support.AbstractPlatformTransactionManager#doGetTransaction() + */ + @Override + protected Object doGetTransaction() throws TransactionException { + + MongoResourceHolder resourceHolder = (MongoResourceHolder) TransactionSynchronizationManager + .getResource(getRequiredDbFactory()); + return new MongoTransactionObject(resourceHolder); + } + + /* + * (non-Javadoc) + * org.springframework.transaction.support.AbstractPlatformTransactionManager#isExistingTransaction(java.lang.Object) + */ + @Override + protected boolean isExistingTransaction(Object transaction) throws TransactionException { + return extractMongoTransaction(transaction).hasResourceHolder(); + } + + /* + * (non-Javadoc) + * org.springframework.transaction.support.AbstractPlatformTransactionManager#doBegin(java.lang.Object, org.springframework.transaction.TransactionDefinition) + */ + @Override + protected void doBegin(Object transaction, TransactionDefinition definition) throws TransactionException { + + MongoTransactionObject mongoTransactionObject = extractMongoTransaction(transaction); + + MongoResourceHolder resourceHolder = newResourceHolder(definition, + ClientSessionOptions.builder().causallyConsistent(true).build()); + mongoTransactionObject.setResourceHolder(resourceHolder); + + if (logger.isDebugEnabled()) { + logger + .debug(String.format("About to start transaction for session %s.", debugString(resourceHolder.getSession()))); + } + + try { + mongoTransactionObject.startTransaction(options); + } catch (MongoException ex) { + throw new TransactionSystemException(String.format("Could not start Mongo transaction for session %s.", + debugString(mongoTransactionObject.getSession())), ex); + } + + if (logger.isDebugEnabled()) { + logger.debug(String.format("Started transaction for session %s.", debugString(resourceHolder.getSession()))); + } + + resourceHolder.setSynchronizedWithTransaction(true); + TransactionSynchronizationManager.bindResource(dbFactory, resourceHolder); + } + + /* + * (non-Javadoc) + * org.springframework.transaction.support.AbstractPlatformTransactionManager#doSuspend(java.lang.Object) + */ + @Override + protected Object doSuspend(Object transaction) throws TransactionException { + + MongoTransactionObject mongoTransactionObject = extractMongoTransaction(transaction); + mongoTransactionObject.setResourceHolder(null); + + return TransactionSynchronizationManager.unbindResource(getRequiredDbFactory()); + } + + /* + * (non-Javadoc) + * org.springframework.transaction.support.AbstractPlatformTransactionManager#doResume(java.lang.Object, java.lang.Object) + */ + @Override + protected void doResume(@Nullable Object transaction, Object suspendedResources) { + TransactionSynchronizationManager.bindResource(getRequiredDbFactory(), suspendedResources); + } + + /* + * (non-Javadoc) + * org.springframework.transaction.support.AbstractPlatformTransactionManager#doCommit(org.springframework.transaction.support.DefaultTransactionStatus) + */ + @Override + protected void doCommit(DefaultTransactionStatus status) throws TransactionException { + + MongoTransactionObject mongoTransactionObject = extractMongoTransaction(status); + + if (logger.isDebugEnabled()) { + logger.debug(String.format("About to commit transaction for session %s.", + debugString(mongoTransactionObject.getSession()))); + } + + try { + mongoTransactionObject.commitTransaction(); + } catch (MongoException ex) { + + throw new TransactionSystemException(String.format("Could not commit Mongo transaction for session %s.", + debugString(mongoTransactionObject.getSession())), ex); + } + } + + /* + * (non-Javadoc) + * org.springframework.transaction.support.AbstractPlatformTransactionManager#doRollback(org.springframework.transaction.support.DefaultTransactionStatus) + */ + @Override + protected void doRollback(DefaultTransactionStatus status) throws TransactionException { + + MongoTransactionObject mongoTransactionObject = extractMongoTransaction(status); + + if (logger.isDebugEnabled()) { + logger.debug(String.format("About to abort transaction for session %s.", + debugString(mongoTransactionObject.getSession()))); + } + + try { + mongoTransactionObject.abortTransaction(); + } catch (MongoException ex) { + + throw new TransactionSystemException(String.format("Could not abort Mongo transaction for session %s.", + debugString(mongoTransactionObject.getSession())), ex); + } + } + + /* + * (non-Javadoc) + * org.springframework.transaction.support.AbstractPlatformTransactionManager#doSetRollbackOnly(org.springframework.transaction.support.DefaultTransactionStatus) + */ + @Override + protected void doSetRollbackOnly(DefaultTransactionStatus status) throws TransactionException { + + MongoTransactionObject transactionObject = extractMongoTransaction(status); + transactionObject.getRequiredResourceHolder().setRollbackOnly(); + } + + /* + * (non-Javadoc) + * org.springframework.transaction.support.AbstractPlatformTransactionManager#doCleanupAfterCompletion(java.lang.Object) + */ + @Override + protected void doCleanupAfterCompletion(Object transaction) { + + Assert.isInstanceOf(MongoTransactionObject.class, transaction, + () -> String.format("Expected to find a %s but it turned out to be %s.", MongoTransactionObject.class, + transaction.getClass())); + + MongoTransactionObject mongoTransactionObject = (MongoTransactionObject) transaction; + + // Remove the connection holder from the thread. + TransactionSynchronizationManager.unbindResource(getRequiredDbFactory()); + mongoTransactionObject.getRequiredResourceHolder().clear(); + + if (logger.isDebugEnabled()) { + logger.debug(String.format("About to release Session %s after transaction.", + debugString(mongoTransactionObject.getSession()))); + } + + mongoTransactionObject.closeSession(); + } + + /** + * Set the {@link MongoDbFactory} that this instance should manage transactions for. + * + * @param dbFactory must not be {@literal null}. + */ + public void setDbFactory(MongoDbFactory dbFactory) { + + Assert.notNull(dbFactory, "DbFactory must not be null!"); + this.dbFactory = dbFactory; + } + + /** + * Set the {@link TransactionOptions} to be applied when starting transactions. + * + * @param options can be {@literal null}. + */ + public void setOptions(@Nullable TransactionOptions options) { + this.options = options; + } + + /** + * Get the {@link MongoDbFactory} that this instance manages transactions for. + * + * @return can be {@literal null}. + */ + @Nullable + public MongoDbFactory getDbFactory() { + return dbFactory; + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.support.ResourceTransactionManager#getResourceFactory() + */ + @Override + public MongoDbFactory getResourceFactory() { + return getRequiredDbFactory(); + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet() + */ + @Override + public void afterPropertiesSet() throws Exception { + getRequiredDbFactory(); + } + + private MongoResourceHolder newResourceHolder(TransactionDefinition definition, ClientSessionOptions options) { + + MongoDbFactory dbFactory = getResourceFactory(); + + MongoResourceHolder resourceHolder = new MongoResourceHolder(dbFactory.getSession(options), dbFactory); + resourceHolder.setTimeoutIfNotDefaulted(determineTimeout(definition)); + + return resourceHolder; + } + + /** + * @throws IllegalStateException if {@link #dbFactory} is {@literal null}. + */ + private MongoDbFactory getRequiredDbFactory() { + + Assert.state(dbFactory != null, + "MongoTransactionManager operates upon a MongoDbFactory. Did you forget to provide one? It's required."); + + return dbFactory; + } + + private static MongoTransactionObject extractMongoTransaction(Object transaction) { + + Assert.isInstanceOf(MongoTransactionObject.class, transaction, + () -> String.format("Expected to find a %s but it turned out to be %s.", MongoTransactionObject.class, + transaction.getClass())); + + return (MongoTransactionObject) transaction; + } + + private static MongoTransactionObject extractMongoTransaction(DefaultTransactionStatus status) { + + Assert.isInstanceOf(MongoTransactionObject.class, status.getTransaction(), + () -> String.format("Expected to find a %s but it turned out to be %s.", MongoTransactionObject.class, + status.getTransaction().getClass())); + + return (MongoTransactionObject) status.getTransaction(); + } + + private static String debugString(@Nullable ClientSession session) { + + if (session == null) { + return "null"; + } + + String debugString = "[" + ClassUtils.getShortName(session.getClass()) + "@" + + Integer.toHexString(session.hashCode()) + " "; + + try { + if (session.getServerSession() != null) { + debugString += "id = " + session.getServerSession().getIdentifier() + ", "; + debugString += "causallyConsistent = " + session.isCausallyConsistent() + ", "; + debugString += "txActive = " + session.hasActiveTransaction() + ", "; + debugString += "txNumber = " + session.getServerSession().getTransactionNumber() + ", "; + debugString += "statementId = " + session.getServerSession().getStatementId() + ", "; + debugString += "clusterTime = " + session.getClusterTime(); + } else { + debugString += "id = n/a"; + debugString += "causallyConsistent = " + session.isCausallyConsistent() + ", "; + debugString += "txActive = " + session.hasActiveTransaction() + ", "; + debugString += "clusterTime = " + session.getClusterTime(); + } + } catch (RuntimeException e) { + debugString += "error = " + e.getMessage(); + } + + debugString += "]"; + + return debugString; + } + + /** + * MongoDB specific transaction object, representing a {@link MongoResourceHolder}. Used as transaction object by + * {@link MongoTransactionManager}. + * + * @author Christoph Strobl + * @since 2.1 + * @see MongoResourceHolder + */ + static class MongoTransactionObject implements SmartTransactionObject { + + private @Nullable MongoResourceHolder resourceHolder; + + MongoTransactionObject(@Nullable MongoResourceHolder resourceHolder) { + this.resourceHolder = resourceHolder; + } + + void setResourceHolder(@Nullable MongoResourceHolder resourceHolder) { + this.resourceHolder = resourceHolder; + } + + boolean hasResourceHolder() { + return resourceHolder != null; + } + + void commitTransaction() { + getRequiredSession().commitTransaction(); + } + + void abortTransaction() { + getRequiredSession().abortTransaction(); + } + + void startTransaction(@Nullable TransactionOptions options) { + + ClientSession session = getRequiredSession(); + if (options != null) { + session.startTransaction(options); + } else { + session.startTransaction(); + } + } + + void closeSession() { + + ClientSession session = getRequiredSession(); + if (session.getServerSession() != null && !session.getServerSession().isClosed()) { + session.close(); + } + } + + @Nullable + ClientSession getSession() { + return resourceHolder != null ? resourceHolder.getSession() : null; + } + + private MongoResourceHolder getRequiredResourceHolder() { + + Assert.state(resourceHolder != null, "MongoResourceHolder is required but not present. o_O"); + return resourceHolder; + } + + private ClientSession getRequiredSession() { + + ClientSession session = getSession(); + Assert.state(session != null, "A Session is required but it turned out to be null."); + return session; + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.support.SmartTransactionObject#isRollbackOnly() + */ + @Override + public boolean isRollbackOnly() { + return this.resourceHolder != null && this.resourceHolder.isRollbackOnly(); + } + + /* + * (non-Javadoc) + * @see org.springframework.transaction.support.SmartTransactionObject#flush() + */ + @Override + public void flush() { + TransactionSynchronizationUtils.triggerFlush(); + } + + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/ReactiveMongoDatabaseFactory.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/ReactiveMongoDatabaseFactory.java index 889fae8ba..1b6dbf73b 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/ReactiveMongoDatabaseFactory.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/ReactiveMongoDatabaseFactory.java @@ -24,8 +24,8 @@ import org.springframework.dao.support.PersistenceExceptionTranslator; import org.springframework.data.mongodb.core.MongoExceptionTranslator; import com.mongodb.ClientSessionOptions; -import com.mongodb.reactivestreams.client.MongoDatabase; import com.mongodb.session.ClientSession; +import com.mongodb.reactivestreams.client.MongoDatabase; /** * Interface for factories creating reactive {@link MongoDatabase} instances. diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/SessionAwareMethodInterceptor.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/SessionAwareMethodInterceptor.java index 03123ee2e..ffa75657e 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/SessionAwareMethodInterceptor.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/SessionAwareMethodInterceptor.java @@ -57,6 +57,7 @@ public class SessionAwareMethodInterceptor implements MethodInterceptor { private final Class targetType; private final Class collectionType; private final Class databaseType; + private final Class sessionType; /** * Create a new SessionAwareMethodInterceptor for given target. @@ -71,12 +72,13 @@ public class SessionAwareMethodInterceptor implements MethodInterceptor { * {@code MongoCollection}. * @param target object type. */ - public SessionAwareMethodInterceptor(ClientSession session, T target, Class databaseType, - ClientSessionOperator databaseDecorator, Class collectionType, + public SessionAwareMethodInterceptor(ClientSession session, T target, Class sessionType, + Class databaseType, ClientSessionOperator databaseDecorator, Class collectionType, ClientSessionOperator collectionDecorator) { Assert.notNull(session, "ClientSession must not be null!"); Assert.notNull(target, "Target must not be null!"); + Assert.notNull(sessionType, "SessionType must not be null!"); Assert.notNull(databaseType, "Database type must not be null!"); Assert.notNull(databaseDecorator, "Database ClientSessionOperator must not be null!"); Assert.notNull(collectionType, "Collection type must not be null!"); @@ -90,6 +92,7 @@ public class SessionAwareMethodInterceptor implements MethodInterceptor { this.databaseDecorator = databaseDecorator; this.targetType = ClassUtils.isAssignable(databaseType, target.getClass()) ? databaseType : collectionType; + this.sessionType = sessionType; } /* @@ -114,7 +117,7 @@ public class SessionAwareMethodInterceptor implements MethodInterceptor { return methodInvocation.proceed(); } - Optional targetMethod = METHOD_CACHE.lookup(methodInvocation.getMethod(), targetType); + Optional targetMethod = METHOD_CACHE.lookup(methodInvocation.getMethod(), targetType, sessionType); return !targetMethod.isPresent() ? methodInvocation.proceed() : ReflectionUtils.invokeMethod(targetMethod.get(), target, @@ -171,18 +174,19 @@ public class SessionAwareMethodInterceptor implements MethodInterceptor { * @param targetClass * @return */ - Optional lookup(Method method, Class targetClass) { + Optional lookup(Method method, Class targetClass, Class sessionType) { return cache.computeIfAbsent(new MethodClassKey(method, targetClass), - val -> Optional.ofNullable(findTargetWithSession(method, targetClass))); + val -> Optional.ofNullable(findTargetWithSession(method, targetClass, sessionType))); } @Nullable - private Method findTargetWithSession(Method sourceMethod, Class targetType) { + private Method findTargetWithSession(Method sourceMethod, Class targetType, + Class sessionType) { Class[] argTypes = sourceMethod.getParameterTypes(); Class[] args = new Class[argTypes.length + 1]; - args[0] = ClientSession.class; + args[0] = sessionType; System.arraycopy(argTypes, 0, args, 1, argTypes.length); return ReflectionUtils.findMethod(targetType, sourceMethod.getName(), args); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/SessionSynchronization.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/SessionSynchronization.java new file mode 100644 index 000000000..9b0cb7bfa --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/SessionSynchronization.java @@ -0,0 +1,37 @@ +/* + * Copyright 2018 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 + * + * http://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.mongodb; + +/** + * {@link SessionSynchronization} is used along with {@link org.springframework.data.mongodb.core.MongoTemplate} to + * define in which type of transactions to participate if any. + * + * @author Christoph Strobl + * @since 2.1 + */ +public enum SessionSynchronization { + + /** + * Synchronize with native MongoDB transactions as those initiated via {@link MongoTransactionManager}. + */ + NATIVE, + + /** + * Synchronize with any ongoing transaction and initiate a MongoDB transaction when doing so by registering a MongoDB + * specific {@link org.springframework.transaction.support.ResourceHolderSynchronization}. + */ + ANY; +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/config/AbstractMongoConfiguration.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/config/AbstractMongoConfiguration.java index d7a3ad2e6..b53a16c5c 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/config/AbstractMongoConfiguration.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/config/AbstractMongoConfiguration.java @@ -40,8 +40,7 @@ import com.mongodb.MongoClient; * @see MongoConfigurationSupport */ @Configuration -public abstract class -AbstractMongoConfiguration extends MongoConfigurationSupport { +public abstract class AbstractMongoConfiguration extends MongoConfigurationSupport { /** * Return the {@link MongoClient} instance to connect to. Annotate with {@link Bean} in case you want to expose a @@ -111,4 +110,5 @@ AbstractMongoConfiguration extends MongoConfigurationSupport { return converter; } + } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableRemoveOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableRemoveOperation.java index 2c55bc982..5ad61d6b2 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableRemoveOperation.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableRemoveOperation.java @@ -86,6 +86,13 @@ public interface ExecutableRemoveOperation { */ DeleteResult all(); + /** + * Remove the first matching document. + * + * @return the {@link DeleteResult}. Never {@literal null}. + */ + DeleteResult one(); + /** * Remove and return all matching documents.
* NOTE The entire list of documents will be fetched before sending the actual delete commands. diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableRemoveOperationSupport.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableRemoveOperationSupport.java index a27108eee..50b75ef55 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableRemoveOperationSupport.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableRemoveOperationSupport.java @@ -98,10 +98,16 @@ class ExecutableRemoveOperationSupport implements ExecutableRemoveOperation { */ @Override public DeleteResult all() { + return template.doRemove(getCollectionName(), query, domainType, true); + } - String collectionName = getCollectionName(); - - return template.doRemove(collectionName, query, domainType); + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.ExecutableRemoveOperation.TerminatingRemove#one() + */ + @Override + public DeleteResult one() { + return template.doRemove(getCollectionName(), query, domainType, false); } /* diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoOperations.java index b5f5a9a2e..bcc815fe6 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoOperations.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoOperations.java @@ -46,10 +46,10 @@ import org.springframework.util.Assert; import com.mongodb.ClientSessionOptions; import com.mongodb.Cursor; import com.mongodb.ReadPreference; +import com.mongodb.client.ClientSession; import com.mongodb.client.MongoCollection; import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; -import com.mongodb.session.ClientSession; /** * Interface that specifies a basic set of MongoDB operations. Implemented by {@link MongoTemplate}. Not often used but diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java index 0932c2409..bf3f41af4 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java @@ -61,7 +61,9 @@ import org.springframework.data.mapping.PropertyPath; import org.springframework.data.mapping.PropertyReferenceException; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mapping.model.ConvertingPropertyAccessor; +import org.springframework.data.mongodb.MongoDatabaseUtils; import org.springframework.data.mongodb.MongoDbFactory; +import org.springframework.data.mongodb.SessionSynchronization; import org.springframework.data.mongodb.core.BulkOperations.BulkMode; import org.springframework.data.mongodb.core.DefaultBulkOperations.BulkOperationContext; import org.springframework.data.mongodb.core.aggregation.Aggregation; @@ -134,6 +136,7 @@ import com.mongodb.MongoException; import com.mongodb.ReadPreference; import com.mongodb.WriteConcern; import com.mongodb.client.AggregateIterable; +import com.mongodb.client.ClientSession; import com.mongodb.client.DistinctIterable; import com.mongodb.client.FindIterable; import com.mongodb.client.MapReduceIterable; @@ -141,19 +144,9 @@ import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoCursor; import com.mongodb.client.MongoDatabase; import com.mongodb.client.MongoIterable; -import com.mongodb.client.model.CountOptions; -import com.mongodb.client.model.CreateCollectionOptions; -import com.mongodb.client.model.DeleteOptions; -import com.mongodb.client.model.Filters; -import com.mongodb.client.model.FindOneAndDeleteOptions; -import com.mongodb.client.model.FindOneAndUpdateOptions; -import com.mongodb.client.model.ReturnDocument; -import com.mongodb.client.model.UpdateOptions; -import com.mongodb.client.model.ValidationAction; -import com.mongodb.client.model.ValidationLevel; +import com.mongodb.client.model.*; import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; -import com.mongodb.session.ClientSession; import com.mongodb.util.JSONParseException; /** @@ -214,6 +207,8 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, private @Nullable ResourceLoader resourceLoader; private @Nullable MongoPersistentEntityIndexCreator indexCreator; + private SessionSynchronization sessionSynchronization = SessionSynchronization.NATIVE; + /** * Constructor used for a basic template configuration * @@ -267,6 +262,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, this.mongoDbFactory = dbFactory; this.exceptionTranslator = that.exceptionTranslator; + this.sessionSynchronization = that.sessionSynchronization; this.mongoConverter = that.mongoConverter instanceof MappingMongoConverter ? getDefaultMongoConverter(dbFactory) : that.mongoConverter; this.queryMapper = that.queryMapper; @@ -582,6 +578,17 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, return new SessionBoundMongoTemplate(session, MongoTemplate.this); } + /** + * Define if {@link MongoTemplate} should participate in transactions. Default is set to + * {@link SessionSynchronization#NATIVE}.
+ * NOTE: MongoDB transactions require at least MongoDB 4.0. + * + * @since 2.1 + */ + public void setSessionSynchronization(SessionSynchronization sessionSynchronization) { + this.sessionSynchronization = sessionSynchronization; + } + /* * (non-Javadoc) * @see org.springframework.data.mongodb.core.MongoOperations#createCollection(java.lang.Class) @@ -687,7 +694,8 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, public Void doInCollection(MongoCollection collection) throws MongoException, DataAccessException { collection.drop(); if (LOGGER.isDebugEnabled()) { - LOGGER.debug("Dropped collection [{}]", collection.getNamespace().getCollectionName()); + LOGGER.debug("Dropped collection [{}]", + collection.getNamespace() != null ? collection.getNamespace().getCollectionName() : collectionName); } return null; } @@ -1158,7 +1166,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, * In case of using MongoDB Java driver version 3 the returned {@link WriteConcern} will be defaulted to * {@link WriteConcern#ACKNOWLEDGED} when {@link WriteResultChecking} is set to {@link WriteResultChecking#EXCEPTION}. * - * @param writeConcern any WriteConcern already configured or null + * @param mongoAction any MongoAction already configured or null * @return The prepared WriteConcern or null */ @Nullable @@ -1474,10 +1482,10 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, collection.withWriteConcern(writeConcernToUse).insertOne(dbDoc); } } else if (writeConcernToUse == null) { - collection.replaceOne(Filters.eq(ID_FIELD, dbDoc.get(ID_FIELD)), dbDoc, new UpdateOptions().upsert(true)); + collection.replaceOne(Filters.eq(ID_FIELD, dbDoc.get(ID_FIELD)), dbDoc, new ReplaceOptions().upsert(true)); } else { collection.withWriteConcern(writeConcernToUse).replaceOne(Filters.eq(ID_FIELD, dbDoc.get(ID_FIELD)), dbDoc, - new UpdateOptions().upsert(true)); + new ReplaceOptions().upsert(true)); } return dbDoc.get(ID_FIELD); } @@ -1583,7 +1591,12 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, collection = writeConcernToUse != null ? collection.withWriteConcern(writeConcernToUse) : collection; if (!UpdateMapper.isUpdateObject(updateObj)) { - return collection.replaceOne(queryObj, updateObj, opts); + + ReplaceOptions replaceOptions = new ReplaceOptions(); + replaceOptions.collation(opts.getCollation()); + replaceOptions.upsert(opts.isUpsert()); + + return collection.replaceOne(queryObj, updateObj, replaceOptions); } else { if (multi) { return collection.updateMany(queryObj, updateObj, opts); @@ -1619,7 +1632,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, Assert.notNull(object, "Object must not be null!"); Assert.hasText(collectionName, "Collection name must not be null or empty!"); - return doRemove(collectionName, getIdQueryFor(object), object.getClass()); + return doRemove(collectionName, getIdQueryFor(object), object.getClass(), false); } /** @@ -1708,7 +1721,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, @Override public DeleteResult remove(Query query, String collectionName) { - return doRemove(collectionName, query, null); + return doRemove(collectionName, query, null, true); } @Override @@ -1720,11 +1733,11 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, public DeleteResult remove(Query query, Class entityClass, String collectionName) { Assert.notNull(entityClass, "EntityClass must not be null!"); - return doRemove(collectionName, query, entityClass); + return doRemove(collectionName, query, entityClass, true); } protected DeleteResult doRemove(final String collectionName, final Query query, - @Nullable final Class entityClass) { + @Nullable final Class entityClass, boolean multi) { Assert.notNull(query, "Query must not be null!"); Assert.hasText(collectionName, "Collection name must not be null or empty!"); @@ -1749,7 +1762,6 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, WriteConcern writeConcernToUse = prepareWriteConcern(mongoAction); - DeleteResult dr = null; if (LOGGER.isDebugEnabled()) { LOGGER.debug("Remove using query: {} in collection: {}.", new Object[] { serializeToJsonSafely(removeQuery), collectionName }); @@ -1768,15 +1780,15 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, removeQuery = new Document(ID_FIELD, new Document("$in", ids)); } - if (writeConcernToUse == null) { - dr = collection.deleteMany(removeQuery, options); - } else { - dr = collection.withWriteConcern(writeConcernToUse).deleteMany(removeQuery, options); - } + MongoCollection collectionToUse = writeConcernToUse != null + ? collection.withWriteConcern(writeConcernToUse) : collection; + + DeleteResult result = multi ? collectionToUse.deleteMany(removeQuery, options) + : collection.deleteOne(removeQuery, options); maybeEmitEvent(new AfterDeleteEvent(queryObject, entityClass, collectionName)); - return dr; + return result; } }); } @@ -2288,7 +2300,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, } protected MongoDatabase doGetDatabase() { - return mongoDbFactory.getDb(); + return MongoDatabaseUtils.getDatabase(mongoDbFactory, sessionSynchronization); } protected MongoDatabase prepareDatabase(MongoDatabase database) { @@ -2350,7 +2362,8 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, // TODO: Emit a collection created event if (LOGGER.isDebugEnabled()) { - LOGGER.debug("Created collection [{}]", coll.getNamespace().getCollectionName()); + LOGGER.debug("Created collection [{}]", + coll.getNamespace() != null ? coll.getNamespace().getCollectionName() : collectionName); } return coll; } @@ -2861,7 +2874,8 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, if (LOGGER.isDebugEnabled()) { LOGGER.debug("findOne using query: {} fields: {} in db.collection: {}", serializeToJsonSafely(query), - serializeToJsonSafely(fields.orElseGet(Document::new)), collection.getNamespace().getFullName()); + serializeToJsonSafely(fields.orElseGet(Document::new)), + collection.getNamespace() != null ? collection.getNamespace().getFullName() : "n/a"); } if (fields.isPresent()) { @@ -3004,7 +3018,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, /** * Simple {@link DocumentCallback} that will transform {@link Document} into the given target type using the given - * {@link MongoReader}. + * {@link EntityReader}. * * @author Oliver Gierke * @author Christoph Strobl diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java index 6a64a0476..b974d2440 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java @@ -127,6 +127,7 @@ import com.mongodb.client.model.DeleteOptions; import com.mongodb.client.model.Filters; import com.mongodb.client.model.FindOneAndDeleteOptions; import com.mongodb.client.model.FindOneAndUpdateOptions; +import com.mongodb.client.model.ReplaceOptions; import com.mongodb.client.model.ReturnDocument; import com.mongodb.client.model.UpdateOptions; import com.mongodb.client.model.ValidationOptions; @@ -1470,10 +1471,10 @@ public class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati } } else if (writeConcernToUse == null) { publisher = collection.replaceOne(Filters.eq(ID_FIELD, document.get(ID_FIELD)), document, - new UpdateOptions().upsert(true)); + new ReplaceOptions().upsert(true)); } else { publisher = collection.withWriteConcern(writeConcernToUse) - .replaceOne(Filters.eq(ID_FIELD, document.get(ID_FIELD)), document, new UpdateOptions().upsert(true)); + .replaceOne(Filters.eq(ID_FIELD, document.get(ID_FIELD)), document, new ReplaceOptions().upsert(true)); } return Mono.from(publisher).map(o -> document.get(ID_FIELD)); @@ -1580,7 +1581,12 @@ public class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati query.getCollation().map(Collation::toMongoCollation).ifPresent(updateOptions::collation); if (!UpdateMapper.isUpdateObject(updateObj)) { - return collectionToUse.replaceOne(queryObj, updateObj, updateOptions); + + ReplaceOptions replaceOptions = new ReplaceOptions(); + replaceOptions.upsert(updateOptions.isUpsert()); + replaceOptions.collation(updateOptions.getCollation()); + + return collectionToUse.replaceOne(queryObj, updateObj, replaceOptions); } if (multi) { return collectionToUse.updateMany(queryObj, updateObj, updateOptions); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SessionCallback.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SessionCallback.java index dcc65f7f2..2f389219d 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SessionCallback.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SessionCallback.java @@ -25,6 +25,7 @@ import org.springframework.lang.Nullable; * @since 2.1 * @see com.mongodb.session.ClientSession */ +@FunctionalInterface public interface SessionCallback { /** diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SessionScoped.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SessionScoped.java index 939dbf678..0a1c218ca 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SessionScoped.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SessionScoped.java @@ -19,7 +19,7 @@ import java.util.function.Consumer; import org.springframework.lang.Nullable; -import com.mongodb.session.ClientSession; +import com.mongodb.client.ClientSession; /** * Gateway interface to execute {@link ClientSession} bound operations against MongoDB via a {@link SessionCallback}. diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SimpleMongoDbFactory.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SimpleMongoDbFactory.java index 3706db665..67626cbcb 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SimpleMongoDbFactory.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SimpleMongoDbFactory.java @@ -31,9 +31,9 @@ import com.mongodb.DB; import com.mongodb.MongoClient; import com.mongodb.MongoClientURI; import com.mongodb.WriteConcern; +import com.mongodb.client.ClientSession; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; -import com.mongodb.session.ClientSession; /** * Factory to create {@link MongoDatabase} instances from a {@link MongoClient} instance. @@ -243,22 +243,22 @@ public class SimpleMongoDbFactory implements DisposableBean, MongoDbFactory { return createProxyInstance(session, database, MongoDatabase.class); } - private MongoDatabase proxyDatabase(ClientSession session, MongoDatabase database) { + private MongoDatabase proxyDatabase(com.mongodb.session.ClientSession session, MongoDatabase database) { return createProxyInstance(session, database, MongoDatabase.class); } - private MongoCollection proxyCollection(ClientSession session, MongoCollection collection) { + private MongoCollection proxyCollection(com.mongodb.session.ClientSession session, MongoCollection collection) { return createProxyInstance(session, collection, MongoCollection.class); } - private T createProxyInstance(ClientSession session, T target, Class targetType) { + private T createProxyInstance(com.mongodb.session.ClientSession session, T target, Class targetType) { ProxyFactory factory = new ProxyFactory(); factory.setTarget(target); factory.setInterfaces(targetType); factory.setOpaque(true); - factory.addAdvice(new SessionAwareMethodInterceptor<>(session, target, MongoDatabase.class, this::proxyDatabase, + factory.addAdvice(new SessionAwareMethodInterceptor<>(session, target, ClientSession.class, MongoDatabase.class, this::proxyDatabase, MongoCollection.class, this::proxyCollection)); return targetType.cast(factory.getProxy()); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SimpleReactiveMongoDatabaseFactory.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SimpleReactiveMongoDatabaseFactory.java index 876923edd..726fb4e9b 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SimpleReactiveMongoDatabaseFactory.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SimpleReactiveMongoDatabaseFactory.java @@ -230,7 +230,7 @@ public class SimpleReactiveMongoDatabaseFactory implements DisposableBean, React factory.setInterfaces(targetType); factory.setOpaque(true); - factory.addAdvice(new SessionAwareMethodInterceptor<>(session, target, MongoDatabase.class, this::proxyDatabase, + factory.addAdvice(new SessionAwareMethodInterceptor<>(session, target, ClientSession.class, MongoDatabase.class, this::proxyDatabase, MongoCollection.class, this::proxyCollection)); return targetType.cast(factory.getProxy()); diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/MongoDatabaseUtilsUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/MongoDatabaseUtilsUnitTests.java new file mode 100644 index 000000000..47aadedd7 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/MongoDatabaseUtilsUnitTests.java @@ -0,0 +1,239 @@ +/* + * Copyright 2018 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 + * + * http://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.mongodb; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import javax.transaction.Status; +import javax.transaction.UserTransaction; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.jta.JtaTransactionManager; +import org.springframework.transaction.support.TransactionCallbackWithoutResult; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +import com.mongodb.client.ClientSession; +import com.mongodb.client.MongoDatabase; +import com.mongodb.session.ServerSession; + +/** + * @author Christoph Strobl + */ +@RunWith(MockitoJUnitRunner.class) +public class MongoDatabaseUtilsUnitTests { + + @Mock ClientSession session; + @Mock ServerSession serverSession; + @Mock MongoDbFactory dbFactory; + @Mock MongoDatabase db; + + @Mock UserTransaction userTransaction; + + @Before + public void setUp() { + + when(dbFactory.getSession(any())).thenReturn(session); + + when(dbFactory.withSession(session)).thenReturn(dbFactory); + + when(dbFactory.getDb()).thenReturn(db); + + when(session.getServerSession()).thenReturn(serverSession); + when(session.hasActiveTransaction()).thenReturn(true); + + when(serverSession.isClosed()).thenReturn(false); + } + + @After + public void verifyTransactionSynchronizationManagerState() { + + assertTrue(TransactionSynchronizationManager.getResourceMap().isEmpty()); + assertFalse(TransactionSynchronizationManager.isSynchronizationActive()); + assertNull(TransactionSynchronizationManager.getCurrentTransactionName()); + assertFalse(TransactionSynchronizationManager.isCurrentTransactionReadOnly()); + assertNull(TransactionSynchronizationManager.getCurrentTransactionIsolationLevel()); + assertFalse(TransactionSynchronizationManager.isActualTransactionActive()); + } + + @Test // DATAMONGO-1920 + public void shouldNotStartSessionWhenNoTransactionOngoing() { + + MongoDatabaseUtils.getDatabase(dbFactory, SessionSynchronization.NATIVE); + + verify(dbFactory, never()).getSession(any()); + verify(dbFactory, never()).withSession(any(ClientSession.class)); + } + + @Test // DATAMONGO-1920 + public void shouldParticipateInOngoingJtaTransactionWithCommitWhenSessionSychronizationIsAny() throws Exception { + + when(userTransaction.getStatus()).thenReturn(Status.STATUS_NO_TRANSACTION, Status.STATUS_ACTIVE, + Status.STATUS_ACTIVE); + + JtaTransactionManager txManager = new JtaTransactionManager(userTransaction); + TransactionTemplate txTemplate = new TransactionTemplate(txManager); + + txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) { + + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isTrue(); + assertThat(transactionStatus.isNewTransaction()).isTrue(); + assertThat(TransactionSynchronizationManager.hasResource(dbFactory)).isFalse(); + + MongoDatabaseUtils.getDatabase(dbFactory, SessionSynchronization.ANY); + + assertThat(TransactionSynchronizationManager.hasResource(dbFactory)).isTrue(); + } + }); + + verify(userTransaction).begin(); + + verify(session).startTransaction(); + verify(session).commitTransaction(); + verify(session).close(); + } + + @Test // DATAMONGO-1920 + public void shouldParticipateInOngoingJtaTransactionWithRollbackWhenSessionSychronizationIsAny() throws Exception { + + when(userTransaction.getStatus()).thenReturn(Status.STATUS_NO_TRANSACTION, Status.STATUS_ACTIVE, + Status.STATUS_ACTIVE); + + JtaTransactionManager txManager = new JtaTransactionManager(userTransaction); + TransactionTemplate txTemplate = new TransactionTemplate(txManager); + + txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) { + + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isTrue(); + assertThat(transactionStatus.isNewTransaction()).isTrue(); + assertThat(TransactionSynchronizationManager.hasResource(dbFactory)).isFalse(); + + MongoDatabaseUtils.getDatabase(dbFactory, SessionSynchronization.ANY); + + assertThat(TransactionSynchronizationManager.hasResource(dbFactory)).isTrue(); + + transactionStatus.setRollbackOnly(); + } + }); + + verify(userTransaction).rollback(); + + verify(session).startTransaction(); + verify(session).abortTransaction(); + verify(session).close(); + } + + @Test // DATAMONGO-1920 + public void shouldNotParticipateInOngoingJtaTransactionWithRollbackWhenSessionSychronizationIsNative() + throws Exception { + + when(userTransaction.getStatus()).thenReturn(Status.STATUS_NO_TRANSACTION, Status.STATUS_ACTIVE, + Status.STATUS_ACTIVE); + + JtaTransactionManager txManager = new JtaTransactionManager(userTransaction); + TransactionTemplate txTemplate = new TransactionTemplate(txManager); + + txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) { + + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isTrue(); + assertThat(transactionStatus.isNewTransaction()).isTrue(); + assertThat(TransactionSynchronizationManager.hasResource(dbFactory)).isFalse(); + + MongoDatabaseUtils.getDatabase(dbFactory, SessionSynchronization.NATIVE); + + assertThat(TransactionSynchronizationManager.hasResource(dbFactory)).isFalse(); + + transactionStatus.setRollbackOnly(); + } + }); + + verify(userTransaction).rollback(); + + verify(session, never()).startTransaction(); + verify(session, never()).abortTransaction(); + verify(session, never()).close(); + } + + @Test // DATAMONGO-1920 + public void shouldParticipateInOngoingMongoTransactionWhenSessionSychronizationIsNative() { + + MongoTransactionManager txManager = new MongoTransactionManager(dbFactory); + TransactionTemplate txTemplate = new TransactionTemplate(txManager); + + txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) { + + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isTrue(); + assertThat(transactionStatus.isNewTransaction()).isTrue(); + assertThat(TransactionSynchronizationManager.hasResource(dbFactory)).isTrue(); + + MongoDatabaseUtils.getDatabase(dbFactory, SessionSynchronization.NATIVE); + + transactionStatus.setRollbackOnly(); + } + }); + + verify(session).startTransaction(); + verify(session).abortTransaction(); + verify(session).close(); + } + + @Test // DATAMONGO-1920 + public void shouldParticipateInOngoingMongoTransactionWhenSessionSychronizationIsAny() { + + MongoTransactionManager txManager = new MongoTransactionManager(dbFactory); + TransactionTemplate txTemplate = new TransactionTemplate(txManager); + + txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) { + + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isTrue(); + assertThat(transactionStatus.isNewTransaction()).isTrue(); + assertThat(TransactionSynchronizationManager.hasResource(dbFactory)).isTrue(); + + MongoDatabaseUtils.getDatabase(dbFactory, SessionSynchronization.ANY); + + transactionStatus.setRollbackOnly(); + } + }); + + verify(session).startTransaction(); + verify(session).abortTransaction(); + verify(session).close(); + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/MongoTransactionManagerUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/MongoTransactionManagerUnitTests.java new file mode 100644 index 000000000..620a6e542 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/MongoTransactionManagerUnitTests.java @@ -0,0 +1,333 @@ +/* + * Copyright 2018 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 + * + * http://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.mongodb; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.UnexpectedRollbackException; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.transaction.support.TransactionCallbackWithoutResult; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +import com.mongodb.client.ClientSession; +import com.mongodb.client.MongoDatabase; +import com.mongodb.session.ServerSession; + +/** + * @author Christoph Strobl + */ +@RunWith(MockitoJUnitRunner.class) +public class MongoTransactionManagerUnitTests { + + @Mock ClientSession session; + @Mock ClientSession session2; + @Mock ServerSession serverSession; + @Mock MongoDbFactory dbFactory; + @Mock MongoDbFactory dbFactory2; + @Mock MongoDatabase db; + @Mock MongoDatabase db2; + + @Before + public void setUp() { + + when(dbFactory.getSession(any())).thenReturn(session, session2); + + when(dbFactory.withSession(session)).thenReturn(dbFactory); + when(dbFactory.withSession(session2)).thenReturn(dbFactory2); + + when(dbFactory.getDb()).thenReturn(db); + when(dbFactory2.getDb()).thenReturn(db2); + + when(session.getServerSession()).thenReturn(serverSession); + when(session2.getServerSession()).thenReturn(serverSession); + + when(serverSession.isClosed()).thenReturn(false); + } + + @After + public void verifyTransactionSynchronizationManager() { + + assertTrue(TransactionSynchronizationManager.getResourceMap().isEmpty()); + assertFalse(TransactionSynchronizationManager.isSynchronizationActive()); + } + + @Test // DATAMONGO-1920 + public void triggerCommitCorrectly() { + + MongoTransactionManager txManager = new MongoTransactionManager(dbFactory); + TransactionStatus txStatus = txManager.getTransaction(new DefaultTransactionDefinition()); + + MongoTemplate template = new MongoTemplate(dbFactory); + + template.execute(db -> { + db.drop(); + return null; + }); + + verify(dbFactory).withSession(eq(session)); + + txManager.commit(txStatus); + + verify(session).startTransaction(); + verify(session).commitTransaction(); + verify(session).close(); + } + + @Test // DATAMONGO-1920 + public void participateInOnGoingTransactionWithCommit() { + + MongoTransactionManager txManager = new MongoTransactionManager(dbFactory); + TransactionStatus txStatus = txManager.getTransaction(new DefaultTransactionDefinition()); + + MongoTemplate template = new MongoTemplate(dbFactory); + + template.execute(db -> { + db.drop(); + return null; + }); + + TransactionTemplate txTemplate = new TransactionTemplate(txManager); + txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + + template.execute(db -> { + db.drop(); + return null; + }); + } + }); + + verify(dbFactory, times(2)).withSession(eq(session)); + + txManager.commit(txStatus); + + verify(session).startTransaction(); + verify(session).commitTransaction(); + verify(session).close(); + } + + @Test // DATAMONGO-1920 + public void participateInOnGoingTransactionWithRollbackOnly() { + + MongoTransactionManager txManager = new MongoTransactionManager(dbFactory); + TransactionStatus txStatus = txManager.getTransaction(new DefaultTransactionDefinition()); + + MongoTemplate template = new MongoTemplate(dbFactory); + + template.execute(db -> { + db.drop(); + return null; + }); + + TransactionTemplate txTemplate = new TransactionTemplate(txManager); + txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + + template.execute(db -> { + db.drop(); + return null; + }); + + status.setRollbackOnly(); + } + }); + + verify(dbFactory, times(2)).withSession(eq(session)); + + assertThatExceptionOfType(UnexpectedRollbackException.class).isThrownBy(() -> txManager.commit(txStatus)); + + verify(session).startTransaction(); + verify(session).abortTransaction(); + verify(session).close(); + } + + @Test // DATAMONGO-1920 + public void triggerRollbackCorrectly() { + + MongoTransactionManager txManager = new MongoTransactionManager(dbFactory); + TransactionStatus txStatus = txManager.getTransaction(new DefaultTransactionDefinition()); + + MongoTemplate template = new MongoTemplate(dbFactory); + + template.execute(db -> { + db.drop(); + return null; + }); + + verify(dbFactory).withSession(eq(session)); + + txManager.rollback(txStatus); + + verify(session).startTransaction(); + verify(session).abortTransaction(); + verify(session).close(); + } + + @Test // DATAMONGO-1920 + public void suspendTransactionWhilePropagationNotSupported() { + + MongoTransactionManager txManager = new MongoTransactionManager(dbFactory); + TransactionStatus txStatus = txManager.getTransaction(new DefaultTransactionDefinition()); + + MongoTemplate template = new MongoTemplate(dbFactory); + + template.execute(db -> { + db.drop(); + return null; + }); + + TransactionTemplate txTemplate = new TransactionTemplate(txManager); + txTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_NOT_SUPPORTED); + txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + + template.execute(db -> { + db.drop(); + return null; + }); + } + }); + + template.execute(MongoDatabase::listCollections); + txManager.commit(txStatus); + + verify(session).startTransaction(); + verify(session2, never()).startTransaction(); + + verify(dbFactory, times(2)).withSession(eq(session)); + verify(dbFactory, never()).withSession(eq(session2)); + + verify(db, times(2)).drop(); + verify(db).listCollections(); + + verify(session).close(); + verify(session2, never()).close(); + } + + @Test // DATAMONGO-1920 + public void suspendTransactionWhilePropagationRequiresNew() { + + MongoTransactionManager txManager = new MongoTransactionManager(dbFactory); + TransactionStatus txStatus = txManager.getTransaction(new DefaultTransactionDefinition()); + + MongoTemplate template = new MongoTemplate(dbFactory); + + template.execute(db -> { + db.drop(); + return null; + }); + + TransactionTemplate txTemplate = new TransactionTemplate(txManager); + txTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + + template.execute(db -> { + db.drop(); + return null; + }); + } + }); + + template.execute(MongoDatabase::listCollections); + txManager.commit(txStatus); + + verify(session).startTransaction(); + verify(session2).startTransaction(); + + verify(dbFactory, times(2)).withSession(eq(session)); + verify(dbFactory).withSession(eq(session2)); + + verify(db).drop(); + verify(db2).drop(); + verify(db).listCollections(); + + verify(session).close(); + verify(session2).close(); + } + + @Test // DATAMONGO-1920 + public void readonlyShouldInitiateASessionStartAndCommitTransaction() { + + MongoTransactionManager txManager = new MongoTransactionManager(dbFactory); + + DefaultTransactionDefinition readonlyTxDefinition = new DefaultTransactionDefinition(); + readonlyTxDefinition.setReadOnly(true); + + TransactionStatus txStatus = txManager.getTransaction(readonlyTxDefinition); + + MongoTemplate template = new MongoTemplate(dbFactory); + + template.execute(db -> { + db.drop(); + return null; + }); + + verify(dbFactory).withSession(eq(session)); + + txManager.commit(txStatus); + + verify(session).startTransaction(); + verify(session).commitTransaction(); + verify(session).close(); + } + + @Test // DATAMONGO-1920 + public void readonlyShouldInitiateASessionStartAndRollbackTransaction() { + + MongoTransactionManager txManager = new MongoTransactionManager(dbFactory); + + DefaultTransactionDefinition readonlyTxDefinition = new DefaultTransactionDefinition(); + readonlyTxDefinition.setReadOnly(true); + + TransactionStatus txStatus = txManager.getTransaction(readonlyTxDefinition); + + MongoTemplate template = new MongoTemplate(dbFactory); + + template.execute(db -> { + db.drop(); + return null; + }); + + verify(dbFactory).withSession(eq(session)); + + txManager.rollback(txStatus); + + verify(session).startTransaction(); + verify(session).abortTransaction(); + verify(session).close(); + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/SessionAwareMethodInterceptorUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/SessionAwareMethodInterceptorUnitTests.java index 923035fcc..2df96b867 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/SessionAwareMethodInterceptorUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/SessionAwareMethodInterceptorUnitTests.java @@ -35,9 +35,9 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.ClassUtils; import com.mongodb.MongoClient; +import com.mongodb.client.ClientSession; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; -import com.mongodb.session.ClientSession; /** * Unit tests for {@link SessionAwareMethodInterceptor}. @@ -129,7 +129,7 @@ public class SessionAwareMethodInterceptorUnitTests { collection.getReadConcern(); assertThat(cache.contains(readConcernMethod, MongoCollection.class)).isTrue(); - assertThat(cache.lookup(readConcernMethod, MongoCollection.class)).isEmpty(); + assertThat(cache.lookup(readConcernMethod, MongoCollection.class, ClientSession.class)).isEmpty(); } @Test // DATAMONGO-1880 @@ -160,23 +160,23 @@ public class SessionAwareMethodInterceptorUnitTests { verify(otherCollection).drop(eq(session)); } - private MongoDatabase proxyDatabase(ClientSession session, MongoDatabase database) { + private MongoDatabase proxyDatabase(com.mongodb.session.ClientSession session, MongoDatabase database) { return createProxyInstance(session, database, MongoDatabase.class); } - private MongoCollection proxyCollection(ClientSession session, MongoCollection collection) { + private MongoCollection proxyCollection(com.mongodb.session.ClientSession session, MongoCollection collection) { return createProxyInstance(session, collection, MongoCollection.class); } - private T createProxyInstance(ClientSession session, T target, Class targetType) { + private T createProxyInstance(com.mongodb.session.ClientSession session, T target, Class targetType) { ProxyFactory factory = new ProxyFactory(); factory.setTarget(target); factory.setInterfaces(targetType); factory.setOpaque(true); - factory.addAdvice(new SessionAwareMethodInterceptor<>(session, target, MongoDatabase.class, this::proxyDatabase, - MongoCollection.class, this::proxyCollection)); + factory.addAdvice(new SessionAwareMethodInterceptor<>(session, target, ClientSession.class, MongoDatabase.class, + this::proxyDatabase, MongoCollection.class, this::proxyCollection)); return targetType.cast(factory.getProxy()); } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ClientSessionTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ClientSessionTests.java index cf207b219..c74697f21 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ClientSessionTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ClientSessionTests.java @@ -16,20 +16,29 @@ package org.springframework.data.mongodb.core; import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.mongodb.core.query.Criteria.*; +import static org.springframework.data.mongodb.core.query.Query.*; + +import lombok.AllArgsConstructor; +import lombok.Data; import org.bson.Document; import org.junit.Before; import org.junit.ClassRule; +import org.junit.Rule; import org.junit.Test; import org.junit.rules.TestRule; +import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.test.util.MongoTestUtils; +import org.springframework.data.mongodb.test.util.MongoVersion; import org.springframework.data.mongodb.test.util.MongoVersionRule; import org.springframework.data.mongodb.test.util.ReplicaSet; import org.springframework.data.util.Version; import com.mongodb.ClientSessionOptions; import com.mongodb.MongoClient; -import com.mongodb.session.ClientSession; +import com.mongodb.client.ClientSession; /** * @author Christoph Strobl @@ -37,8 +46,11 @@ import com.mongodb.session.ClientSession; */ public class ClientSessionTests { - public static @ClassRule MongoVersionRule REQUIRES_AT_LEAST_3_6_0 = MongoVersionRule.atLeast(Version.parse("3.6.0")); public static @ClassRule TestRule replSet = ReplicaSet.required(); + public @Rule MongoVersionRule REQUIRES_AT_LEAST_3_6_0 = MongoVersionRule.atLeast(Version.parse("3.6.0")); + + private static final String DB_NAME = "client-session-tests"; + private static final String COLLECTION_NAME = "test"; MongoTemplate template; MongoClient client; @@ -46,11 +58,12 @@ public class ClientSessionTests { @Before public void setUp() { - client = new MongoClient(); - template = new MongoTemplate(client, "reflective-client-session-tests"); - template.getDb().getCollection("test").drop(); + client = MongoTestUtils.replSetClient(); - template.getDb().getCollection("test").insertOne(new Document("_id", "id-1").append("value", "spring")); + MongoTestUtils.createOrReplaceCollection(DB_NAME, COLLECTION_NAME, client); + + template = new MongoTemplate(client, DB_NAME); + template.getDb().getCollection(COLLECTION_NAME).insertOne(new Document("_id", "id-1").append("value", "spring")); } @Test // DATAMONGO-1880 @@ -69,4 +82,66 @@ public class ClientSessionTests { session.close(); } + + @Test // DATAMONGO-1920 + @MongoVersion(asOf = "3.7.3") + public void withCommittedTransaction() { + + ClientSession session = client.startSession(ClientSessionOptions.builder().causallyConsistent(true).build()); + + assertThat(session.getOperationTime()).isNull(); + + session.startTransaction(); + + SomeDoc saved = template.withSession(() -> session).execute(action -> { + + SomeDoc doc = new SomeDoc("id-2", "value2"); + action.insert(doc); + return doc; + }); + + session.commitTransaction(); + session.close(); + + assertThat(saved).isNotNull(); + assertThat(session.getOperationTime()).isNotNull(); + + assertThat(template.exists(query(where("id").is(saved.getId())), SomeDoc.class)).isTrue(); + } + + @Test // DATAMONGO-1920 + @MongoVersion(asOf = "3.7.3") + public void withAbortedTransaction() { + + ClientSession session = client.startSession(ClientSessionOptions.builder().causallyConsistent(true).build()); + + assertThat(session.getOperationTime()).isNull(); + + session.startTransaction(); + + SomeDoc saved = template.withSession(() -> session).execute(action -> { + + SomeDoc doc = new SomeDoc("id-2", "value2"); + action.insert(doc); + return doc; + }); + + session.abortTransaction(); + session.close(); + + assertThat(saved).isNotNull(); + assertThat(session.getOperationTime()).isNotNull(); + + assertThat(template.exists(query(where("id").is(saved.getId())), SomeDoc.class)).isFalse(); + } + + @Data + @AllArgsConstructor + @org.springframework.data.mongodb.core.mapping.Document(COLLECTION_NAME) + static class SomeDoc { + + @Id String id; + String value; + } + } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateTransactionTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateTransactionTests.java new file mode 100644 index 000000000..2a455c4eb --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateTransactionTests.java @@ -0,0 +1,183 @@ +/* + * Copyright 2018 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 + * + * http://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.mongodb.core; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.mongodb.core.query.Criteria.*; +import static org.springframework.data.mongodb.core.query.Query.*; +import static org.springframework.data.mongodb.test.util.MongoTestUtils.*; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.bson.Document; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.RuleChain; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.annotation.Id; +import org.springframework.data.domain.Persistable; +import org.springframework.data.mongodb.MongoDbFactory; +import org.springframework.data.mongodb.MongoTransactionManager; +import org.springframework.data.mongodb.config.AbstractMongoConfiguration; +import org.springframework.data.mongodb.test.util.AfterTransactionAssertion; +import org.springframework.data.mongodb.test.util.MongoTestUtils; +import org.springframework.data.mongodb.test.util.MongoVersionRule; +import org.springframework.data.mongodb.test.util.ReplicaSet; +import org.springframework.data.util.Version; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.transaction.AfterTransaction; +import org.springframework.test.context.transaction.BeforeTransaction; +import org.springframework.transaction.annotation.Transactional; + +import com.mongodb.MongoClient; +import com.mongodb.ReadPreference; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Filters; + +/** + * @author Christoph Strobl + * @currentRead Shadow's Edge - Brent Weeks + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration +@Transactional(transactionManager = "txManager") +public class MongoTemplateTransactionTests { + + public static @ClassRule RuleChain TEST_RULES = RuleChain.outerRule(MongoVersionRule.atLeast(Version.parse("3.7.3"))) + .around(ReplicaSet.required()); + + static final String DB_NAME = "template-tx-tests"; + static final String COLLECTION_NAME = "assassins"; + + @Configuration + static class Config extends AbstractMongoConfiguration { + + @Bean + public MongoClient mongoClient() { + return MongoTestUtils.replSetClient(); + } + + @Override + protected String getDatabaseName() { + return DB_NAME; + } + + @Bean + MongoTransactionManager txManager(MongoDbFactory dbFactory) { + return new MongoTransactionManager(dbFactory); + } + } + + @Autowired MongoTemplate template; + @Autowired MongoClient client; + + List>> assertionList; + + @Before + public void setUp() { + + template.setReadPreference(ReadPreference.primary()); + assertionList = new CopyOnWriteArrayList<>(); + } + + @BeforeTransaction + public void xxx() { + + createOrReplaceCollection(DB_NAME, COLLECTION_NAME, client); + } + + @AfterTransaction + public void verifyDbState() throws InterruptedException { + + MongoCollection collection = client.getDatabase(DB_NAME).withReadPreference(ReadPreference.primary()) + .getCollection(COLLECTION_NAME); + + assertionList.forEach(it -> { + + boolean isPresent = collection.count(Filters.eq("_id", it.getId())) != 0; + + assertThat(isPresent).isEqualTo(it.shouldBePresent()) + .withFailMessage(String.format("After transaction entity %s should %s.", it.getPersistable(), + it.shouldBePresent() ? "be present" : "NOT be present")); + }); + } + + @Rollback(false) + @Test // DATAMONGO-1920 + public void shouldOperateCommitCorrectly() { + + Assassin hu = new Assassin("hu", "Hu Gibbet"); + template.save(hu); + + assertAfterTransaction(hu).isPresent(); + } + + @Test // DATAMONGO-1920 + public void shouldOperateRollbackCorrectly() { + + Assassin vi = new Assassin("vi", "Viridiana Sovari"); + template.save(vi); + + assertAfterTransaction(vi).isNotPresent(); + } + + @Test // DATAMONGO-1920 + public void shouldBeAbleToViewChangesDuringTransaction() throws InterruptedException { + + Assassin durzo = new Assassin("durzo", "Durzo Blint"); + template.save(durzo); + + Thread.sleep(100); + Assassin retrieved = template.findOne(query(where("id").is(durzo.getId())), Assassin.class); + + assertThat(retrieved).isEqualTo(durzo); + + assertAfterTransaction(durzo).isNotPresent(); + } + + // --- Just some helpers and tests entities + + private AfterTransactionAssertion assertAfterTransaction(Assassin assassin) { + + AfterTransactionAssertion assertion = new AfterTransactionAssertion(assassin); + assertionList.add(assertion); + return assertion; + } + + @Data + @AllArgsConstructor + @org.springframework.data.mongodb.core.mapping.Document(COLLECTION_NAME) + static class Assassin implements Persistable { + + @Id String id; + String name; + + @Override + public boolean isNew() { + return id == null; + } + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java index 8451d8c52..ccee9a1bc 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java @@ -22,6 +22,7 @@ import static org.mockito.Mockito.any; import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; import static org.springframework.data.mongodb.test.util.IsBsonObject.*; +import com.mongodb.client.model.ReplaceOptions; import lombok.Data; import java.math.BigInteger; @@ -716,7 +717,7 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { @Test // DATAMONGO-1518 public void findAndRemoveManyShouldUseCollationWhenPresent() { - template.doRemove("collection-1", new BasicQuery("{}").collation(Collation.of("fr")), AutogenerateableId.class); + template.doRemove("collection-1", new BasicQuery("{}").collation(Collation.of("fr")), AutogenerateableId.class, true); ArgumentCaptor options = ArgumentCaptor.forClass(DeleteOptions.class); verify(collection).deleteMany(any(), options.capture()); @@ -754,7 +755,7 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { template.updateFirst(new BasicQuery("{}").collation(Collation.of("fr")), new Update(), AutogenerateableId.class); - ArgumentCaptor options = ArgumentCaptor.forClass(UpdateOptions.class); + ArgumentCaptor options = ArgumentCaptor.forClass(ReplaceOptions.class); verify(collection).replaceOne(any(), any(), options.capture()); assertThat(options.getValue().getCollation().getLocale(), is("fr")); diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java index da73018cc..fd44ad563 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java @@ -21,6 +21,7 @@ import static org.mockito.Mockito.*; import static org.mockito.Mockito.any; import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; +import com.mongodb.client.model.ReplaceOptions; import lombok.Data; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -233,12 +234,12 @@ public class ReactiveMongoTemplateUnitTests { @Test // DATAMONGO-1518 public void replaceOneShouldUseCollationWhenPresent() { - when(collection.replaceOne(any(Bson.class), any(), any())).thenReturn(Mono.empty()); + when(collection.replaceOne(any(Bson.class), any(), any(ReplaceOptions.class))).thenReturn(Mono.empty()); template.updateFirst(new BasicQuery("{}").collation(Collation.of("fr")), new Update(), AutogenerateableId.class) .subscribe(); - ArgumentCaptor options = ArgumentCaptor.forClass(UpdateOptions.class); + ArgumentCaptor options = ArgumentCaptor.forClass(ReplaceOptions.class); verify(collection).replaceOne(any(Bson.class), any(), options.capture()); assertThat(options.getValue().getCollation().getLocale(), is("fr")); diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SessionBoundMongoTemplateTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SessionBoundMongoTemplateTests.java index 3d1a369ee..5376d9e0f 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SessionBoundMongoTemplateTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SessionBoundMongoTemplateTests.java @@ -61,9 +61,9 @@ import org.springframework.test.util.ReflectionTestUtils; import com.mongodb.ClientSessionOptions; import com.mongodb.MongoClient; +import com.mongodb.client.ClientSession; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; -import com.mongodb.session.ClientSession; /** * Integration tests for {@link SessionBoundMongoTemplate} operating up an active {@link ClientSession}. diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SessionBoundMongoTemplateUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SessionBoundMongoTemplateUnitTests.java index c510a002b..0087bad36 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SessionBoundMongoTemplateUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SessionBoundMongoTemplateUnitTests.java @@ -56,7 +56,7 @@ import com.mongodb.client.model.CountOptions; import com.mongodb.client.model.DeleteOptions; import com.mongodb.client.model.FindOneAndUpdateOptions; import com.mongodb.client.model.UpdateOptions; -import com.mongodb.session.ClientSession; +import com.mongodb.client.ClientSession; /** * Unit test for {@link SessionBoundMongoTemplate} making sure a proxied {@link MongoCollection} and diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SimpleMongoDbFactoryUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SimpleMongoDbFactoryUnitTests.java index fa5a9249d..641522d0a 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SimpleMongoDbFactoryUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SimpleMongoDbFactoryUnitTests.java @@ -37,7 +37,7 @@ import org.springframework.test.util.ReflectionTestUtils; import com.mongodb.MongoClient; import com.mongodb.MongoClientURI; import com.mongodb.client.MongoDatabase; -import com.mongodb.session.ClientSession; +import com.mongodb.client.ClientSession; /** * Unit tests for {@link SimpleMongoDbFactory}. diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepositoryTransactionalTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepositoryTransactionalTests.java new file mode 100644 index 000000000..44b99ea18 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepositoryTransactionalTests.java @@ -0,0 +1,192 @@ +/* + * Copyright 2018 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 + * + * http://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.mongodb.repository; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.mongodb.test.util.MongoTestUtils.*; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.RuleChain; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Persistable; +import org.springframework.data.mongodb.MongoDbFactory; +import org.springframework.data.mongodb.MongoTransactionManager; +import org.springframework.data.mongodb.config.AbstractMongoConfiguration; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; +import org.springframework.data.mongodb.test.util.AfterTransactionAssertion; +import org.springframework.data.mongodb.test.util.MongoTestUtils; +import org.springframework.data.mongodb.test.util.MongoVersionRule; +import org.springframework.data.mongodb.test.util.ReplicaSet; +import org.springframework.data.util.Version; +import org.springframework.lang.Nullable; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.transaction.AfterTransaction; +import org.springframework.test.context.transaction.BeforeTransaction; +import org.springframework.transaction.annotation.Transactional; + +import com.mongodb.MongoClient; +import com.mongodb.MongoClientOptions; +import com.mongodb.ReadPreference; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Filters; + +/** + * @author Christoph Strobl + * @currentRead Shadow's Edge - Brent Weeks + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration +@Transactional(transactionManager = "txManager") +public class PersonRepositoryTransactionalTests { + + public static @ClassRule RuleChain TEST_RULES = RuleChain.outerRule(MongoVersionRule.atLeast(Version.parse("3.7.3"))) + .around(ReplicaSet.required()); + + static final String DB_NAME = "repository-tx-tests"; + + @Configuration + @EnableMongoRepositories + static class Config extends AbstractMongoConfiguration { + + @Bean + public MongoClient mongoClient() { + return MongoTestUtils.replSetClient(); + } + + @Override + protected String getDatabaseName() { + return DB_NAME; + } + + @Bean + MongoTransactionManager txManager(MongoDbFactory dbFactory) { + return new MongoTransactionManager(dbFactory); + } + } + + public @Rule ExpectedException expectedException = ExpectedException.none(); + + @Autowired MongoClient client; + @Autowired PersonRepository repository; + @Autowired MongoTemplate template; + + Person durzo, kylar, vi; + + List all; + + List>> assertionList; + + @Before + public void setUp() throws InterruptedException { + assertionList = new CopyOnWriteArrayList<>(); + } + + @BeforeTransaction + public void beforeTransaction() throws InterruptedException { + + createOrReplaceCollection(DB_NAME, template.getCollectionName(Person.class), client); + + durzo = new Person("Durzo", "Blint", 700); + kylar = new Person("Kylar", "Stern", 21); + vi = new Person("Viridiana", "Sovari", 20); + + all = repository.saveAll(Arrays.asList(durzo, kylar, vi)); + } + + @AfterTransaction + public void verifyDbState() throws InterruptedException { + + MongoCollection collection = client.getDatabase(DB_NAME) + .getCollection(template.getCollectionName(Person.class)); + + assertionList.forEach(it -> { + + boolean isPresent = collection.find(Filters.eq("_id", new ObjectId(it.getId().toString()))).limit(1).iterator() + .hasNext(); + + assertThat(isPresent).isEqualTo(it.shouldBePresent()) + .withFailMessage(String.format("After transaction entity %s should %s.", it.getPersistable(), + it.shouldBePresent() ? "be present" : "NOT be present")); + }); + } + + @Rollback(false) + @Test // DATAMONGO-1920 + public void shouldHonorCommitForDerivedQuery() { + + repository.removePersonByLastnameUsingAnnotatedQuery(durzo.getLastname()); + + assertAfterTransaction(durzo).isNotPresent(); + } + + @Rollback(false) + @Test // DATAMONGO-1920 + public void shouldHonorCommit() { + + Person hu = new Person("Hu", "Gibbet", 43); + + repository.save(hu); + + assertAfterTransaction(hu).isPresent(); + } + + @Test // DATAMONGO-1920 + public void shouldHonorRollback() { + + Person hu = new Person("Hu", "Gibbet", 43); + + repository.save(hu); + + assertAfterTransaction(hu).isNotPresent(); + } + + private AfterTransactionAssertion assertAfterTransaction(Person person) { + + AfterTransactionAssertion assertion = new AfterTransactionAssertion(new Persistable() { + + @Nullable + @Override + public Object getId() { + return person.id; + } + + @Override + public boolean isNew() { + return person.id != null; + } + }); + + assertionList.add(assertion); + return assertion; + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/AfterTransactionAssertion.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/AfterTransactionAssertion.java new file mode 100644 index 000000000..f043ddfd7 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/AfterTransactionAssertion.java @@ -0,0 +1,47 @@ +/* + * Copyright 2018 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 + * + * http://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.mongodb.test.util; + +import lombok.Data; + +import org.springframework.data.domain.Persistable; + +/** + * @author Christoph Strobl + * @currentRead Shadow's Edge - Brent Weeks + */ +@Data +public class AfterTransactionAssertion { + + private final T persistable; + private boolean presentAfterTransaction; + + public void isPresent() { + presentAfterTransaction = true; + } + + public void isNotPresent() { + presentAfterTransaction = false; + } + + public Object getId() { + return persistable.getId(); + } + + public boolean shouldBePresent() { + return presentAfterTransaction; + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestUtils.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestUtils.java new file mode 100644 index 000000000..023665b41 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestUtils.java @@ -0,0 +1,72 @@ +/* + * Copyright 2018 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 + * + * http://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.mongodb.test.util; + +import org.bson.Document; + +import com.mongodb.MongoClient; +import com.mongodb.MongoClientOptions; +import com.mongodb.ReadPreference; +import com.mongodb.WriteConcern; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; + +/** + * @author Christoph Strobl + */ +public class MongoTestUtils { + + /** + * Create a {@link com.mongodb.client.MongoCollection} if it does not exist, or drop and recreate it if it does. + * + * @param dbName must not be {@literal null}. + * @param collectionName must not be {@literal null}. + * @param client must not be {@literal null}. + */ + public static MongoCollection createOrReplaceCollection(String dbName, String collectionName, + MongoClient client) { + + MongoDatabase database = client.getDatabase(dbName); + + boolean collectionExists = database.listCollections().filter(new Document("name", collectionName)).first() != null; + + if (collectionExists) { + database.getCollection(collectionName).drop(); + } + + database.createCollection(collectionName); + + try { + Thread.sleep(10); // server replication time + } catch (InterruptedException e) { + e.printStackTrace(); + } + + return database.getCollection(collectionName); + } + + /** + * Create a new {@link MongoClient} with defaults suitable for replica set usage. + * + * @return new instance of {@link MongoClient}. + */ + public static MongoClient replSetClient() { + + return new MongoClient("localhost", + MongoClientOptions.builder().requiredReplicaSetName("rs0").build()); + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoVersionRule.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoVersionRule.java index cd124bc62..697c82561 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoVersionRule.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoVersionRule.java @@ -128,12 +128,20 @@ public class MongoVersionRule implements TestRule { Version maxVersion = MongoVersionRule.this.maxVersion.equals(ANY) ? DEFAULT_HIGH : MongoVersionRule.this.maxVersion; - if (MongoVersionRule.this.minVersion.equals(ANY) && MongoVersionRule.this.maxVersion.equals(ANY)) { + if (description.getAnnotation(MongoVersion.class) != null) { MongoVersion version = description.getAnnotation(MongoVersion.class); if (version != null) { - minVersion = Version.parse(version.asOf()); - maxVersion = Version.parse(version.until()); + + Version tmpMinVersion = Version.parse(version.asOf()); + if (!tmpMinVersion.equals(ANY) && !tmpMinVersion.equals(DEFAULT_LOW)) { + minVersion = tmpMinVersion; + } + + Version tmpMaxVersion = Version.parse(version.until()); + if (!tmpMaxVersion.equals(ANY) && !tmpMaxVersion.equals(DEFAULT_HIGH)) { + maxVersion = tmpMaxVersion; + } } } diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index 155dccdf3..5e8f2cc79 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -27,7 +27,7 @@ include::{spring-data-commons-docs}/repositories.adoc[] include::reference/introduction.adoc[] include::reference/mongodb.adoc[] include::reference/reactive-mongodb.adoc[] -include::reference/client-session.adoc[] +include::reference/client-session-transactions.adoc[] include::reference/mongo-repositories.adoc[] include::reference/reactive-mongo-repositories.adoc[] include::{spring-data-commons-docs}/auditing.adoc[] diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index 47edadc08..dc6ffd35a 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -10,7 +10,8 @@ * <> for queries and collection creation. * <> for imperative and reactive drivers. * Tailable cursors for imperative driver. -* <> support for the imperative and reactive Template API. +* <> support for the imperative and reactive Template API. +* <> support and MongoDB specific transaction manager implementation. [[new-features.2-0-0]] == What's new in Spring Data MongoDB 2.0 diff --git a/src/main/asciidoc/reference/client-session-transactions.adoc b/src/main/asciidoc/reference/client-session-transactions.adoc new file mode 100644 index 000000000..0112712c0 --- /dev/null +++ b/src/main/asciidoc/reference/client-session-transactions.adoc @@ -0,0 +1,200 @@ +[[mongo.sessions]] += MongoDB Sessions + +As of version 3.6 MongoDB supports a concept of Sessions. The use of sessions enables MongoDBs https://docs.mongodb.com/manual/core/read-isolation-consistency-recency/#causal-consistency[Causal Consistency] model guaranteeing to execute operations in an order that respect their causal relationships. Those are split into ``ServerSession``s and ``ClientSession``s. In the following when we speak of session we refer to `ClientSession`. + +WARNING: Operations within a client session are not isolated from operations outside the session. + +Both `MongoOperations` and `ReactiveMongoOperations` provide gateway methods for tying a `ClientSession` to the operations themselves. `MongoCollection` and `MongoDatabase` use session proxy objects implementing MongoDB's collection and and database interfaces so there's no need to add a session on each call. This means that a potential call to `MongoCollection#find()` is delegated to `MongoCollection#find(ClientSession)`. + +NOTE: Methods like `(Reactive)MongoOperations#getCollection` returning native MongoDB Java Driver gateway objects, such as `MongoCollection`, that themselves offer dedicated methods for `ClientSession` are *NOT* be session-proxied. So make sure to provide the `ClientSession` where needed when interacting directly with a `MongoCollection` or `MongoDatabase` and not via one of the `#execute` callbacks on `MongoOperations`. + +.ClientSession with `MongoOperations` +==== +[source,java] +---- +ClientSessionOptions sessionOptions = ClientSessionOptions.builder() + .causallyConsistent(true) + .build(); + +ClientSession session = client.startSession(sessionOptions); <1> + +template.withSession(() -> session) + .execute(action -> { + + Query query = query(where("name").is("Durzo Blint")); + Person durzo = action.findOne(query, Person.class); <2> + + Person azoth = new Person("Kylar Stern"); + azoth.setMaster(durzo); + + action.insert(azoth); <2> + + return azoth; + }); + +session.close() <4> +---- +<1> Obtain a new session from the server. +<2> Use `MongoOperation` methods as before. The `ClientSession` gets applied automatically. +<3> Make sure to close the `ClientSession`. +==== + +WARNING: When dealing with ``DBRef``s, especially lazily loaded ones, it is essential to **not** close the `ClientSession` before all data is loaded. Otherwise, lazy fetch fails. + +The reactive counterpart uses the very same building blocks as the imperative one. + +.ClientSession with `ReactiveMongoOperations` +==== +[source,java] +---- +ClientSessionOptions sessionOptions = ClientSessionOptions.builder() + .causallyConsistent(true) + .build(); + +Publisher session = client.startSession(sessionOptions); <1> + +template.withSession(session) + .execute(action -> { + + Query query = query(where("name").is("Durzo Blint")); + return action.findOne(query, Person.class) + .flatMap(durzo -> { + + Person azoth = new Person("Kylar Stern"); + azoth.setMaster(durzo); + + return action.insert(azoth); <2> + }); + }, ClientSession::close) <3> + .subscribe(); +---- +<1> Obtain a `Publisher` for new session retrieval. +<2> Use `ReactiveMongoOperation` methods as before. The `ClientSession` is obtained and applied automatically. +<3> Make sure to close the `ClientSession`. +==== + +By using a `Publisher` providing the actual session you can defer session acquisition to the point of actual subscription. +Still you need to close the session when done in order to not pollute the server with stale sessions. Use the `doFinally` hook on `execute` to call `ClientSession#close()` when you don't need the session any more. +In case you prefer having more control over the session itself, you can always obtain the `ClientSession` via the driver and provide it via a `Supplier`. + + +[[mongo.transactions]] += MongoDB Transactions + +As of version 4 MongoDB supports https://www.mongodb.com/transactions[Transactions]. Transactions are built on top of <> and therefore require an active `ClientSession`. + +NOTE: By default, unless you specify a `MongoTransactionManager` within your application context, transaction support is **DISABLED**. You may use `setSessionSynchronization(ANY)` to participate in ongoing non native MongoDB transactions. + +To get full programmatic control over transactions you may want to use the session callback on `MongoOperations`. + +.Programmatic transactions +==== +[source,java] +---- +ClientSession session = client.startSession(options); <1> + +template.withSession(session) + .execute(action -> { + + session.startTransaction(); <2> + + try { + + Step step = // ...; + action.insert(step); + + process(step); + + action.update(Step.class).apply(Update.set("state", // ... + + session.commitTransaction(); <3> + + } catch (RuntimeException e) { + session.abortTransaction(); <4> + } + }, ClientSession::close) <5> + .subscribe(); +---- +<1> Obtain a new `ClientSession`. +<2> Start the transaction. +<3> If everything works out as expected, go on and commit the changes. +<4> Something broke, just roll back everything. +<5> Do not forget to close the session when done. +==== + +The above example allows you to have full control over transactional behavior while using the session scoped `MongoOperations` instance within the callback to ensure the session is passed on to each and every server call. +To avoid some of the overhead that comes with this approach usage of a `TransactionTemplate` can take away some of the noise of manual transaction flow. + +== Transactions with TransactionTemplate + +.Transactions with TransactionTemplate +==== +[source,java] +---- +template.setSessionSynchronization(ANY); <1> + +// ... + +TransactionTemplate txTemplate = new TransactionTemplate(anyTxManager); <2> + +txTemplate.execute(new TransactionCallbackWithoutResult() { + + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { <3> + + Step step = // ...; + template.insert(step); + + process(step); + + template.update(Step.class).apply(Update.set("state", // ... + }; +}); +---- +<1> Manually enable transaction synchronization. +<2> Create the `TransactionTemplate` using the provided `PlatformTransactionManager`. +<3> Within the callback the `ClientSession` and transaction are already registered. +==== + +== Transactions with MongoTransactionManager + +`MongoTransactionManager` is the gateway to the well known Spring transaction support. It allows applications to use http://docs.spring.io/spring/docs/{springVersion}/spring-framework-reference/html/transaction.html[managed transaction features of Spring]. +The `MongoTransactionManager` binds a `ClientSession` to the thread. `MongoTemplate` automatically detects those and operates on them accordingly. `MongoTemplate` can also participate in other, ongoing transactions. + +.Transactions with MongoTransactionManager +==== +[source,java] +---- +@Configuration +static class Config extends AbstractMongoConfiguration { + + @Bean + MongoTransactionManager transactionManager(MongoDbFactory dbFactory) { <1> + return new MongoTransactionManager(dbFactory); + } + + // ... +} + +@Component +public class StateService { + + @Transactional + void someBusinessFunction(Step step) { <2> + + template.insert(step); + + process(step); + + template.update(Step.class).apply(Update.set("state", // ... + }; +}); + +---- +<1> Register `MongoTransactionManager` in the application context. +<2> Mark methods as transactional. +==== + +NOTE: `@Transactional(readOnly = true)` advises the `MongoTransactionManager` to also start a transaction adding the + `ClientSession` to outgoing requests. \ No newline at end of file diff --git a/src/main/asciidoc/reference/client-session.adoc b/src/main/asciidoc/reference/client-session.adoc deleted file mode 100644 index bbd9ce784..000000000 --- a/src/main/asciidoc/reference/client-session.adoc +++ /dev/null @@ -1,79 +0,0 @@ -[[mongo.sessions]] -= MongoDB Sessions - -As of version 3.6 MongoDB supports a concept of Sessions. The use of sessions enables MongoDBs https://docs.mongodb.com/manual/core/read-isolation-consistency-recency/#causal-consistency[Causal Consistency] model guaranteeing to execute operations in an order that respect their causal relationships. Those are split into ``ServerSession``s and ``ClientSession``s. In the following when we speak of session we refer to `ClientSession`. - -WARNING: Operations within a client session are not isolated from operations outside the session. - -Both `MongoOperations` and `ReactiveMongoOperations` provide gateway methods for tying a `ClientSession` to the operations themselves. `MongoCollection` and `MongoDatabase` use session proxy objects implementing MongoDB's collection and and database interfaces so there's no need to add a session on each call. This means that a potential call to `MongoCollection#find()` is delegated to `MongoCollection#find(ClientSession)`. - -NOTE: Methods like `(Reactive)MongoOperations#getCollection` returning native MongoDB Java Driver gateway objects, such as `MongoCollection`, that themselves offer dedicated methods for `ClientSession` are *NOT* be session-proxied. So make sure to provide the `ClientSession` where needed when interacting directly with a `MongoCollection` or `MongoDatabase` and not via one of the `#execute` callbacks on `MongoOperations`. - -.ClientSession with `MongoOperations` -==== -[source,java] ----- -ClientSessionOptions sessionOptions = ClientSessionOptions.builder() - .causallyConsistent(true) - .build(); - -ClientSession session = client.startSession(sessionOptions); <1> - -template.withSession(() -> session) - .execute(action -> { - - Query query = query(where("name").is("Durzo Blint")); - Person durzo = action.findOne(query, Person.class); <2> - - Person azoth = new Person("Kylar Stern"); - azoth.setMaster(durzo); - - action.insert(azoth); <2> - - return azoth; - }); - -session.close() <4> ----- -<1> Obtain a new session from the server. -<2> Use `MongoOperation` methods as before. The `ClientSession` gets applied automatically. -<3> Make sure to close the `ClientSession`. -==== - -WARNING: When dealing with ``DBRef``s, especially lazily loaded ones, it is essential to **not** close the `ClientSession` before all data is loaded. Otherwise, lazy fetch fails. - -The reactive counterpart uses the very same building blocks as the imperative one. - -.ClientSession with `ReactiveMongoOperations` -==== -[source,java] ----- -ClientSessionOptions sessionOptions = ClientSessionOptions.builder() - .causallyConsistent(true) - .build(); - -Publisher session = client.startSession(sessionOptions); <1> - -template.withSession(session) - .execute(action -> { - - Query query = query(where("name").is("Durzo Blint")); - return action.findOne(query, Person.class) - .flatMap(durzo -> { - - Person azoth = new Person("Kylar Stern"); - azoth.setMaster(durzo); - - return action.insert(azoth); <2> - }); - }, ClientSession::close) <3> - .subscribe(); ----- -<1> Obtain a `Publisher` for new session retrieval. -<2> Use `ReactiveMongoOperation` methods as before. The `ClientSession` is obtained and applied automatically. -<3> Make sure to close the `ClientSession`. -==== - -By using a `Publisher` providing the actual session you can defer session acquisition to the point of actual subscription. -Still you need to close the session when done in order to not pollute the server with stale sessions. Use the `doFinally` hook on `execute` to call `ClientSession#close()` when you don't need the session any more. -In case you prefer having more control over the session itself, you can always obtain the `ClientSession` via the driver and provide it via a `Supplier`.