diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/event/BeanValidationDelegate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/event/BeanValidationDelegate.java new file mode 100644 index 000000000..91107834f --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/event/BeanValidationDelegate.java @@ -0,0 +1,72 @@ +/* + * Copyright 2025 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.mapping.event; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; + +import java.util.Set; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.util.Assert; + +/** + * Delegate to handle common calls to Bean {@link Validator Validation}. + * + * @author Mark Paluch + * @since 4.5 + */ +class BeanValidationDelegate { + + private static final Log LOG = LogFactory.getLog(BeanValidationDelegate.class); + + private final Validator validator; + + /** + * Creates a new {@link BeanValidationDelegate} using the given {@link Validator}. + * + * @param validator must not be {@literal null}. + */ + public BeanValidationDelegate(Validator validator) { + Assert.notNull(validator, "Validator must not be null"); + this.validator = validator; + } + + /** + * Validate the given object. + * + * @param object + * @return set of constraint violations. + */ + public Set> validate(Object object) { + + if (LOG.isDebugEnabled()) { + LOG.debug(String.format("Validating object: %s", object)); + } + + Set> violations = validator.validate(object); + + if (!violations.isEmpty()) { + if (LOG.isDebugEnabled()) { + LOG.info(String.format("During object: %s validation violations found: %s", object, violations)); + } + } + + return violations; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/event/ReactiveValidatingEntityCallback.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/event/ReactiveValidatingEntityCallback.java new file mode 100644 index 000000000..7011da90b --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/event/ReactiveValidatingEntityCallback.java @@ -0,0 +1,69 @@ +/* + * Copyright 2025 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.mapping.event; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Validator; +import reactor.core.publisher.Mono; + +import java.util.Set; + +import org.bson.Document; + +import org.springframework.core.Ordered; + +/** + * Reactive variant of JSR-303 dependant entities validator. + *

+ * When it is registered as Spring component its automatically invoked after object to {@link Document} conversion and + * before entities are saved to the database. + * + * @author Mark Paluch + * @author Rene Felgenträger + * @since 4.5 + */ +public class ReactiveValidatingEntityCallback implements ReactiveBeforeSaveCallback, Ordered { + + private final BeanValidationDelegate delegate; + + /** + * Creates a new {@link ReactiveValidatingEntityCallback} using the given {@link Validator}. + * + * @param validator must not be {@literal null}. + */ + public ReactiveValidatingEntityCallback(Validator validator) { + this.delegate = new BeanValidationDelegate(validator); + } + + @Override + public Mono onBeforeSave(Object entity, Document document, String collection) { + + Set> violations = delegate.validate(entity); + + if (!violations.isEmpty()) { + return Mono.error(new ConstraintViolationException(violations)); + } + + return Mono.just(entity); + } + + @Override + public int getOrder() { + return 100; + } + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/event/ValidatingEntityCallback.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/event/ValidatingEntityCallback.java index bd453e981..260652616 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/event/ValidatingEntityCallback.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/event/ValidatingEntityCallback.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,29 +18,26 @@ package org.springframework.data.mongodb.core.mapping.event; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import jakarta.validation.Validator; + import java.util.Set; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; + import org.bson.Document; + import org.springframework.core.Ordered; -import org.springframework.util.Assert; /** * JSR-303 dependant entities validator. *

- * When it is registered as Spring component its automatically invoked after any {@link AbstractMongoEventListener} and - * before entities are saved in database. + * When it is registered as Spring component its automatically invoked after object to {@link Document} conversion and + * before entities are saved to the database. * - * @author original authors of {@link ValidatingMongoEventListener} * @author Rene Felgenträger - * @see {@link ValidatingMongoEventListener} + * @author Mark Paluch + * @since 4.5 */ public class ValidatingEntityCallback implements BeforeSaveCallback, Ordered { - private static final Log LOG = LogFactory.getLog(ValidatingEntityCallback.class); - - // TODO: create a validation handler (similar to "AuditingHandler") an reference it from "ValidatingMongoEventListener" and "ValidatingMongoEventListener" - private final Validator validator; + private final BeanValidationDelegate delegate; /** * Creates a new {@link ValidatingEntityCallback} using the given {@link Validator}. @@ -48,25 +45,18 @@ public class ValidatingEntityCallback implements BeforeSaveCallback, Ord * @param validator must not be {@literal null}. */ public ValidatingEntityCallback(Validator validator) { - Assert.notNull(validator, "Validator must not be null"); - this.validator = validator; + this.delegate = new BeanValidationDelegate(validator); } - // TODO: alternatively implement the "BeforeConvertCallback" interface and set the order to highest value ? @Override public Object onBeforeSave(Object entity, Document document, String collection) { - if (LOG.isDebugEnabled()) { - LOG.debug(String.format("Validating object: %s", entity)); - } - Set> violations = validator.validate(entity); + Set> violations = delegate.validate(entity); if (!violations.isEmpty()) { - if (LOG.isDebugEnabled()) { - LOG.info(String.format("During object: %s validation violations found: %s", entity, violations)); - } throw new ConstraintViolationException(violations); } + return entity; } @@ -74,4 +64,5 @@ public class ValidatingEntityCallback implements BeforeSaveCallback, Ord public int getOrder() { return 100; } + } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/event/ValidatingMongoEventListener.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/event/ValidatingMongoEventListener.java index 1242b361b..1854c486f 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/event/ValidatingMongoEventListener.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/mapping/event/ValidatingMongoEventListener.java @@ -15,31 +15,30 @@ */ package org.springframework.data.mongodb.core.mapping.event; -import java.util.Set; - +import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import jakarta.validation.Validator; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; +import java.util.Set; -import org.springframework.util.Assert; +import org.bson.Document; /** - * javax.validation dependant entities validator. When it is registered as Spring component its automatically invoked - * before entities are saved in database. + * JSR-303 dependant entities validator. + *

+ * When it is registered as Spring component its automatically invoked after object to {@link Document} conversion and + * before entities are saved to the database. * * @author Maciej Walkowiak * @author Oliver Gierke * @author Christoph Strobl - * - * @see {@link ValidatingEntityCallback} + * @deprecated since 4.5, use {@link ValidatingEntityCallback} respectively {@link ReactiveValidatingEntityCallback} + * instead to ensure ordering and interruption of saving when encountering validation constraint violations. */ +@Deprecated(since = "4.5") public class ValidatingMongoEventListener extends AbstractMongoEventListener { - private static final Log LOG = LogFactory.getLog(ValidatingMongoEventListener.class); - - private final Validator validator; + private final BeanValidationDelegate delegate; /** * Creates a new {@link ValidatingMongoEventListener} using the given {@link Validator}. @@ -47,26 +46,17 @@ public class ValidatingMongoEventListener extends AbstractMongoEventListener event) { - if (LOG.isDebugEnabled()) { - LOG.debug(String.format("Validating object: %s", event.getSource())); - } - Set violations = validator.validate(event.getSource()); + Set> violations = delegate.validate(event.getSource()); if (!violations.isEmpty()) { - - if (LOG.isDebugEnabled()) { - LOG.info(String.format("During object: %s validation violations found: %s", event.getSource(), violations)); - } throw new ConstraintViolationException(violations); } } + } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/mapping/event/ReactiveValidatingEntityCallbackUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/mapping/event/ReactiveValidatingEntityCallbackUnitTests.java new file mode 100644 index 000000000..c0db92a3d --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/mapping/event/ReactiveValidatingEntityCallbackUnitTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2025 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.mapping.event; + +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Validation; +import jakarta.validation.ValidatorFactory; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import reactor.test.StepVerifier; + +import org.bson.Document; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ReactiveValidatingEntityCallback}. + * + * @author Mark Paluch + * @author Rene Felgenträger + */ +class ReactiveValidatingEntityCallbackUnitTests { + + private ReactiveValidatingEntityCallback callback; + + @BeforeEach + void setUp() { + try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) { + callback = new ReactiveValidatingEntityCallback(factory.getValidator()); + } + } + + @Test // GH-4910 + void validationThrowsException() { + + Coordinates coordinates = new Coordinates(-1, -1); + + callback.onBeforeSave(coordinates, coordinates.toDocument(), "coordinates") // + .as(StepVerifier::create) // + .verifyError(ConstraintViolationException.class); + } + + @Test // GH-4910 + void validateSuccessful() { + + Coordinates coordinates = new Coordinates(0, 0); + + callback.onBeforeSave(coordinates, coordinates.toDocument(), "coordinates") // + .as(StepVerifier::create) // + .expectNext(coordinates) // + .verifyComplete(); + } + + record Coordinates(@NotNull @Min(0) Integer x, @NotNull @Min(0) Integer y) { + + Document toDocument() { + return Document.parse(""" + { + "x": %d, + "y": %d + } + """.formatted(x, y)); + } + } + +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/mapping/event/ValidatingEntityCallbackUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/mapping/event/ValidatingEntityCallbackUnitTests.java index 05e5feef5..e20da176b 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/mapping/event/ValidatingEntityCallbackUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/mapping/event/ValidatingEntityCallbackUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,37 +15,38 @@ */ package org.springframework.data.mongodb.core.mapping.event; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.*; import jakarta.validation.ConstraintViolationException; import jakarta.validation.Validation; import jakarta.validation.ValidatorFactory; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; + import org.bson.Document; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** - * Unit test for {@link ValidatingEntityCallback}. + * Unit tests for {@link ValidatingEntityCallback}. * * @author Rene Felgenträger + * @author Mark Paluch */ class ValidatingEntityCallbackUnitTests { private ValidatingEntityCallback callback; @BeforeEach - public void setUp() { + void setUp() { try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) { callback = new ValidatingEntityCallback(factory.getValidator()); } } - @Test - // GH-4910 - void invalidModel_throwsException() { + @Test // GH-4910 + void validationThrowsException() { + Coordinates coordinates = new Coordinates(-1, -1); assertThatExceptionOfType(ConstraintViolationException.class).isThrownBy( @@ -53,11 +54,12 @@ class ValidatingEntityCallbackUnitTests { .satisfies(e -> assertThat(e.getConstraintViolations()).hasSize(2)); } - @Test - // GH-4910 - void validModel_noExceptionThrown() { + @Test // GH-4910 + void validateSuccessful() { + Coordinates coordinates = new Coordinates(0, 0); Object entity = callback.onBeforeSave(coordinates, coordinates.toDocument(), "coordinates"); + assertThat(entity).isEqualTo(coordinates); } @@ -72,4 +74,5 @@ class ValidatingEntityCallbackUnitTests { """.formatted(x, y)); } } + } diff --git a/src/main/antora/modules/ROOT/pages/mongodb/lifecycle-events.adoc b/src/main/antora/modules/ROOT/pages/mongodb/lifecycle-events.adoc index acbbee679..e7a41a342 100644 --- a/src/main/antora/modules/ROOT/pages/mongodb/lifecycle-events.adoc +++ b/src/main/antora/modules/ROOT/pages/mongodb/lifecycle-events.adoc @@ -104,3 +104,44 @@ Can modify the domain object, to be returned after save, `Document` containing a |=== +=== Bean Validation + +Spring Data MongoDB supports Bean Validation for MongoDB entities annotated with https://beanvalidation.org/[https://xxx][Jakarta Validation annotations]. + +You can enable Bean Validation by registering `ValidatingEntityCallback` respectively `ReactiveValidatingEntityCallback` for reactive driver usage in your Spring `ApplicationContext` as shown in the following example: + +[tabs] +====== +Imperative:: ++ +[source,java,indent=0,subs="verbatim,quotes",role="primary"] +---- +@Configuration +class Config { + + @Bean + public ValidatingEntityCallback validatingEntityCallback(Validator validator) { + return new ValidatingEntityCallback(validator); + } +} +---- + +Reactive:: ++ +[source,java,indent=0,subs="verbatim,quotes",role="secondary"] +---- +@Configuration +class Config { + + @Bean + public ReactiveValidatingEntityCallback validatingEntityCallback(Validator validator) { + return new ReactiveValidatingEntityCallback(validator); + } +} +---- +====== + +If you're using both, imperative and reactive, then you can enable also both callbacks. + +NOTE: When using XML-based configuration, historically, `ValidatingMongoEventListener` is registered through our namespace handlers when configuring ``. +If you want to use the newer Entity Callback variant, make sure to not use ``, otherwise you'll end up with both, the `ValidatingMongoEventListener` and the `ValidatingEntityCallback` being registered.