Skip to content

Commit

Permalink
Add support for replaceOne operation.
Browse files Browse the repository at this point in the history
Add replace methods to MongoOperations and MongoTemplate that allow to replace the first matching document with a given value.

Closes: #4462
Original Pull Request: #4463
  • Loading branch information
jakubwladyslaw authored and christophstrobl committed Sep 11, 2023
1 parent 66c4a3b commit f7549f7
Show file tree
Hide file tree
Showing 6 changed files with 353 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1753,6 +1753,122 @@ default long exactCount(Query query, String collectionName) {
*/
<T> List<T> findAllAndRemove(Query query, Class<T> entityClass, String collectionName);

/**
* Triggers <a href="https://docs.mongodb.com/manual/reference/method/db.collection.replaceOne/">replaceOne</a> to
* replace a single document matching {@link Criteria} of given {@link Query} with the {@code replacement} document.
* <br />
* The collection name is derived from the {@literal replacement} type. <br />
* Options are defaulted to {@link ReplaceOptions#empty()}. <br />
* <strong>NOTE:</strong> The replacement entity must not hold an {@literal id}.
*
* @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}.
* @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.
*/
default <T> UpdateResult replace(Query query, T replacement) {
return replace(query, replacement, ReplaceOptions.empty());
}

/**
* Triggers <a href="https://docs.mongodb.com/manual/reference/method/db.collection.replaceOne/">replaceOne</a> to
* replace a single document matching {@link Criteria} of given {@link Query} with the {@code replacement}
* document.<br />
* Options are defaulted to {@link ReplaceOptions#empty()}. <br />
* <strong>NOTE:</strong> The replacement entity must not hold an {@literal id}.
*
* @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 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.
*/
default <T> UpdateResult replace(Query query, T replacement, String collectionName) {
return replace(query, replacement, ReplaceOptions.empty(), collectionName);
}

/**
* Triggers <a href="https://docs.mongodb.com/manual/reference/method/db.collection.replaceOne/">replaceOne</a> to
* replace a single document matching {@link Criteria} of given {@link Query} with the {@code replacement} document
* taking {@link ReplaceOptions} into account.<br />
* <strong>NOTE:</strong> The replacement entity must not hold an {@literal id}.
*
* @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}.
* @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.
*/
default <T> UpdateResult replace(Query query, T replacement, ReplaceOptions options) {
return replace(query, replacement, options, getCollectionName(ClassUtils.getUserClass(replacement)));
}

/**
* Triggers <a href="https://docs.mongodb.com/manual/reference/method/db.collection.replaceOne/">replaceOne</a> to
* replace a single document matching {@link Criteria} of given {@link Query} with the {@code replacement} document
* taking {@link ReplaceOptions} into account.<br />
* <strong>NOTE:</strong> The replacement entity must not hold an {@literal id}.
*
* @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}.
* @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.
*/
default <T> UpdateResult replace(Query query, T replacement, ReplaceOptions options, String collectionName) {

Assert.notNull(replacement, "Replacement must not be null");
return replace(query, replacement, options, (Class<T>) ClassUtils.getUserClass(replacement), collectionName);
}

/**
* Triggers <a href="https://docs.mongodb.com/manual/reference/method/db.collection.replaceOne/">replaceOne</a> to
* replace a single document matching {@link Criteria} of given {@link Query} with the {@code replacement} document
* taking {@link ReplaceOptions} into account.<br />
* <strong>NOTE:</strong> The replacement entity must not hold an {@literal id}.
*
* @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 entityType the type used for mapping the {@link Query} to domain type fields and deriving the collection
* 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.
*/
default <S> UpdateResult replace(Query query, S replacement, ReplaceOptions options, Class<S> entityType) {

return replace(query, replacement, options, entityType, getCollectionName(ClassUtils.getUserClass(entityType)));
}

/**
* Triggers <a href="https://docs.mongodb.com/manual/reference/method/db.collection.replaceOne/">replaceOne</a> to
* replace a single document matching {@link Criteria} of given {@link Query} with the {@code replacement} document
* taking {@link ReplaceOptions} into account.<br />
* <strong>NOTE:</strong> The replacement entity must not hold an {@literal id}.
*
* @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 entityType the type used for mapping the {@link Query} to domain type fields. 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.
*/
<S> UpdateResult replace(Query query, S replacement, ReplaceOptions options, Class<S> entityType,
String collectionName);

/**
* Returns the underlying {@link MongoConverter}.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@
* @author Anton Barkan
* @author Bartłomiej Mazur
* @author Michael Krog
* @author Jakub Zurawa
*/
public class MongoTemplate
implements MongoOperations, ApplicationContextAware, IndexOperationsProvider, ReadPreferenceAware {
Expand Down Expand Up @@ -1618,7 +1619,7 @@ protected Object saveDocument(String collectionName, Document dbDoc, Class<?> en
}
}

collectionToUse.replaceOne(filter, replacement, new ReplaceOptions().upsert(true));
collectionToUse.replaceOne(filter, replacement, new com.mongodb.client.model.ReplaceOptions().upsert(true));
}
return mapped.getId();
});
Expand Down Expand Up @@ -1749,7 +1750,7 @@ protected UpdateResult doUpdate(String collectionName, Query query, UpdateDefini
}
}

ReplaceOptions replaceOptions = updateContext.getReplaceOptions(entityClass);
com.mongodb.client.model.ReplaceOptions replaceOptions = updateContext.getReplaceOptions(entityClass);
return collection.replaceOne(filter, updateObj, replaceOptions);
} else {
return multi ? collection.updateMany(queryObj, updateObj, opts)
Expand Down Expand Up @@ -3421,6 +3422,56 @@ public MongoDatabaseFactory getMongoDatabaseFactory() {
return mongoDbFactory;
}

@Override
public <S> UpdateResult replace(Query query, S replacement, ReplaceOptions options, Class<S> 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 <S> UpdateResult doReplace(ReplaceOptions options, Class<S> 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}.
*
Expand Down Expand Up @@ -3555,4 +3606,33 @@ interface CountExecution {
long countDocuments(CollectionPreparer collectionPreparer, String collection, Document filter,
CountOptions options);
}

private static class ReplaceCallback implements CollectionCallback<UpdateResult> {

private final CollectionPreparer<MongoCollection<Document>> collectionPreparer;
private final Document query;
private final Document update;
private final @Nullable com.mongodb.client.model.Collation collation;
private final ReplaceOptions options;

ReplaceCallback(CollectionPreparer<MongoCollection<Document>> collectionPreparer, Document query, Document update,
@Nullable com.mongodb.client.model.Collation collation, ReplaceOptions options) {
this.collectionPreparer = collectionPreparer;
this.query = query;
this.update = update;
this.options = options;
this.collation = collation;
}

@Override
public UpdateResult doInCollection(MongoCollection<Document> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1782,7 +1782,7 @@ protected Mono<UpdateResult> doUpdate(String collectionName, Query query, @Nulla
deferredFilter = Mono.just(filter);
}

ReplaceOptions replaceOptions = updateContext.getReplaceOptions(entityClass);
com.mongodb.client.model.ReplaceOptions replaceOptions = updateContext.getReplaceOptions(entityClass);
return deferredFilter.flatMap(it -> Mono.from(collectionToUse.replaceOne(it, updateObj, replaceOptions)));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Options for
* <a href="https://docs.mongodb.com/manual/reference/method/db.collection.replaceOne/">replaceOne</a>.
* <br />
* Defaults to
* <dl>
* <dt>upsert</dt>
* <dd>false</dd>
* </dl>
*
* @author Jakub Zurawa
*/
package org.springframework.data.mongodb.core;

public class ReplaceOptions {
private boolean upsert;

private static final ReplaceOptions NONE = new ReplaceOptions() {

private static final String ERROR_MSG = "ReplaceOptions.none() cannot be changed; Please use ReplaceOptions.options() instead";

@Override
public ReplaceOptions upsert() {
throw new UnsupportedOperationException(ERROR_MSG);
}
};

/**
* Static factory method to create a {@link ReplaceOptions} instance.
* <dl>
* <dt>upsert</dt>
* <dd>false</dd>
* </dl>
*
* @return new instance of {@link ReplaceOptions}.
*/
public static ReplaceOptions options() {
return new 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
* <dl>
* <dt>upsert</dt>
* <dd>false</dd>
* </dl>
*
* @return new instance of {@link ReplaceOptions}.
*/
public static ReplaceOptions empty() {
return new ReplaceOptions();
}

/**
* Insert a new document if not exists.
*
* @return this.
*/
public ReplaceOptions upsert() {

this.upsert = true;
return this;
}

/**
* Get the bit indicating if to create a new document if not exists.
*
* @return {@literal true} if set.
*/
public boolean isUpsert() {
return upsert;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
* @author Mark Paluch
* @author Laszlo Csontos
* @author duozhilin
* @author Jakub Zurawa
*/
@ExtendWith(MongoClientExtension.class)
public class MongoTemplateTests {
Expand Down Expand Up @@ -3872,6 +3873,21 @@ void shouldExecuteQueryWithExpression() {
assertThat(loaded).isEqualTo(source2);
}

@Test // GH-4300
public void replaceShouldReplaceDocument() {

org.bson.Document doc = new org.bson.Document("foo", "bar");
String collectionName = "replace";
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(),
collectionName);

assertThat(updateResult.wasAcknowledged()).isTrue();
assertThat(template.findOne(query(where("foo").is("baz")), org.bson.Document.class, collectionName)).isNotNull();
}

private AtomicReference<ImmutableVersioned> createAfterSaveReference() {

AtomicReference<ImmutableVersioned> saved = new AtomicReference<>();
Expand Down
Loading

0 comments on commit f7549f7

Please sign in to comment.