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