diff --git a/src/main/java/io/xlate/validation/constraints/Expression.java b/src/main/java/io/xlate/validation/constraints/Expression.java index 0cbc0dc..592e818 100644 --- a/src/main/java/io/xlate/validation/constraints/Expression.java +++ b/src/main/java/io/xlate/validation/constraints/Expression.java @@ -30,6 +30,7 @@ import java.lang.annotation.Target; import javax.validation.Constraint; +import javax.validation.ConstraintViolation; import javax.validation.Payload; import io.xlate.validation.internal.constraintvalidators.ExpressionValidator; @@ -60,6 +61,25 @@ */ String value(); + /** + * An EL expression used to determine if the expression given by + * {@link #value()} should be checked. This expression is available to + * short-circuit the constraint validation of this {@link Expression} in + * scenarios when it should not apply, e.g. a value is null and the + * constraint only applies to non-null values. + * + * @return the expression to evaluate to determine whether the constraint + * should be checked + */ + String when() default ""; + + /** + * Name of the node to be identified in a {@link ConstraintViolation} should + * validation fail. + * + * @return the name of the node to be identified in a + * {@link ConstraintViolation} should validation fail. + */ String node() default ""; /** diff --git a/src/main/java/io/xlate/validation/constraints/JdbcStatement.java b/src/main/java/io/xlate/validation/constraints/JdbcStatement.java index d74fe1f..12ec654 100644 --- a/src/main/java/io/xlate/validation/constraints/JdbcStatement.java +++ b/src/main/java/io/xlate/validation/constraints/JdbcStatement.java @@ -85,6 +85,18 @@ */ String[] parameters() default {}; + /** + * An EL expression used to determine if the expression given by + * {@link #value()} should be checked. This expression is available to + * short-circuit the constraint validation of this {@link Expression} in + * scenarios when it should not apply, e.g. a value is null and the + * constraint only applies to non-null values. + * + * @return the expression to evaluate to determine whether the constraint + * should be checked + */ + String when() default ""; + /** * The JNDI lookup name of the resource to be used to obtain a * {@link Connection}. It can link to any compatible {@link DataSource} diff --git a/src/main/java/io/xlate/validation/internal/constraintvalidators/BooleanExpression.java b/src/main/java/io/xlate/validation/internal/constraintvalidators/BooleanExpression.java new file mode 100644 index 0000000..d659aa4 --- /dev/null +++ b/src/main/java/io/xlate/validation/internal/constraintvalidators/BooleanExpression.java @@ -0,0 +1,22 @@ +package io.xlate.validation.internal.constraintvalidators; + +import javax.el.ELProcessor; +import javax.validation.ConstraintDeclarationException; + +public interface BooleanExpression { + + default boolean evaluate(ELProcessor processor, String expression) { + if (!expression.isEmpty()) { + Object result = processor.eval(expression); + + if (result instanceof Boolean) { + return (Boolean) result; + } else { + throw new ConstraintDeclarationException("Expression `" + expression + "` does not evaluate to Boolean"); + } + } + + return true; + } + +} diff --git a/src/main/java/io/xlate/validation/internal/constraintvalidators/ExpressionValidator.java b/src/main/java/io/xlate/validation/internal/constraintvalidators/ExpressionValidator.java index 52d08fc..cef7003 100644 --- a/src/main/java/io/xlate/validation/internal/constraintvalidators/ExpressionValidator.java +++ b/src/main/java/io/xlate/validation/internal/constraintvalidators/ExpressionValidator.java @@ -17,13 +17,12 @@ package io.xlate.validation.internal.constraintvalidators; import javax.el.ELProcessor; -import javax.validation.ConstraintDeclarationException; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; import io.xlate.validation.constraints.Expression; -public class ExpressionValidator implements ConstraintValidator { +public class ExpressionValidator implements BooleanExpression, ConstraintValidator { private Expression annotation; @@ -34,13 +33,14 @@ public void initialize(Expression constraintAnnotation) { @Override public boolean isValid(Object target, ConstraintValidatorContext context) { - String expression = annotation.value(); ELProcessor processor = new ELProcessor(); processor.defineBean("self", target); - Object result = processor.eval(expression); + if (!evaluate(processor, annotation.when())) { + return true; + } - if (result instanceof Boolean) { + if (!evaluate(processor, annotation.value())) { String nodeName = annotation.node(); if (!nodeName.isEmpty()) { @@ -50,9 +50,9 @@ public boolean isValid(Object target, ConstraintValidatorContext context) { .addConstraintViolation(); } - return (Boolean) result; + return false; } - throw new ConstraintDeclarationException("Expression `" + expression + "` does not evaluate to Boolean"); + return true; } } diff --git a/src/main/java/io/xlate/validation/internal/constraintvalidators/JdbcStatementValidator.java b/src/main/java/io/xlate/validation/internal/constraintvalidators/JdbcStatementValidator.java index 7adcabb..5d55bb8 100644 --- a/src/main/java/io/xlate/validation/internal/constraintvalidators/JdbcStatementValidator.java +++ b/src/main/java/io/xlate/validation/internal/constraintvalidators/JdbcStatementValidator.java @@ -32,7 +32,7 @@ import io.xlate.validation.constraints.JdbcStatement; -public class JdbcStatementValidator implements ConstraintValidator { +public class JdbcStatementValidator implements BooleanExpression, ConstraintValidator { JdbcStatement annotation; DataSource dataSource; @@ -47,7 +47,22 @@ public void initialize(JdbcStatement constraintAnnotation) { public boolean isValid(Object target, ConstraintValidatorContext context) { final String sql = annotation.value(); final String[] parameters = annotation.parameters(); - final boolean valid = executeQuery(target, sql, parameters); + final String when = annotation.when(); + final ELProcessor processor; + + if (!when.isEmpty() || parameters.length > 0) { + processor = new ELProcessor(); + processor.defineBean("self", target); + + if (!evaluate(processor, when)) { + // Validation does not apply based on 'when' condition + return true; + } + } else { + processor = null; + } + + final boolean valid = executeQuery(processor, sql, parameters); if (!valid) { updateValidationContext(context, annotation.node(), annotation.message()); @@ -72,12 +87,12 @@ DataSource getDataSource(String dataSourceLookup) { return dataSource; } - boolean executeQuery(Object target, String sql, String[] parameters) { + boolean executeQuery(ELProcessor processor, String sql, String[] parameters) { final boolean valid; try (Connection connection = dataSource.getConnection()) { try (PreparedStatement statement = connection.prepareStatement(sql)) { - setParameters(target, parameters, statement); + setParameters(processor, parameters, statement); try (ResultSet results = statement.executeQuery()) { valid = results.next(); @@ -90,13 +105,11 @@ boolean executeQuery(Object target, String sql, String[] parameters) { return valid; } - void setParameters(Object target, String[] parameters, PreparedStatement statement) { + void setParameters(ELProcessor processor, String[] parameters, PreparedStatement statement) { if (parameters.length == 0) { return; } - ELProcessor processor = new ELProcessor(); - processor.defineBean("self", target); int p = 0; for (String parameterExpression : parameters) { diff --git a/src/test/java/io/xlate/validation/internal/constraintvalidators/ExpressionValidatorTest.java b/src/test/java/io/xlate/validation/internal/constraintvalidators/ExpressionValidatorTest.java index d52946c..bddaa12 100644 --- a/src/test/java/io/xlate/validation/internal/constraintvalidators/ExpressionValidatorTest.java +++ b/src/test/java/io/xlate/validation/internal/constraintvalidators/ExpressionValidatorTest.java @@ -55,6 +55,7 @@ class ExpressionValidatorTest { @BeforeEach void setUp() { target = new ExpressionValidator(); + Mockito.when(annotation.when()).thenReturn(""); Mockito.when(annotation.node()).thenReturn(""); } @@ -126,4 +127,40 @@ void testPrimitiveIntArray() { target.initialize(annotation); Assertions.assertTrue(target.isValid(data, context)); } + + @Test + void testDateComparisonWhenConditionTrue() { + Mockito.when(annotation.value()).thenReturn("self.earlier lt self.later"); + Mockito.when(annotation.when()).thenReturn("self.earlier.time eq 5"); + Map data = new HashMap<>(); + data.put("earlier", new Date(5)); + data.put("later", new Date()); + target.initialize(annotation); + Assertions.assertTrue(target.isValid(data, context)); + } + + @Test + void testDateComparisonWhenConditionFalse() { + Mockito.when(annotation.value()).thenReturn("self.earlier lt self.later"); + Mockito.when(annotation.when()).thenReturn("self.earlier.time ne 1"); + Map data = new HashMap<>(); + data.put("earlier", new Date(1)); + data.put("later", new Date()); + target.initialize(annotation); + Assertions.assertTrue(target.isValid(data, context)); + } + + @Test + void testNonBooleanInWhenCondition() { + Mockito.when(annotation.value()).thenReturn("self.earlier lt self.later"); + Mockito.when(annotation.when()).thenReturn("'0'"); + Map data = new HashMap<>(); + data.put("earlier", new Date(1)); + data.put("later", new Date()); + target.initialize(annotation); + ConstraintDeclarationException ex = assertThrows(ConstraintDeclarationException.class, () -> { + target.isValid(data, context); + }); + Assertions.assertTrue(ex.getMessage().contains("`'0'` does not evaluate to Boolean")); + } } diff --git a/src/test/java/io/xlate/validation/internal/constraintvalidators/JdbcStatementValidatorTest.java b/src/test/java/io/xlate/validation/internal/constraintvalidators/JdbcStatementValidatorTest.java index 73c60ff..43f3dc7 100644 --- a/src/test/java/io/xlate/validation/internal/constraintvalidators/JdbcStatementValidatorTest.java +++ b/src/test/java/io/xlate/validation/internal/constraintvalidators/JdbcStatementValidatorTest.java @@ -13,6 +13,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import javax.el.ELProcessor; import javax.naming.Context; import javax.naming.InitialContext; import javax.naming.NamingException; @@ -23,28 +24,60 @@ import javax.validation.ConstraintValidatorContext.ConstraintViolationBuilder.NodeBuilderCustomizableContext; import javax.validation.ValidationException; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.platform.runner.JUnitPlatform; import org.junit.runner.RunWith; +import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import org.mockito.stubbing.Answer; +import io.xlate.validation.constraints.JdbcStatement; + @ExtendWith(MockitoExtension.class) @RunWith(JUnitPlatform.class) class JdbcStatementValidatorTest { JdbcStatementValidator target; + @Mock + ConstraintValidatorContext constraintContext; + @BeforeEach void setUp() { target = new JdbcStatementValidator(); } + @Test + void testWhenExpessionFalse() throws NamingException { + JdbcStatement annotation = Mockito.mock(JdbcStatement.class); + Mockito.when(annotation.value()).thenReturn("SELECT 1"); + Mockito.when(annotation.when()).thenReturn("0 == 1"); + Mockito.when(annotation.dataSourceLookup()).thenReturn(""); + + DataSource dataSource = Mockito.mock(DataSource.class); + System.setProperty("java.naming.factory.initial", "org.osjava.sj.SimpleContextFactory"); + System.setProperty("org.osjava.sj.jndi.shared", "true"); + System.setProperty("org.osjava.sj.delimiter", "/"); + + Context context = new InitialContext(); + context.createSubcontext("java:"); + context.createSubcontext("java:comp"); + context.bind("java:comp/DefaultDataSource", dataSource); + + try { + target.initialize(annotation); + Assertions.assertTrue(target.isValid(new Object(), constraintContext)); + } finally { + context.close(); + } + } + @Test void testGetDataSourceReturnsDefault() throws NamingException { DataSource dataSource = Mockito.mock(DataSource.class); @@ -110,7 +143,6 @@ void testGetDataSourceThrowsValidationExceptionCausedByNamingException() throws @Test void testExecuteQuerySucceeds() throws SQLException { - Object self = new Object(); String sql = "SELECT 1"; String[] parameters = { }; @@ -126,19 +158,18 @@ void testExecuteQuerySucceeds() throws SQLException { Mockito.when(results.next()).thenReturn(true); target.dataSource = dataSource; - assertTrue(target.executeQuery(self, sql, parameters)); + assertTrue(target.executeQuery(null, sql, parameters)); } @Test void testExecuteQueryThrowsValidationException() throws SQLException { - Object self = new Object(); DataSource dataSource = Mockito.mock(DataSource.class); String sql = "SELECT 1"; String[] parameters = { }; Mockito.when(dataSource.getConnection()).thenThrow(SQLException.class); target.dataSource = dataSource; ValidationException ex = assertThrows(ValidationException.class, () -> { - target.executeQuery(self, sql, parameters); + target.executeQuery(null, sql, parameters); }); Throwable cause = ex.getCause(); @@ -149,7 +180,6 @@ void testExecuteQueryThrowsValidationException() throws SQLException { @Test @MockitoSettings(strictness = Strictness.LENIENT) void testSetParametersNoParameters() throws SQLException { - Object self = new Object(); String[] parameters = { }; PreparedStatement statement = Mockito.mock(PreparedStatement.class); final AtomicInteger callCount = new AtomicInteger(0); @@ -159,7 +189,7 @@ void testSetParametersNoParameters() throws SQLException { return null; }).when(statement).setObject(1, Object.class); - target.setParameters(self, parameters, statement); + target.setParameters(null, parameters, statement); assertEquals(0, callCount.get()); } @@ -190,7 +220,10 @@ void testSetParametersSucceeds() throws SQLException { return null; }).when(statement).setObject(2, self.getValue2()); - target.setParameters(self, parameters, statement); + ELProcessor processor = new ELProcessor(); + processor.defineBean("self", self); + + target.setParameters(processor, parameters, statement); assertEquals(2, callCount.get()); } @@ -211,8 +244,11 @@ void testSetParametersPropertyNotFound() { String[] parameters = { "self.value1", "self.value2" }; PreparedStatement statement = Mockito.mock(PreparedStatement.class); + ELProcessor processor = new ELProcessor(); + processor.defineBean("self", self); + ConstraintDeclarationException ex = assertThrows(ConstraintDeclarationException.class, () -> { - target.setParameters(self, parameters, statement); + target.setParameters(processor, parameters, statement); }); Throwable cause = ex.getCause(); @@ -229,10 +265,12 @@ void testSetParametersSQLException() { TestSetParametersSQLException self = new TestSetParametersSQLException(); String[] parameters = { "self" }; PreparedStatement statement = Mockito.mock(PreparedStatement.class); + ELProcessor processor = new ELProcessor(); + processor.defineBean("self", self); ConstraintDeclarationException ex = assertThrows(ConstraintDeclarationException.class, () -> { Mockito.doThrow(java.sql.SQLException.class).when(statement).setObject(1, self); - target.setParameters(self, parameters, statement); + target.setParameters(processor, parameters, statement); }); Throwable cause = ex.getCause();