diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableUpdateOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableUpdateOperation.java
index 6ab3dd932..e0dd4e380 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableUpdateOperation.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableUpdateOperation.java
@@ -87,6 +87,24 @@ public interface ExecutableUpdateOperation {
T findAndModifyValue();
}
+ /**
+ * Trigger replaceOne
+ * execution by calling one of the terminating methods./** Trigger replace execution by calling one of the terminating
+ * methods.
+ *
+ * @author Christoph Strobl
+ * @since 4.2
+ */
+ interface TerminatingReplace {
+
+ /**
+ * Find first and replace/upsert.
+ *
+ * @return never {@literal null}.
+ */
+ UpdateResult replaceFirst();
+ }
+
/**
* Trigger
* findOneAndReplace
@@ -95,7 +113,7 @@ public interface ExecutableUpdateOperation {
* @author Mark Paluch
* @since 2.1
*/
- interface TerminatingFindAndReplace {
+ interface TerminatingFindAndReplace extends TerminatingReplace {
/**
* Find, replace and return the first matching document.
@@ -243,6 +261,22 @@ public interface ExecutableUpdateOperation {
TerminatingFindAndModify withOptions(FindAndModifyOptions options);
}
+ /**
+ * @author Christoph Strobl
+ * @since 4.2
+ */
+ interface ReplaceWithOptions extends TerminatingReplace {
+
+ /**
+ * Explicitly define {@link ReplaceOptions}.
+ *
+ * @param options must not be {@literal null}.
+ * @return new instance of {@link FindAndReplaceOptions}.
+ * @throws IllegalArgumentException if options is {@literal null}.
+ */
+ TerminatingReplace withOptions(ReplaceOptions options);
+ }
+
/**
* Define {@link FindAndReplaceOptions}.
*
@@ -250,7 +284,7 @@ public interface ExecutableUpdateOperation {
* @author Christoph Strobl
* @since 2.1
*/
- interface FindAndReplaceWithOptions extends TerminatingFindAndReplace {
+ interface FindAndReplaceWithOptions extends TerminatingFindAndReplace, ReplaceWithOptions {
/**
* Explicitly define {@link FindAndReplaceOptions} for the {@link Update}.
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableUpdateOperationSupport.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableUpdateOperationSupport.java
index 23a790f59..fff146926 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableUpdateOperationSupport.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableUpdateOperationSupport.java
@@ -126,6 +126,17 @@ class ExecutableUpdateOperationSupport implements ExecutableUpdateOperation {
options, replacement, targetType);
}
+ @Override
+ public TerminatingReplace withOptions(ReplaceOptions options) {
+
+ FindAndReplaceOptions target = new FindAndReplaceOptions();
+ if(options.isUpsert()) {
+ target.upsert();
+ }
+ return new ExecutableUpdateSupport<>(template, domainType, query, update, collection, findAndModifyOptions,
+ target, replacement, targetType);
+ }
+
@Override
public UpdateWithUpdate matching(Query query) {
@@ -175,6 +186,15 @@ class ExecutableUpdateOperationSupport implements ExecutableUpdateOperation {
getCollectionName(), targetType);
}
+ @Override
+ public UpdateResult replaceFirst() {
+ if(replacement != null) {
+ return template.replace(query, domainType, replacement, findAndReplaceOptions != null ? findAndReplaceOptions : ReplaceOptions.none(), getCollectionName());
+ }
+
+ return template.replace(query, domainType, update, findAndReplaceOptions != null ? findAndReplaceOptions : ReplaceOptions.none(), getCollectionName());
+ }
+
private UpdateResult doUpdate(boolean multi, boolean upsert) {
return template.doUpdate(getCollectionName(), query, update, domainType, upsert, multi);
}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/FindAndReplaceOptions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/FindAndReplaceOptions.java
index cadf935bb..470814028 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/FindAndReplaceOptions.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/FindAndReplaceOptions.java
@@ -31,10 +31,9 @@ package org.springframework.data.mongodb.core;
* @author Christoph Strobl
* @since 2.1
*/
-public class FindAndReplaceOptions {
+public class FindAndReplaceOptions extends ReplaceOptions {
private boolean returnNew;
- private boolean upsert;
private static final FindAndReplaceOptions NONE = new FindAndReplaceOptions() {
@@ -109,7 +108,7 @@ public class FindAndReplaceOptions {
*/
public FindAndReplaceOptions upsert() {
- this.upsert = true;
+ super.upsert();
return this;
}
@@ -122,13 +121,4 @@ public class FindAndReplaceOptions {
return returnNew;
}
- /**
- * Get the bit indicating if to create a new document if not exists.
- *
- * @return {@literal true} if set.
- */
- public boolean isUpsert() {
- return upsert;
- }
-
}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoActionOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoActionOperation.java
index 446c9e557..467f6586f 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoActionOperation.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoActionOperation.java
@@ -21,9 +21,10 @@ package org.springframework.data.mongodb.core;
*
* @author Mark Pollack
* @author Oliver Gierke
+ * @author Christoph Strobl
* @see MongoAction
*/
public enum MongoActionOperation {
- REMOVE, UPDATE, INSERT, INSERT_LIST, SAVE, BULK;
+ REMOVE, UPDATE, INSERT, INSERT_LIST, SAVE, BULK, REPLACE;
}
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 210fffcbe..0e12b37a7 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
@@ -40,6 +40,7 @@ import org.springframework.data.mongodb.core.index.IndexOperations;
import org.springframework.data.mongodb.core.mapreduce.MapReduceOptions;
import org.springframework.data.mongodb.core.mapreduce.MapReduceResults;
import org.springframework.data.mongodb.core.query.BasicQuery;
+import org.springframework.data.mongodb.core.query.Collation;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.NearQuery;
import org.springframework.data.mongodb.core.query.Query;
@@ -1754,119 +1755,115 @@ public interface MongoOperations extends FluentMongoOperations {
List findAllAndRemove(Query query, Class entityClass, String collectionName);
/**
- * Triggers replaceOne to
- * replace a single document matching {@link Criteria} of given {@link Query} with the {@code replacement} document.
- *
+ * Replace a single document matching the {@link Criteria} of given {@link Query} with the {@code replacement}
+ * document.
* The collection name is derived from the {@literal replacement} type.
- * Options are defaulted to {@link ReplaceOptions#empty()}.
- * NOTE: The replacement entity must not hold an {@literal id}.
+ * Options are defaulted to {@link ReplaceOptions#none()}.
*
- * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record and also an optional
- * fields specification. Must not be {@literal null}.
+ * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record. The query may
+ * contain an index {@link Query#withHint(String) hint} or the {@link Query#collation(Collation) collation}
+ * to use. Must not be {@literal null}.
* @param replacement the replacement document. Must not be {@literal null}.
* @return the {@link UpdateResult} which lets you access the results of the previous replacement.
* @throws org.springframework.data.mapping.MappingException if the collection name cannot be
* {@link #getCollectionName(Class) derived} from the given replacement value.
+ * @since 4.2
*/
default UpdateResult replace(Query query, T replacement) {
- return replace(query, replacement, ReplaceOptions.empty());
+ return replace(query, replacement, ReplaceOptions.none());
}
/**
- * Triggers replaceOne to
- * replace a single document matching {@link Criteria} of given {@link Query} with the {@code replacement}
- * document.
- * Options are defaulted to {@link ReplaceOptions#empty()}.
- * NOTE: The replacement entity must not hold an {@literal id}.
+ * Replace a single document matching the {@link Criteria} of given {@link Query} with the {@code replacement}
+ * document. Options are defaulted to {@link ReplaceOptions#none()}.
*
- * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record and also an optional
- * fields specification. Must not be {@literal null}.
+ * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record. The query may
+ * contain an index {@link Query#withHint(String) hint} or the {@link Query#collation(Collation) collation}
+ * to use. Must not be {@literal null}.
* @param replacement the replacement document. Must not be {@literal null}.
* @param collectionName the collection to query. Must not be {@literal null}.
* @return the {@link UpdateResult} which lets you access the results of the previous replacement.
* @throws org.springframework.data.mapping.MappingException if the collection name cannot be
* {@link #getCollectionName(Class) derived} from the given replacement value.
+ * @since 4.2
*/
default UpdateResult replace(Query query, T replacement, String collectionName) {
- return replace(query, replacement, ReplaceOptions.empty(), collectionName);
+ return replace(query, replacement, ReplaceOptions.none(), collectionName);
}
/**
- * Triggers replaceOne to
- * replace a single document matching {@link Criteria} of given {@link Query} with the {@code replacement} document
- * taking {@link ReplaceOptions} into account.
- * NOTE: The replacement entity must not hold an {@literal id}.
+ * Replace a single document matching the {@link Criteria} of given {@link Query} with the {@code replacement}
+ * document taking {@link ReplaceOptions} into account.
*
- * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record and also an optional
- * fields specification. Must not be {@literal null}.
+ * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record.The query may
+ * contain an index {@link Query#withHint(String) hint} or the {@link Query#collation(Collation) collation}
+ * to use. Must not be {@literal null}.
* @param replacement the replacement document. Must not be {@literal null}.
- * @param options the {@link FindAndModifyOptions} holding additional information. Must not be {@literal null}.
+ * @param options the {@link ReplaceOptions} holding additional information. Must not be {@literal null}.
* @return the {@link UpdateResult} which lets you access the results of the previous replacement.
* @throws org.springframework.data.mapping.MappingException if the collection name cannot be
* {@link #getCollectionName(Class) derived} from the given replacement value.
+ * @since 4.2
*/
default UpdateResult replace(Query query, T replacement, ReplaceOptions options) {
return replace(query, replacement, options, getCollectionName(ClassUtils.getUserClass(replacement)));
}
/**
- * Triggers replaceOne to
- * replace a single document matching {@link Criteria} of given {@link Query} with the {@code replacement} document
- * taking {@link ReplaceOptions} into account.
- * NOTE: The replacement entity must not hold an {@literal id}.
+ * Replace a single document matching the {@link Criteria} of given {@link Query} with the {@code replacement}
+ * document taking {@link ReplaceOptions} into account.
*
- * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record and also an optional
- * fields specification. Must not be {@literal null}.
+ * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record. The query may *
+ * contain an index {@link Query#withHint(String) hint} or the {@link Query#collation(Collation) collation}
+ * to use. Must not be {@literal null}.
* @param replacement the replacement document. Must not be {@literal null}.
- * @param options the {@link FindAndModifyOptions} holding additional information. Must not be {@literal null}.
+ * @param options the {@link ReplaceOptions} holding additional information. Must not be {@literal null}.
* @return the {@link UpdateResult} which lets you access the results of the previous replacement.
* @throws org.springframework.data.mapping.MappingException if the collection name cannot be
* {@link #getCollectionName(Class) derived} from the given replacement value.
+ * @since 4.2
*/
default UpdateResult replace(Query query, T replacement, ReplaceOptions options, String collectionName) {
Assert.notNull(replacement, "Replacement must not be null");
- return replace(query, replacement, options, (Class) ClassUtils.getUserClass(replacement), collectionName);
+ return replace(query, (Class) ClassUtils.getUserClass(replacement), replacement, options, collectionName);
}
/**
- * Triggers replaceOne to
- * replace a single document matching {@link Criteria} of given {@link Query} with the {@code replacement} document
- * taking {@link ReplaceOptions} into account.
- * NOTE: The replacement entity must not hold an {@literal id}.
+ * Replace a single document matching the {@link Criteria} of given {@link Query} with the {@code replacement}
+ * document taking {@link ReplaceOptions} into account.
*
- * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record and also an optional
- * fields specification. Must not be {@literal null}.
- * @param replacement the replacement document. Must not be {@literal null}.
- * @param options the {@link FindAndModifyOptions} holding additional information. Must not be {@literal null}.
+ * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record. The query may
+ * contain an index {@link Query#withHint(String) hint} or the {@link Query#collation(Collation) collation}
+ * to use. Must not be {@literal null}.
* @param entityType the type used for mapping the {@link Query} to domain type fields and deriving the collection
+ * @param replacement the replacement document. Must not be {@literal null}.
+ * @param options the {@link ReplaceOptions} holding additional information. Must not be {@literal null}.
* from. Must not be {@literal null}.
* @return the {@link UpdateResult} which lets you access the results of the previous replacement.
* @throws org.springframework.data.mapping.MappingException if the collection name cannot be
* {@link #getCollectionName(Class) derived} from the given replacement value.
+ * @since 4.2
*/
- default UpdateResult replace(Query query, S replacement, ReplaceOptions options, Class entityType) {
-
- return replace(query, replacement, options, entityType, getCollectionName(ClassUtils.getUserClass(entityType)));
+ default UpdateResult replace(Query query, Class entityType, T replacement, ReplaceOptions options) {
+ return replace(query, entityType, replacement, options, getCollectionName(ClassUtils.getUserClass(entityType)));
}
/**
- * Triggers replaceOne to
- * replace a single document matching {@link Criteria} of given {@link Query} with the {@code replacement} document
- * taking {@link ReplaceOptions} into account.
- * NOTE: The replacement entity must not hold an {@literal id}.
+ * Replace a single document matching the {@link Criteria} of given {@link Query} with the {@code replacement}
+ * document taking {@link ReplaceOptions} into account.
*
- * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record and also an optional
- * fields specification. Must not be {@literal null}.
- * @param replacement the replacement document. Must not be {@literal null}.
- * @param options the {@link FindAndModifyOptions} holding additional information. Must not be {@literal null}.
+ * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record. The query may
+ * contain an index {@link Query#withHint(String) hint} or the {@link Query#collation(Collation) collation}
+ * to use. Must not be {@literal null}.
* @param entityType the type used for mapping the {@link Query} to domain type fields. Must not be {@literal null}.
+ * @param replacement the replacement document. Must not be {@literal null}.
+ * @param options the {@link ReplaceOptions} holding additional information. Must not be {@literal null}.
* @param collectionName the collection to query. Must not be {@literal null}.
* @return the {@link UpdateResult} which lets you access the results of the previous replacement.
- * @throws org.springframework.data.mapping.MappingException if the collection name cannot be
- * {@link #getCollectionName(Class) derived} from the given replacement value.
+ * @since 4.2
*/
- UpdateResult replace(Query query, S replacement, ReplaceOptions options, Class entityType,
+ UpdateResult replace(Query query, Class entityType, T replacement, ReplaceOptions options,
String collectionName);
/**
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 b2d0d0461..fc39b4cc5 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
@@ -1311,7 +1311,7 @@ public class MongoTemplate
if (ObjectUtils.nullSafeEquals(WriteResultChecking.EXCEPTION, writeResultChecking)) {
if (wc == null || wc.getWObject() == null
- || (wc.getWObject() instanceof Number concern && concern.intValue() < 1)) {
+ || (wc.getWObject()instanceof Number concern && concern.intValue() < 1)) {
return WriteConcern.ACKNOWLEDGED;
}
}
@@ -2068,6 +2068,42 @@ public class MongoTemplate
return doFindAndDelete(collectionName, query, entityClass);
}
+ @Override
+ public UpdateResult replace(Query query, Class entityType, T replacement, ReplaceOptions options,
+ String collectionName) {
+
+ Assert.notNull(query, "Query must not be null");
+ Assert.notNull(replacement, "Replacement must not be null");
+ Assert.notNull(options, "Options must not be null Use ReplaceOptions#none() instead");
+ Assert.notNull(entityType, "EntityType must not be null");
+ Assert.notNull(collectionName, "CollectionName must not be null");
+
+ Assert.isTrue(query.getLimit() <= 1, "Query must not define a limit other than 1 ore none");
+ Assert.isTrue(query.getSkip() <= 0, "Query must not define skip");
+
+ UpdateContext updateContext = queryOperations.replaceSingleContext(query,
+ operations.forEntity(replacement).toMappedDocument(this.mongoConverter), options.isUpsert());
+
+ replacement = maybeCallBeforeConvert(replacement, collectionName);
+ Document mappedReplacement = updateContext.getMappedUpdate(mappingContext.getPersistentEntity(entityType));
+ maybeEmitEvent(new BeforeSaveEvent<>(replacement, mappedReplacement, collectionName));
+ replacement = maybeCallBeforeSave(replacement, mappedReplacement, collectionName);
+
+ MongoAction action = new MongoAction(writeConcern, MongoActionOperation.REPLACE, collectionName, entityType,
+ mappedReplacement, updateContext.getQueryObject());
+
+ UpdateResult result = doReplace(options, entityType, collectionName, updateContext,
+ createCollectionPreparer(query, action), mappedReplacement);
+
+ if (result.wasAcknowledged()) {
+
+ maybeEmitEvent(new AfterSaveEvent<>(replacement, mappedReplacement, collectionName));
+ maybeCallAfterSave(replacement, mappedReplacement, collectionName);
+ }
+
+ return result;
+ }
+
/**
* Retrieve and remove all documents matching the given {@code query} by calling {@link #find(Query, Class, String)}
* and {@link #remove(Query, Class, String)}, whereas the {@link Query} for {@link #remove(Query, Class, String)} is
@@ -2733,6 +2769,17 @@ public class MongoTemplate
return CollectionPreparerDelegate.of(query);
}
+ CollectionPreparer> createCollectionPreparer(Query query, @Nullable MongoAction action) {
+ CollectionPreparer> collectionPreparer = createDelegate(query);
+ if (action == null) {
+ return collectionPreparer;
+ }
+ return collectionPreparer.andThen(collection -> {
+ WriteConcern writeConcern = prepareWriteConcern(action);
+ return writeConcern != null ? collection.withWriteConcern(writeConcern) : collection;
+ });
+ }
+
/**
* Customize this part for findAndReplace.
*
@@ -2768,6 +2815,24 @@ public class MongoTemplate
collectionName);
}
+ private UpdateResult doReplace(ReplaceOptions options, Class entityType, String collectionName,
+ UpdateContext updateContext, CollectionPreparer> collectionPreparer,
+ Document replacement) {
+
+ MongoPersistentEntity> persistentEntity = mappingContext.getPersistentEntity(entityType);
+
+ ReplaceCallback replaceCallback = new ReplaceCallback(collectionPreparer,
+ updateContext.getMappedQuery(persistentEntity), replacement, updateContext.getReplaceOptions(entityType, it -> {
+ it.upsert(options.isUpsert());
+ }));
+ if (LOGGER.isDebugEnabled()) {
+ LOGGER.debug(String.format("replace one using query: %s for class: %s in collection: %s",
+ serializeToJsonSafely(updateContext.getMappedQuery(persistentEntity)), entityType, collectionName));
+ }
+
+ return execute(collectionName, replaceCallback);
+ }
+
/**
* Populates the id property of the saved object, if it's not set already.
*
@@ -3422,56 +3487,6 @@ public class MongoTemplate
return mongoDbFactory;
}
- @Override
- public UpdateResult replace(Query query, S replacement, ReplaceOptions options, Class entityType,
- String collectionName) {
- Assert.notNull(query, "Query must not be null");
- Assert.notNull(replacement, "Replacement must not be null");
- Assert.notNull(options, "Options must not be null Use ReplaceOptions#empty() instead");
- Assert.notNull(entityType, "EntityType must not be null");
- Assert.notNull(collectionName, "CollectionName must not be null");
-
- Assert.isTrue(query.getLimit() <= 1, "Query must not define a limit other than 1 ore none");
- Assert.isTrue(query.getSkip() <= 0, "Query must not define skip");
-
- MongoPersistentEntity> entity = mappingContext.getPersistentEntity(entityType);
- QueryContext queryContext = queryOperations.createQueryContext(query);
-
- CollectionPreparerDelegate collectionPreparer = createDelegate(query);
- Document mappedQuery = queryContext.getMappedQuery(entity);
-
- replacement = maybeCallBeforeConvert(replacement, collectionName);
- Document mappedReplacement = operations.forEntity(replacement).toMappedDocument(this.mongoConverter).getDocument();
- maybeCallBeforeSave(replacement, mappedReplacement, collectionName);
-
- maybeEmitEvent(new BeforeSaveEvent<>(replacement, mappedReplacement, collectionName));
- maybeCallBeforeSave(replacement, mappedReplacement, collectionName);
-
- UpdateResult result = doReplace(options, entityType, collectionName, queryContext, collectionPreparer, mappedQuery,
- mappedReplacement);
-
- if (result.wasAcknowledged()) {
- maybeEmitEvent(new AfterSaveEvent<>(replacement, mappedReplacement, collectionName));
- maybeCallAfterSave(replacement, mappedReplacement, collectionName);
- }
-
- return result;
- }
-
- private UpdateResult doReplace(ReplaceOptions options, Class entityType, String collectionName,
- QueryContext queryContext, CollectionPreparerDelegate collectionPreparer, Document mappedQuery,
- Document replacement) {
- ReplaceCallback replaceCallback = new ReplaceCallback(collectionPreparer, mappedQuery, replacement,
- queryContext.getCollation(entityType).orElse(null), options);
- if (LOGGER.isDebugEnabled()) {
- LOGGER.debug(
- String.format("findAndReplace using query: %s for class: %s and replacement: %s " + "in collection: %s",
- serializeToJsonSafely(mappedQuery), entityType, serializeToJsonSafely(replacement), collectionName));
- }
-
- return execute(collectionName, replaceCallback);
- }
-
/**
* A {@link CloseableIterator} that is backed by a MongoDB {@link MongoCollection}.
*
@@ -3612,27 +3627,20 @@ public class MongoTemplate
private final CollectionPreparer> collectionPreparer;
private final Document query;
private final Document update;
- private final @Nullable com.mongodb.client.model.Collation collation;
- private final ReplaceOptions options;
+ private final com.mongodb.client.model.ReplaceOptions options;
ReplaceCallback(CollectionPreparer> collectionPreparer, Document query, Document update,
- @Nullable com.mongodb.client.model.Collation collation, ReplaceOptions options) {
+ com.mongodb.client.model.ReplaceOptions options) {
this.collectionPreparer = collectionPreparer;
this.query = query;
this.update = update;
this.options = options;
- this.collation = collation;
}
@Override
public UpdateResult doInCollection(MongoCollection collection)
throws MongoException, DataAccessException {
- com.mongodb.client.model.ReplaceOptions opts = new com.mongodb.client.model.ReplaceOptions();
- opts.collation(collation);
-
- opts.upsert(options.isUpsert());
-
- return collectionPreparer.prepare(collection).replaceOne(query, update, opts);
+ return collectionPreparer.prepare(collection).replaceOne(query, update, options);
}
}
}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryOperations.java
index f3e41eb13..fd1da7f70 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryOperations.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryOperations.java
@@ -193,6 +193,15 @@ class QueryOperations {
return new UpdateContext(replacement, upsert);
}
+ /**
+ * @param replacement the {@link MappedDocument mapped replacement} document.
+ * @param upsert use {@literal true} to insert diff when no existing document found.
+ * @return new instance of {@link UpdateContext}.
+ */
+ UpdateContext replaceSingleContext(Query query, MappedDocument replacement, boolean upsert) {
+ return new UpdateContext(query, replacement, upsert);
+ }
+
/**
* Create a new {@link DeleteContext} instance removing all matching documents.
*
@@ -439,6 +448,25 @@ class QueryOperations {
return entityOperations.forType(domainType).getCollation(query) //
.map(Collation::toMongoCollation);
}
+
+ /**
+ * Get the {@link HintFunction} reading the actual hint form the {@link Query}.
+ *
+ * @return new instance of {@link HintFunction}.
+ * @since 4.2
+ */
+ HintFunction getHintFunction() {
+ return HintFunction.from(query.getHint());
+ }
+
+ /**
+ * Read and apply the hint from the {@link Query}.
+ *
+ * @since 4.2
+ */
+ void applyHint(Function stringConsumer, Function bsonConsumer) {
+ getHintFunction().ifPresent(codecRegistryProvider, stringConsumer, bsonConsumer);
+ }
}
/**
@@ -696,8 +724,12 @@ class QueryOperations {
}
UpdateContext(MappedDocument update, boolean upsert) {
+ this(new BasicQuery(BsonUtils.asDocument(update.getIdFilter())), update, upsert);
+ }
- super(new BasicQuery(BsonUtils.asDocument(update.getIdFilter())));
+ UpdateContext(Query query, MappedDocument update, boolean upsert) {
+
+ super(query);
this.multi = false;
this.upsert = upsert;
this.mappedDocument = update;
@@ -765,6 +797,7 @@ class QueryOperations {
ReplaceOptions options = new ReplaceOptions();
options.collation(updateOptions.getCollation());
options.upsert(updateOptions.isUpsert());
+ applyHint(options::hintString, options::hint);
if (callback != null) {
callback.accept(options);
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoOperations.java
index 9f391bb79..9b2e28562 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoOperations.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoOperations.java
@@ -15,6 +15,7 @@
*/
package org.springframework.data.mongodb.core;
+import org.springframework.data.mongodb.core.query.Collation;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -1638,6 +1639,118 @@ public interface ReactiveMongoOperations extends ReactiveFluentMongoOperations {
*/
Flux findAllAndRemove(Query query, Class entityClass, String collectionName);
+ /**
+ * Replace a single document matching the {@link Criteria} of given {@link Query} with the {@code replacement}
+ * document.
+ * The collection name is derived from the {@literal replacement} type.
+ * Options are defaulted to {@link ReplaceOptions#none()}.
+ *
+ * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record. The query may
+ * contain an index {@link Query#withHint(String) hint} or the {@link Query#collation(Collation) collation}
+ * to use. Must not be {@literal null}.
+ * @param replacement the replacement document. Must not be {@literal null}.
+ * @return the {@link UpdateResult} which lets you access the results of the previous replacement.
+ * @throws org.springframework.data.mapping.MappingException if the collection name cannot be
+ * {@link #getCollectionName(Class) derived} from the given replacement value.
+ * @since 4.2
+ */
+ default Mono replace(Query query, T replacement) {
+ return replace(query, replacement, ReplaceOptions.none());
+ }
+
+ /**
+ * Replace a single document matching the {@link Criteria} of given {@link Query} with the {@code replacement}
+ * document. Options are defaulted to {@link ReplaceOptions#none()}.
+ *
+ * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record. The query may
+ * contain an index {@link Query#withHint(String) hint} or the {@link Query#collation(Collation) collation}
+ * to use. Must not be {@literal null}.
+ * @param replacement the replacement document. Must not be {@literal null}.
+ * @param collectionName the collection to query. Must not be {@literal null}.
+ * @return the {@link UpdateResult} which lets you access the results of the previous replacement.
+ * @throws org.springframework.data.mapping.MappingException if the collection name cannot be
+ * {@link #getCollectionName(Class) derived} from the given replacement value.
+ * @since 4.2
+ */
+ default Mono replace(Query query, T replacement, String collectionName) {
+ return replace(query, replacement, ReplaceOptions.none(), collectionName);
+ }
+
+ /**
+ * Replace a single document matching the {@link Criteria} of given {@link Query} with the {@code replacement}
+ * document taking {@link ReplaceOptions} into account.
+ *
+ * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record.The query may
+ * contain an index {@link Query#withHint(String) hint} or the {@link Query#collation(Collation) collation}
+ * to use. Must not be {@literal null}.
+ * @param replacement the replacement document. Must not be {@literal null}.
+ * @param options the {@link ReplaceOptions} holding additional information. Must not be {@literal null}.
+ * @return the {@link UpdateResult} which lets you access the results of the previous replacement.
+ * @throws org.springframework.data.mapping.MappingException if the collection name cannot be
+ * {@link #getCollectionName(Class) derived} from the given replacement value.
+ * @since 4.2
+ */
+ default Mono replace(Query query, T replacement, ReplaceOptions options) {
+ return replace(query, replacement, options, getCollectionName(ClassUtils.getUserClass(replacement)));
+ }
+
+ /**
+ * Replace a single document matching the {@link Criteria} of given {@link Query} with the {@code replacement}
+ * document taking {@link ReplaceOptions} into account.
+ *
+ * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record. The query may *
+ * contain an index {@link Query#withHint(String) hint} or the {@link Query#collation(Collation) collation}
+ * to use. Must not be {@literal null}.
+ * @param replacement the replacement document. Must not be {@literal null}.
+ * @param options the {@link ReplaceOptions} holding additional information. Must not be {@literal null}.
+ * @return the {@link UpdateResult} which lets you access the results of the previous replacement.
+ * @throws org.springframework.data.mapping.MappingException if the collection name cannot be
+ * {@link #getCollectionName(Class) derived} from the given replacement value.
+ * @since 4.2
+ */
+ default Mono replace(Query query, T replacement, ReplaceOptions options, String collectionName) {
+
+ Assert.notNull(replacement, "Replacement must not be null");
+ return replace(query, (Class) ClassUtils.getUserClass(replacement), replacement, options, collectionName);
+ }
+
+ /**
+ * Replace a single document matching the {@link Criteria} of given {@link Query} with the {@code replacement}
+ * document taking {@link ReplaceOptions} into account.
+ *
+ * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record. The query may
+ * contain an index {@link Query#withHint(String) hint} or the {@link Query#collation(Collation) collation}
+ * to use. Must not be {@literal null}.
+ * @param entityType the type used for mapping the {@link Query} to domain type fields and deriving the collection
+ * @param replacement the replacement document. Must not be {@literal null}.
+ * @param options the {@link ReplaceOptions} holding additional information. Must not be {@literal null}.
+ * from. Must not be {@literal null}.
+ * @return the {@link UpdateResult} which lets you access the results of the previous replacement.
+ * @throws org.springframework.data.mapping.MappingException if the collection name cannot be
+ * {@link #getCollectionName(Class) derived} from the given replacement value.
+ * @since 4.2
+ */
+ default Mono replace(Query query, Class entityType, T replacement, ReplaceOptions options) {
+ return replace(query, entityType, replacement, options, getCollectionName(ClassUtils.getUserClass(entityType)));
+ }
+
+ /**
+ * Replace a single document matching the {@link Criteria} of given {@link Query} with the {@code replacement}
+ * document taking {@link ReplaceOptions} into account.
+ *
+ * @param query the {@link Query} class that specifies the {@link Criteria} used to find a record. The query may
+ * contain an index {@link Query#withHint(String) hint} or the {@link Query#collation(Collation) collation}
+ * to use. Must not be {@literal null}.
+ * @param entityType the type used for mapping the {@link Query} to domain type fields. Must not be {@literal null}.
+ * @param replacement the replacement document. Must not be {@literal null}.
+ * @param options the {@link ReplaceOptions} holding additional information. Must not be {@literal null}.
+ * @param collectionName the collection to query. Must not be {@literal null}.
+ * @return the {@link UpdateResult} which lets you access the results of the previous replacement.
+ * @since 4.2
+ */
+ Mono replace(Query query, Class entityType, T replacement, ReplaceOptions options,
+ String collectionName);
+
/**
* Map the results of an ad-hoc query on the collection for the entity class to a stream of objects of the specified
* type. The stream uses a {@link com.mongodb.CursorType#TailableAwait tailable} cursor that may be an infinite
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 5284cff84..95cf4f767 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
@@ -17,6 +17,7 @@ package org.springframework.data.mongodb.core;
import static org.springframework.data.mongodb.core.query.SerializationUtils.*;
+import org.springframework.data.mongodb.core.CollectionPreparerSupport.CollectionPreparerDelegate;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.function.Tuple2;
@@ -1959,6 +1960,28 @@ public class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati
return doFindAndDelete(collectionName, query, entityClass);
}
+ @Override
+ public Mono replace(Query query, Class entityType, T replacement, ReplaceOptions options,
+ String collectionName) {
+
+ MongoPersistentEntity> entity = mappingContext.getPersistentEntity(entityType);
+ UpdateContext updateContext = queryOperations.replaceSingleContext(query, operations.forEntity(replacement).toMappedDocument(this.mongoConverter), options.isUpsert());
+
+ return createMono(collectionName, collection -> {
+
+ Document mappedUpdate = updateContext.getMappedUpdate(entity);
+
+ MongoAction action = new MongoAction(writeConcern, MongoActionOperation.REPLACE, collectionName, entityType,
+ mappedUpdate, updateContext.getQueryObject());
+
+ MongoCollection collectionToUse = createCollectionPreparer(query, action).prepare(collection);
+
+ return collectionToUse.replaceOne(updateContext.getMappedQuery(entity), mappedUpdate, updateContext.getReplaceOptions(entityType, it -> {
+ it.upsert(options.isUpsert());
+ }));
+ });
+ }
+
@Override
public Flux tail(Query query, Class entityClass) {
return tail(query, entityClass, getCollectionName(entityClass));
@@ -2341,6 +2364,21 @@ public class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati
objectCallback, collectionName);
}
+ CollectionPreparer> createCollectionPreparer(Query query) {
+ return ReactiveCollectionPreparerDelegate.of(query);
+ }
+
+ CollectionPreparer> createCollectionPreparer(Query query, @Nullable MongoAction action) {
+ CollectionPreparer> collectionPreparer = createCollectionPreparer(query);
+ if (action == null) {
+ return collectionPreparer;
+ }
+ return collectionPreparer.andThen(collection -> {
+ WriteConcern writeConcern = prepareWriteConcern(action);
+ return writeConcern != null ? collection.withWriteConcern(writeConcern) : collection;
+ });
+ }
+
/**
* Map the results of an ad-hoc query on the default MongoDB collection to a List of the specified targetClass while
* using sourceClass for mapping the query.
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReplaceOptions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReplaceOptions.java
index f5a0d0eab..568c524c0 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReplaceOptions.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReplaceOptions.java
@@ -1,18 +1,35 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core;
+
+import org.springframework.data.mongodb.core.query.Query;
+
/**
- * Options for
- * replaceOne.
- *
- * Defaults to
+ * Options for {@link org.springframework.data.mongodb.core.MongoOperations#replace(Query, Object) replace operations}. Defaults to
*
* - upsert
* - false
*
*
* @author Jakub Zurawa
+ * @author Christoph Strob
+ * @since 4.2
*/
-package org.springframework.data.mongodb.core;
-
public class ReplaceOptions {
+
private boolean upsert;
private static final ReplaceOptions NONE = new ReplaceOptions() {
@@ -34,7 +51,7 @@ public class ReplaceOptions {
*
* @return new instance of {@link ReplaceOptions}.
*/
- public static ReplaceOptions options() {
+ public static ReplaceOptions replaceOptions() {
return new ReplaceOptions();
}
@@ -42,25 +59,11 @@ public class ReplaceOptions {
* Static factory method returning an unmodifiable {@link ReplaceOptions} instance.
*
* @return unmodifiable {@link ReplaceOptions} instance.
- * @since 2.2
*/
public static ReplaceOptions none() {
return NONE;
}
- /**
- * Static factory method to create a {@link ReplaceOptions} instance with
- *
- * - upsert
- * - false
- *
- *
- * @return new instance of {@link ReplaceOptions}.
- */
- public static ReplaceOptions empty() {
- return new ReplaceOptions();
- }
-
/**
* Insert a new document if not exists.
*
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableUpdateOperationSupportTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableUpdateOperationSupportTests.java
index c053375f2..85ceab706 100644
--- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableUpdateOperationSupportTests.java
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableUpdateOperationSupportTests.java
@@ -247,6 +247,29 @@ class ExecutableUpdateOperationSupportTests {
assertThat(result).isNotEqualTo(han).hasFieldOrPropertyWithValue("firstname", "Luke");
}
+ @Test // GH-4463
+ void replace() {
+
+ Person luke = new Person();
+ luke.id = han.id;
+ luke.firstname = "Luke";
+
+ UpdateResult result = template.update(Person.class).matching(queryHan()).replaceWith(luke).replaceFirst();
+ assertThat(result.getModifiedCount()).isEqualTo(1L);
+ }
+
+ @Test // GH-4463
+ void replaceWithOptions() {
+
+ Person luke = new Person();
+ luke.id = "upserted-luke";
+ luke.firstname = "Luke";
+
+ UpdateResult result = template.update(Person.class).matching(query(where("firstname")
+ .is("c3p0"))).replaceWith(luke).withOptions(ReplaceOptions.replaceOptions().upsert()).replaceFirst();
+ assertThat(result.getUpsertedId()).isEqualTo(new BsonString("upserted-luke"));
+ }
+
@Test // DATAMONGO-1827
void findAndReplaceWithProjection() {
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateReplaceTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateReplaceTests.java
new file mode 100644
index 000000000..91cfd9703
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateReplaceTests.java
@@ -0,0 +1,282 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.springframework.data.mongodb.core.ReplaceOptions.*;
+import static org.springframework.data.mongodb.core.query.Criteria.*;
+import static org.springframework.data.mongodb.core.query.Query.*;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.bson.BsonInt64;
+import org.bson.Document;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.data.mongodb.core.aggregation.AggregationUpdate;
+import org.springframework.data.mongodb.core.mapping.Field;
+import org.springframework.data.mongodb.test.util.Client;
+import org.springframework.data.mongodb.test.util.MongoClientExtension;
+
+import com.mongodb.client.MongoClient;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.model.Filters;
+import com.mongodb.client.result.UpdateResult;
+
+/**
+ * @author Christoph Strobl
+ */
+@ExtendWith(MongoClientExtension.class)
+public class MongoTemplateReplaceTests {
+
+ static final String DB_NAME = "mongo-template-replace-tests";
+ static final String RESTAURANT_COLLECTION = "restaurant";
+
+ static @Client MongoClient client;
+ private MongoTemplate template;
+
+ @BeforeEach
+ void beforeEach() {
+
+ template = new MongoTemplate(client, DB_NAME);
+ template.setEntityLifecycleEventsEnabled(false);
+
+ initTestData();
+ }
+
+ @AfterEach()
+ void afterEach() {
+ clearTestData();
+ }
+
+ @Test // GH-4462
+ void replacesExistingDocument() {
+
+ UpdateResult result = template.replace(query(where("name").is("Central Perk Cafe")),
+ new Restaurant("Central Pork Cafe", "Manhattan"));
+
+ assertThat(result.getMatchedCount()).isEqualTo(1);
+ assertThat(result.getModifiedCount()).isEqualTo(1);
+
+ Document document = retrieve(collection -> collection.find(Filters.eq("_id", 1)).first());
+ assertThat(document).containsEntry("r-name", "Central Pork Cafe");
+ }
+
+ @Test // GH-4462
+ void replacesFirstOnMoreThanOneMatch() {
+
+ UpdateResult result = template
+ .replace(query(where("violations").exists(true)), new Restaurant("Central Pork Cafe", "Manhattan"));
+
+ assertThat(result.getMatchedCount()).isEqualTo(1);
+ assertThat(result.getModifiedCount()).isEqualTo(1);
+
+ Document document = retrieve(collection -> collection.find(Filters.eq("_id", 2)).first());
+ assertThat(document).containsEntry("r-name", "Central Pork Cafe");
+ }
+
+ @Test // GH-4462
+ void replacesExistingDocumentWithRawDoc() {
+
+ UpdateResult result = template.replace(query(where("r-name").is("Central Perk Cafe")),
+ Document.parse("{ 'r-name' : 'Central Pork Cafe', 'Borough' : 'Manhattan' }"),
+ template.getCollectionName(Restaurant.class));
+
+ assertThat(result.getMatchedCount()).isEqualTo(1);
+ assertThat(result.getModifiedCount()).isEqualTo(1);
+
+ Document document = retrieve(collection -> collection.find(Filters.eq("_id", 1)).first());
+ assertThat(document).containsEntry("r-name", "Central Pork Cafe");
+ }
+
+ @Test // GH-4462
+ void replacesExistingDocumentWithRawDocMappingQueryAgainstDomainType() {
+
+ UpdateResult result = template.replace(query(where("name").is("Central Perk Cafe")), Restaurant.class,
+ Document.parse("{ 'r-name' : 'Central Pork Cafe', 'Borough' : 'Manhattan' }"), ReplaceOptions.none());
+
+ assertThat(result.getMatchedCount()).isEqualTo(1);
+ assertThat(result.getModifiedCount()).isEqualTo(1);
+
+ Document document = retrieve(collection -> collection.find(Filters.eq("_id", 1)).first());
+ assertThat(document).containsEntry("r-name", "Central Pork Cafe");
+ }
+
+ @Test // GH-4462
+ void replacesExistingDocumentWithMatchingId() {
+
+ UpdateResult result = template.replace(query(where("name").is("Central Perk Cafe")),
+ new Restaurant(1L, "Central Pork Cafe", "Manhattan", 0));
+
+ assertThat(result.getMatchedCount()).isEqualTo(1);
+ assertThat(result.getModifiedCount()).isEqualTo(1);
+
+ Document document = retrieve(collection -> collection.find(Filters.eq("_id", 1)).first());
+ assertThat(document).containsEntry("r-name", "Central Pork Cafe");
+ }
+
+ @Test // GH-4462
+ void replacesExistingDocumentWithNewIdThrowsDataIntegrityViolationException() {
+
+ assertThatExceptionOfType(DataIntegrityViolationException.class)
+ .isThrownBy(() -> template.replace(query(where("name").is("Central Perk Cafe")),
+ new Restaurant(4L, "Central Pork Cafe", "Manhattan", 0)));
+ }
+
+ @Test // GH-4462
+ void doesNothingIfNoMatchFoundAndUpsertSetToFalse/* by default */() {
+
+ UpdateResult result = template.replace(query(where("name").is("Pizza Rat's Pizzaria")),
+ new Restaurant(null, "Pizza Rat's Pizzaria", "Manhattan", 8));
+
+ assertThat(result.getMatchedCount()).isEqualTo(0);
+ assertThat(result.getModifiedCount()).isEqualTo(0);
+
+ Document document = retrieve(collection -> collection.find(Filters.eq("r-name", "Pizza Rat's Pizzaria")).first());
+ assertThat(document).isNull();
+ }
+
+ @Test // GH-4462
+ void insertsIfNoMatchFoundAndUpsertSetToTrue() {
+
+ UpdateResult result = template.replace(query(where("name").is("Pizza Rat's Pizzaria")),
+ new Restaurant(4L, "Pizza Rat's Pizzaria", "Manhattan", 8), replaceOptions().upsert());
+
+ assertThat(result.getMatchedCount()).isEqualTo(0);
+ assertThat(result.getModifiedCount()).isEqualTo(0);
+ assertThat(result.getUpsertedId()).isEqualTo(new BsonInt64(4L));
+
+ Document document = retrieve(collection -> collection.find(Filters.eq("_id", 4)).first());
+ assertThat(document).containsEntry("r-name", "Pizza Rat's Pizzaria");
+ }
+
+ void initTestData() {
+
+ List testData = Stream.of( //
+ "{ '_id' : 1, 'r-name' : 'Central Perk Cafe', 'Borough' : 'Manhattan' }",
+ "{ '_id' : 2, 'r-name' : 'Rock A Feller Bar and Grill', 'Borough' : 'Queens', 'violations' : 2 }",
+ "{ '_id' : 3, 'r-name' : 'Empire State Pub', 'Borough' : 'Brooklyn', 'violations' : 0 }") //
+ .map(Document::parse).collect(Collectors.toList());
+
+ doInCollection(collection -> collection.insertMany(testData));
+ }
+
+ void clearTestData() {
+ doInCollection(collection -> collection.deleteMany(new Document()));
+ }
+
+ void doInCollection(Consumer> consumer) {
+ retrieve(collection -> {
+ consumer.accept(collection);
+ return "done";
+ });
+ }
+
+ T retrieve(Function, T> fkt) {
+ return fkt.apply(client.getDatabase(DB_NAME).getCollection(RESTAURANT_COLLECTION));
+ }
+
+ @org.springframework.data.mongodb.core.mapping.Document(RESTAURANT_COLLECTION)
+ static class Restaurant {
+
+ Long id;
+
+ @Field("r-name") String name;
+ String borough;
+ Integer violations;
+
+ Restaurant() {}
+
+ Restaurant(String name, String borough) {
+
+ this.name = name;
+ this.borough = borough;
+ }
+
+ Restaurant(Long id, String name, String borough, Integer violations) {
+
+ this.id = id;
+ this.name = name;
+ this.borough = borough;
+ this.violations = violations;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getRName() {
+ return name;
+ }
+
+ public void setRName(String rName) {
+ this.name = rName;
+ }
+
+ public String getBorough() {
+ return borough;
+ }
+
+ public void setBorough(String borough) {
+ this.borough = borough;
+ }
+
+ public int getViolations() {
+ return violations;
+ }
+
+ public void setViolations(int violations) {
+ this.violations = violations;
+ }
+
+ @Override
+ public String toString() {
+ return "Restaurant{" + "id=" + id + ", name='" + name + '\'' + ", borough='" + borough + '\'' + ", violations="
+ + violations + '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Restaurant that = (Restaurant) o;
+ return violations == that.violations && Objects.equals(id, that.id) && Objects.equals(name, that.name)
+ && Objects.equals(borough, that.borough);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, name, borough, violations);
+ }
+ }
+
+}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateTests.java
index e2b3dc80d..76b4d25d8 100644
--- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateTests.java
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateTests.java
@@ -3881,7 +3881,7 @@ public class MongoTemplateTests {
template.save(doc, collectionName);
org.bson.Document replacement = new org.bson.Document("foo", "baz");
- UpdateResult updateResult = template.replace(query(where("foo").is("bar")), replacement, ReplaceOptions.options(),
+ UpdateResult updateResult = template.replace(query(where("foo").is("bar")), replacement, ReplaceOptions.replaceOptions(),
collectionName);
assertThat(updateResult.wasAcknowledged()).isTrue();
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 44b8f6642..d196394fd 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
@@ -178,6 +178,7 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests {
when(collection.withWriteConcern(any())).thenReturn(collectionWithWriteConcern);
when(collection.distinct(anyString(), any(Document.class), any())).thenReturn(distinctIterable);
when(collectionWithWriteConcern.deleteOne(any(Bson.class), any())).thenReturn(deleteResult);
+ when(collectionWithWriteConcern.replaceOne(any(), any(), any(com.mongodb.client.model.ReplaceOptions.class))).thenReturn(updateResult);
when(findIterable.projection(any())).thenReturn(findIterable);
when(findIterable.sort(any(org.bson.Document.class))).thenReturn(findIterable);
when(findIterable.collation(any())).thenReturn(findIterable);
@@ -2436,7 +2437,7 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests {
any(FindOneAndReplaceOptions.class));
}
- @Test // GH-4300
+ @Test // GH-4462
void replaceShouldUseCollationWhenPresent() {
template.replace(new BasicQuery("{}").collation(Collation.of("fr")), new AutogenerateableId());
@@ -2449,23 +2450,34 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests {
assertThat(options.getValue().getCollation().getLocale()).isEqualTo("fr");
}
- @Test // GH-4300
+ @Test // GH-4462
+ void replaceShouldNotUpsertByDefault() {
+
+ template.replace(new BasicQuery("{}"), new Sith());
+
+ ArgumentCaptor options = ArgumentCaptor
+ .forClass(com.mongodb.client.model.ReplaceOptions.class);
+ verify(collection).replaceOne(any(), any(), options.capture());
+
+ assertThat(options.getValue().isUpsert()).isFalse();
+ }
+
+ @Test // GH-4462
void replaceShouldUpsert() {
- template.replace(new BasicQuery("{}"), new Sith(), ReplaceOptions.options().upsert());
+ template.replace(new BasicQuery("{}"), new Sith(), ReplaceOptions.replaceOptions().upsert());
ArgumentCaptor options = ArgumentCaptor
.forClass(com.mongodb.client.model.ReplaceOptions.class);
verify(collection).replaceOne(any(), any(), options.capture());
assertThat(options.getValue().isUpsert()).isTrue();
-
}
- @Test // GH-4300
+ @Test // GH-4462
void replaceShouldUseDefaultCollationWhenPresent() {
- template.replace(new BasicQuery("{}"), new Sith(), ReplaceOptions.options());
+ template.replace(new BasicQuery("{}"), new Sith(), ReplaceOptions.replaceOptions());
ArgumentCaptor options = ArgumentCaptor
.forClass(com.mongodb.client.model.ReplaceOptions.class);
@@ -2474,6 +2486,34 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests {
assertThat(options.getValue().getCollation().getLocale()).isEqualTo("de_AT");
}
+ @Test // GH-4462
+ void replaceShouldUseHintIfPresent() {
+
+ template.replace(new BasicQuery("{}").withHint("index-to-use"), new Sith(), ReplaceOptions.replaceOptions().upsert());
+
+ ArgumentCaptor options = ArgumentCaptor
+ .forClass(com.mongodb.client.model.ReplaceOptions.class);
+ verify(collection).replaceOne(any(), any(), options.capture());
+
+ assertThat(options.getValue().getHintString()).isEqualTo("index-to-use");
+ }
+
+ @Test // GH-4462
+ void replaceShouldApplyWriteConcern() {
+
+ template.setWriteConcernResolver(new WriteConcernResolver() {
+ public WriteConcern resolve(MongoAction action) {
+
+ assertThat(action.getMongoActionOperation()).isEqualTo(MongoActionOperation.REPLACE);
+ return WriteConcern.UNACKNOWLEDGED;
+ }
+ });
+
+ template.replace(new BasicQuery("{}").withHint("index-to-use"), new Sith(), ReplaceOptions.replaceOptions().upsert());
+
+ verify(collection).withWriteConcern(eq(WriteConcern.UNACKNOWLEDGED));
+ }
+
class AutogenerateableId {
@Id BigInteger id;
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateReplaceTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateReplaceTests.java
new file mode 100644
index 000000000..3f4fab96b
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateReplaceTests.java
@@ -0,0 +1,309 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.mongodb.core;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.springframework.data.mongodb.core.ReplaceOptions.*;
+import static org.springframework.data.mongodb.core.query.Criteria.*;
+import static org.springframework.data.mongodb.core.query.Query.*;
+
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.bson.BsonInt64;
+import org.bson.Document;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.reactivestreams.Publisher;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.data.mongodb.core.mapping.Field;
+import org.springframework.data.mongodb.test.util.Client;
+import org.springframework.data.mongodb.test.util.MongoClientExtension;
+
+import com.mongodb.client.model.Filters;
+import com.mongodb.client.result.UpdateResult;
+import com.mongodb.reactivestreams.client.MongoClient;
+import com.mongodb.reactivestreams.client.MongoCollection;
+
+/**
+ * @author Christoph Strobl
+ */
+@ExtendWith(MongoClientExtension.class)
+public class ReactiveMongoTemplateReplaceTests {
+
+ static final String DB_NAME = "mongo-template-replace-tests";
+ static final String RESTAURANT_COLLECTION = "restaurant";
+
+ static @Client MongoClient client;
+ private ReactiveMongoTemplate template;
+
+ @BeforeEach
+ void beforeEach() {
+
+ template = new ReactiveMongoTemplate(client, DB_NAME);
+ template.setEntityLifecycleEventsEnabled(false);
+
+ initTestData();
+ }
+
+ @AfterEach()
+ void afterEach() {
+ clearTestData();
+ }
+
+ @Test // GH-4462
+ void replacesExistingDocument() {
+
+ Mono result = template.replace(query(where("name").is("Central Perk Cafe")),
+ new Restaurant("Central Pork Cafe", "Manhattan"));
+
+ result.as(StepVerifier::create).consumeNextWith(it -> {
+ assertThat(it.getMatchedCount()).isEqualTo(1);
+ assertThat(it.getModifiedCount()).isEqualTo(1);
+ }).verifyComplete();
+
+ retrieve(collection -> collection.find(Filters.eq("_id", 1)).first()).as(StepVerifier::create)
+ .consumeNextWith(document -> {
+ assertThat(document).containsEntry("r-name", "Central Pork Cafe");
+ }).verifyComplete();
+ }
+
+ @Test // GH-4462
+ void replacesFirstOnMoreThanOneMatch() {
+
+ Mono result = template.replace(query(where("violations").exists(true)),
+ new Restaurant("Central Pork Cafe", "Manhattan"));
+
+ result.as(StepVerifier::create).consumeNextWith(it -> {
+ assertThat(it.getMatchedCount()).isEqualTo(1);
+ assertThat(it.getModifiedCount()).isEqualTo(1);
+ }).verifyComplete();
+
+ retrieve(collection -> collection.find(Filters.eq("_id", 2)).first()).as(StepVerifier::create)
+ .consumeNextWith(document -> {
+ assertThat(document).containsEntry("r-name", "Central Pork Cafe");
+ }).verifyComplete();
+ }
+
+ @Test // GH-4462
+ void replacesExistingDocumentWithRawDoc() {
+
+ Mono result = template.replace(query(where("r-name").is("Central Perk Cafe")),
+ Document.parse("{ 'r-name' : 'Central Pork Cafe', 'Borough' : 'Manhattan' }"),
+ template.getCollectionName(Restaurant.class));
+
+ result.as(StepVerifier::create).consumeNextWith(it -> {
+ assertThat(it.getMatchedCount()).isEqualTo(1);
+ assertThat(it.getModifiedCount()).isEqualTo(1);
+ }).verifyComplete();
+
+ retrieve(collection -> collection.find(Filters.eq("_id", 1)).first()).as(StepVerifier::create)
+ .consumeNextWith(document -> {
+ assertThat(document).containsEntry("r-name", "Central Pork Cafe");
+ }).verifyComplete();
+ }
+
+ @Test // GH-4462
+ void replacesExistingDocumentWithRawDocMappingQueryAgainstDomainType() {
+
+ Mono result = template.replace(query(where("name").is("Central Perk Cafe")), Restaurant.class,
+ Document.parse("{ 'r-name' : 'Central Pork Cafe', 'Borough' : 'Manhattan' }"), ReplaceOptions.none());
+
+ result.as(StepVerifier::create).consumeNextWith(it -> {
+ assertThat(it.getMatchedCount()).isEqualTo(1);
+ assertThat(it.getModifiedCount()).isEqualTo(1);
+ }).verifyComplete();
+
+ retrieve(collection -> collection.find(Filters.eq("_id", 1)).first()).as(StepVerifier::create)
+ .consumeNextWith(document -> {
+ assertThat(document).containsEntry("r-name", "Central Pork Cafe");
+ }).verifyComplete();
+ }
+
+ @Test // GH-4462
+ void replacesExistingDocumentWithMatchingId() {
+
+ Mono result = template.replace(query(where("name").is("Central Perk Cafe")),
+ new Restaurant(1L, "Central Pork Cafe", "Manhattan", 0));
+
+ result.as(StepVerifier::create).consumeNextWith(it -> {
+ assertThat(it.getMatchedCount()).isEqualTo(1);
+ assertThat(it.getModifiedCount()).isEqualTo(1);
+ }).verifyComplete();
+
+ retrieve(collection -> collection.find(Filters.eq("_id", 1)).first()).as(StepVerifier::create)
+ .consumeNextWith(document -> {
+ assertThat(document).containsEntry("r-name", "Central Pork Cafe");
+ }).verifyComplete();
+ }
+
+ @Test // GH-4462
+ void replacesExistingDocumentWithNewIdThrowsDataIntegrityViolationException() {
+
+ template.replace(query(where("name").is("Central Perk Cafe")),
+ new Restaurant(4L, "Central Pork Cafe", "Manhattan", 0))
+ .as(StepVerifier::create)
+ .expectError(DataIntegrityViolationException.class)
+ .verify();
+ }
+
+ @Test // GH-4462
+ void doesNothingIfNoMatchFoundAndUpsertSetToFalse/* by default */() {
+
+ Mono result = template.replace(query(where("name").is("Pizza Rat's Pizzaria")),
+ new Restaurant(null, "Pizza Rat's Pizzaria", "Manhattan", 8));
+
+ result.as(StepVerifier::create).consumeNextWith(it -> {
+ assertThat(it.getMatchedCount()).isEqualTo(0);
+ assertThat(it.getModifiedCount()).isEqualTo(0);
+ }).verifyComplete();
+
+ retrieve(collection -> collection.find(Filters.eq("r-name", "Pizza Rat's Pizzaria")).first())
+ .as(StepVerifier::create).verifyComplete();
+ }
+
+ @Test // GH-4462
+ void insertsIfNoMatchFoundAndUpsertSetToTrue() {
+
+ Mono result = template.replace(query(where("name").is("Pizza Rat's Pizzaria")),
+ new Restaurant(4L, "Pizza Rat's Pizzaria", "Manhattan", 8), replaceOptions().upsert());
+
+ result.as(StepVerifier::create).consumeNextWith(it -> {
+ assertThat(it.getMatchedCount()).isEqualTo(0);
+ assertThat(it.getModifiedCount()).isEqualTo(0);
+ assertThat(it.getUpsertedId()).isEqualTo(new BsonInt64(4L));
+ }).verifyComplete();
+
+ retrieve(collection -> collection.find(Filters.eq("_id", 4)).first()).as(StepVerifier::create)
+ .consumeNextWith(document -> {
+ assertThat(document).containsEntry("r-name", "Pizza Rat's Pizzaria");
+ });
+ }
+
+ void initTestData() {
+
+ List testData = Stream.of( //
+ "{ '_id' : 1, 'r-name' : 'Central Perk Cafe', 'Borough' : 'Manhattan' }",
+ "{ '_id' : 2, 'r-name' : 'Rock A Feller Bar and Grill', 'Borough' : 'Queens', 'violations' : 2 }",
+ "{ '_id' : 3, 'r-name' : 'Empire State Pub', 'Borough' : 'Brooklyn', 'violations' : 0 }") //
+ .map(Document::parse).collect(Collectors.toList());
+
+ doInCollection(collection -> collection.insertMany(testData));
+ }
+
+ void clearTestData() {
+ doInCollection(collection -> collection.deleteMany(new Document()));
+ }
+
+ void doInCollection(Function, Publisher>> fkt) {
+ retrieve(collection -> Mono.from(fkt.apply(collection))).then().as(StepVerifier::create).verifyComplete();
+ }
+
+ Mono retrieve(Function, Publisher> fkt) {
+ return Mono.from(fkt.apply(client.getDatabase(DB_NAME).getCollection(RESTAURANT_COLLECTION)));
+ }
+
+ @org.springframework.data.mongodb.core.mapping.Document(RESTAURANT_COLLECTION)
+ static class Restaurant {
+
+ Long id;
+
+ @Field("r-name") String name;
+ String borough;
+ Integer violations;
+
+ Restaurant() {}
+
+ Restaurant(String name, String borough) {
+
+ this.name = name;
+ this.borough = borough;
+ }
+
+ Restaurant(Long id, String name, String borough, Integer violations) {
+
+ this.id = id;
+ this.name = name;
+ this.borough = borough;
+ this.violations = violations;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getRName() {
+ return name;
+ }
+
+ public void setRName(String rName) {
+ this.name = rName;
+ }
+
+ public String getBorough() {
+ return borough;
+ }
+
+ public void setBorough(String borough) {
+ this.borough = borough;
+ }
+
+ public int getViolations() {
+ return violations;
+ }
+
+ public void setViolations(int violations) {
+ this.violations = violations;
+ }
+
+ @Override
+ public String toString() {
+ return "Restaurant{" + "id=" + id + ", name='" + name + '\'' + ", borough='" + borough + '\'' + ", violations="
+ + violations + '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Restaurant that = (Restaurant) o;
+ return violations == that.violations && Objects.equals(id, that.id) && Objects.equals(name, that.name)
+ && Objects.equals(borough, that.borough);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, name, borough, violations);
+ }
+ }
+
+}
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 8d368e280..c7b1e8f03 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
@@ -20,6 +20,8 @@ import static org.mockito.Mockito.*;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
import static org.springframework.data.mongodb.test.util.Assertions.assertThat;
+import com.mongodb.WriteConcern;
+import org.springframework.data.mongodb.core.MongoTemplateUnitTests.Sith;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@@ -1602,6 +1604,83 @@ public class ReactiveMongoTemplateUnitTests {
verify(changeStreamPublisher).startAfter(eq(token));
}
+ @Test // GH-4462
+ void replaceShouldUseCollationWhenPresent() {
+
+ template.replace(new BasicQuery("{}").collation(Collation.of("fr")), new Jedi()).subscribe();
+
+ ArgumentCaptor options = ArgumentCaptor
+ .forClass(com.mongodb.client.model.ReplaceOptions.class);
+ verify(collection).replaceOne(any(Bson.class), any(), options.capture());
+
+ assertThat(options.getValue().isUpsert()).isFalse();
+ assertThat(options.getValue().getCollation().getLocale()).isEqualTo("fr");
+ }
+
+ @Test // GH-4462
+ void replaceShouldNotUpsertByDefault() {
+
+ template.replace(new BasicQuery("{}"), new MongoTemplateUnitTests.Sith()).subscribe();
+
+ ArgumentCaptor options = ArgumentCaptor
+ .forClass(com.mongodb.client.model.ReplaceOptions.class);
+ verify(collection).replaceOne(any(Bson.class), any(), options.capture());
+
+ assertThat(options.getValue().isUpsert()).isFalse();
+ }
+
+ @Test // GH-4462
+ void replaceShouldUpsert() {
+
+ template.replace(new BasicQuery("{}"), new MongoTemplateUnitTests.Sith(), org.springframework.data.mongodb.core.ReplaceOptions.replaceOptions().upsert()).subscribe();
+
+ ArgumentCaptor options = ArgumentCaptor
+ .forClass(com.mongodb.client.model.ReplaceOptions.class);
+ verify(collection).replaceOne(any(Bson.class), any(), options.capture());
+
+ assertThat(options.getValue().isUpsert()).isTrue();
+ }
+
+ @Test // GH-4462
+ void replaceShouldUseDefaultCollationWhenPresent() {
+
+ template.replace(new BasicQuery("{}"), new MongoTemplateUnitTests.Sith(), org.springframework.data.mongodb.core.ReplaceOptions.replaceOptions()).subscribe();
+
+ ArgumentCaptor options = ArgumentCaptor
+ .forClass(com.mongodb.client.model.ReplaceOptions.class);
+ verify(collection).replaceOne(any(Bson.class), any(), options.capture());
+
+ assertThat(options.getValue().getCollation().getLocale()).isEqualTo("de_AT");
+ }
+
+ @Test // GH-4462
+ void replaceShouldUseHintIfPresent() {
+
+ template.replace(new BasicQuery("{}").withHint("index-to-use"), new MongoTemplateUnitTests.Sith(), org.springframework.data.mongodb.core.ReplaceOptions.replaceOptions().upsert()).subscribe();
+
+ ArgumentCaptor options = ArgumentCaptor
+ .forClass(com.mongodb.client.model.ReplaceOptions.class);
+ verify(collection).replaceOne(any(Bson.class), any(), options.capture());
+
+ assertThat(options.getValue().getHintString()).isEqualTo("index-to-use");
+ }
+
+ @Test // GH-4462
+ void replaceShouldApplyWriteConcern() {
+
+ template.setWriteConcernResolver(new WriteConcernResolver() {
+ public WriteConcern resolve(MongoAction action) {
+
+ assertThat(action.getMongoActionOperation()).isEqualTo(MongoActionOperation.REPLACE);
+ return WriteConcern.UNACKNOWLEDGED;
+ }
+ });
+
+ template.replace(new BasicQuery("{}").withHint("index-to-use"), new Sith(), org.springframework.data.mongodb.core.ReplaceOptions.replaceOptions().upsert()).subscribe();
+
+ verify(collection).withWriteConcern(eq(WriteConcern.UNACKNOWLEDGED));
+ }
+
private void stubFindSubscribe(Document document) {
Publisher realPublisher = Flux.just(document);
diff --git a/src/main/antora/modules/ROOT/pages/mongodb/template-crud-operations.adoc b/src/main/antora/modules/ROOT/pages/mongodb/template-crud-operations.adoc
index c9d26289f..06b84f0bc 100644
--- a/src/main/antora/modules/ROOT/pages/mongodb/template-crud-operations.adoc
+++ b/src/main/antora/modules/ROOT/pages/mongodb/template-crud-operations.adoc
@@ -182,7 +182,8 @@ A similar set of insert operations is also available:
=== How the `_id` Field is Handled in the Mapping Layer
MongoDB requires that you have an `_id` field for all documents.
-If you do not provide one, the driver assigns an `ObjectId` with a generated value. When you use the `MappingMongoConverter`, certain rules govern how properties from the Java class are mapped to this `_id` field:
+If you do not provide one, the driver assigns an `ObjectId` with a generated value without considering your domain model as the server isn't aware of your identifier type.
+When you use the `MappingMongoConverter`, certain rules govern how properties from the Java class are mapped to this `_id` field:
. A property or field annotated with `@Id` (`org.springframework.data.annotation.Id`) maps to the `_id` field.
. A property or field without an annotation but named `id` maps to the `_id` field.
@@ -477,6 +478,47 @@ Mono result = template.update(Person.class)
WARNING: `upsert` does not support ordering. Please use xref:mongodb/template-crud-operations.adoc#mongo-template.find-and-upsert[findAndModify] to apply `Sort`.
+[[mongo-template.replace]]
+=== Replacing Documents in a Collection
+
+The various `replace` methods available via `MongoTemplate` allow to override a single matching Document.
+If no match is found a new one can be upserted (as outlined in the previous section) by providing `ReplaceOptions` with according configuration.
+
+====
+.Replace one
+[source,java]
+----
+Person tom = template.insert(new Person("Motte", 21)); <1>
+Query query = Query.query(Criteria.where("firstName").is(tom.getFirstName())); <2>
+tom.setFirstname("Tom"); <3>
+template.replace(query, tom, ReplaceOptions.none()); <4>
+----
+<1> Insert a new document.
+<2> The query used to identify the single document to replace.
+<3> Set up the replacement document which must hold either the same `_id` as the existing or no `_id` at all.
+<4> Run the replace operation.
+.Replace one with upsert
+[source,java]
+----
+Person tom = new Person("id-123", "Tom", 21) <1>
+Query query = Query.query(Criteria.where("firstName").is(tom.getFirstName()));
+template.replace(query, tom, ReplaceOptions.replaceOptions().upsert()); <2>
+----
+<1> The `_id` value needs to be provided for upsert, otherwise MongoDB will generate an `ObjectId`.
+As MongoDB is not aware of your domain type, any `@Field(targetType)` hints are not considered and the resulting `ObjectId` might be not compatible with your domain model.
+<2> Use `upsert` to insert a new document if no match is found
+====
+
+[WARNING]
+====
+It is not possible to change the `_id` of existing documents with a replace operation.
+On `upsert` MongoDB uses 2 ways of determining the new id for the entry:
+* The `_id` is used within the query as in `{"_id" : 1234 }`
+* The `_id` is present in the replacement document.
+If no `_id` is provided in either way, MongoDB will create a new `ObjectId` for the document.
+This may lead to mapping and data lookup malfunctions if the used domain types `id` property has a different type like e.g. `Long`.
+====
+
[[mongo-template.find-and-upsert]]
== Find and Modify