diff --git a/bval-jsr/src/main/java/org/apache/bval/el/ELFacade.java b/bval-jsr/src/main/java/org/apache/bval/el/ELFacade.java index 6e53509eac..0fa45e361d 100644 --- a/bval-jsr/src/main/java/org/apache/bval/el/ELFacade.java +++ b/bval-jsr/src/main/java/org/apache/bval/el/ELFacade.java @@ -94,6 +94,7 @@ private static ELResolver initResolver() { resolver.add(new ListELResolver()); resolver.add(new ArrayELResolver()); resolver.add(new ResourceBundleELResolver()); + resolver.add(new FallThroughRecordELResolver()); resolver.add(new BeanELResolver()); return resolver; } diff --git a/bval-jsr/src/main/java/org/apache/bval/el/FallThroughRecordELResolver.java b/bval-jsr/src/main/java/org/apache/bval/el/FallThroughRecordELResolver.java new file mode 100644 index 0000000000..24d32d161b --- /dev/null +++ b/bval-jsr/src/main/java/org/apache/bval/el/FallThroughRecordELResolver.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you 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 + * + * http://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.apache.bval.el; + +import java.lang.reflect.RecordComponent; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import jakarta.el.ELContext; +import jakarta.el.RecordELResolver; + +/** + * {@link RecordELResolver} that resolves properties named after a component of the {@link Record} and falls through + * to the next resolver for everything else. + *

+ * The plain {@link RecordELResolver} claims every property of a record and invokes any public no-arg method of that + * name. This resolver leaves other properties unresolved, so JavaBean getters declared on a record are still handled + * by a subsequent {@link jakarta.el.BeanELResolver}, and arbitrary methods are not invoked through property syntax. + */ +class FallThroughRecordELResolver extends RecordELResolver { + /** + * Component names per record class. {@link Class#getRecordComponents()} is not cached by the JDK and builds new + * {@link RecordComponent} instances on every call. {@link ClassValue} keeps the entry tied to the class lifecycle, + * so the cache does not pin application classloaders. + */ + private static final ClassValue> COMPONENT_NAMES = new ClassValue<>() { + @Override + protected Set computeValue(final Class type) { + return Stream.of(type.getRecordComponents()).map(RecordComponent::getName) + .collect(Collectors.toUnmodifiableSet()); + } + }; + + @Override + public Object getValue(final ELContext context, final Object base, final Object property) { + final String component = componentName(context, base, property); + return component == null ? null : super.getValue(context, base, component); + } + + @Override + public Class getType(final ELContext context, final Object base, final Object property) { + final String component = componentName(context, base, property); + return component == null ? null : super.getType(context, base, component); + } + + @Override + public boolean isReadOnly(final ELContext context, final Object base, final Object property) { + final String component = componentName(context, base, property); + return component != null && super.isReadOnly(context, base, component); + } + + @Override + public void setValue(final ELContext context, final Object base, final Object property, final Object value) { + final String component = componentName(context, base, property); + if (component != null) { + super.setValue(context, base, component, value); + } + } + + /** + * @return {@code property} as a String if {@code base} is a {@link Record} with a component of that name, + * otherwise {@code null} + */ + private static String componentName(final ELContext context, final Object base, final Object property) { + Objects.requireNonNull(context); + if (base instanceof Record && property != null) { + final String name = property.toString(); + if (COMPONENT_NAMES.get(base.getClass()).contains(name)) { + return name; + } + } + return null; + } +} diff --git a/bval-jsr/src/test/java/org/apache/bval/el/FallThroughRecordELResolverTest.java b/bval-jsr/src/test/java/org/apache/bval/el/FallThroughRecordELResolverTest.java new file mode 100644 index 0000000000..5629ead28c --- /dev/null +++ b/bval-jsr/src/test/java/org/apache/bval/el/FallThroughRecordELResolverTest.java @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you 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 + * + * http://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.apache.bval.el; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.concurrent.atomic.AtomicInteger; + +import jakarta.el.ELContext; +import jakarta.el.ExpressionFactory; +import jakarta.el.PropertyNotWritableException; +import jakarta.el.StandardELContext; + +import org.junit.Test; + +public class FallThroughRecordELResolverTest { + + public record Item(String name, AtomicInteger invocations, String optional) { + public Item(String name) { + this(name, new AtomicInteger(), null); + } + + public String getDisplayName() { + return "Display " + name; + } + + public boolean isActive() { + return true; + } + + public String getOptional() { + return "getter must not shadow a null component"; + } + + public String sideEffect() { + invocations.incrementAndGet(); + return "invoked"; + } + } + + public interface Data { + boolean isGood(); + } + + public record DataImpl(int status) implements Data { + @Override + public boolean isGood() { + return status == 1; + } + } + + private final FallThroughRecordELResolver resolver = new FallThroughRecordELResolver(); + + private static ELContext newContext() { + return new StandardELContext(ExpressionFactory.newInstance()); + } + + @Test + public void resolvesComponentsReadOnly() { + final Item item = new Item("widget"); + + ELContext context = newContext(); + assertEquals("widget", resolver.getValue(context, item, "name")); + assertTrue(context.isPropertyResolved()); + + context = newContext(); + assertNull(resolver.getType(context, item, "name")); + assertTrue(context.isPropertyResolved()); + + context = newContext(); + assertTrue(resolver.isReadOnly(context, item, "name")); + assertTrue(context.isPropertyResolved()); + + final ELContext writeContext = newContext(); + assertThrows(PropertyNotWritableException.class, () -> resolver.setValue(writeContext, item, "name", "x")); + assertTrue(writeContext.isPropertyResolved()); + } + + @Test + public void resolvesNullComponent() { + final ELContext context = newContext(); + assertNull(resolver.getValue(context, new Item("widget"), "optional")); + assertTrue(context.isPropertyResolved()); + } + + @Test + public void leavesNonComponentsUnresolved() { + final Item item = new Item("widget"); + for (final Object[] baseAndProperty : new Object[][] { { item, "displayName" }, { item, "sideEffect" }, + { item, "hashCode" }, { item, "missing" }, { item, null }, { null, "name" }, { "widget", "bytes" } }) { + final Object base = baseAndProperty[0]; + final Object property = baseAndProperty[1]; + + final ELContext context = newContext(); + assertNull(resolver.getValue(context, base, property)); + assertNull(resolver.getType(context, base, property)); + assertFalse(resolver.isReadOnly(context, base, property)); + resolver.setValue(context, base, property, "x"); + assertFalse(base + "." + property + " must stay unresolved", context.isPropertyResolved()); + } + assertEquals(0, item.invocations().get()); + } + + /** + * Interface property implemented by a record, see https://github.com/jakartaee/expression-language/issues/489. + */ + @Test + public void interpolatesInterfacePropertyOfRecord() { + final ELFacade el = new ELFacade(); + + assertEquals("true", el.interpolate("${validatedValue.good}", Collections.emptyMap(), new DataImpl(1))); + assertEquals("1", el.interpolate("${validatedValue.status}", Collections.emptyMap(), new DataImpl(1))); + } + + @Test + public void rejectsNullContext() { + assertThrows(NullPointerException.class, () -> resolver.getValue(null, null, null)); + assertThrows(NullPointerException.class, () -> resolver.getType(null, null, null)); + assertThrows(NullPointerException.class, () -> resolver.isReadOnly(null, null, null)); + assertThrows(NullPointerException.class, () -> resolver.setValue(null, null, null, null)); + } + + @Test + public void interpolatesRecordProperties() { + final ELFacade el = new ELFacade(); + final Item item = new Item("widget"); + + assertEquals("widget", el.interpolate("${validatedValue.name}", Collections.emptyMap(), item)); + assertEquals("widget", el.interpolate("${validatedValue['name']}", Collections.emptyMap(), item)); + assertEquals("widget", el.interpolate("${validatedValue.name()}", Collections.emptyMap(), item)); + assertEquals("", el.interpolate("${validatedValue.optional}", Collections.emptyMap(), item)); + assertEquals("Display widget", el.interpolate("${validatedValue.displayName}", Collections.emptyMap(), item)); + assertEquals("true", el.interpolate("${validatedValue.active}", Collections.emptyMap(), item)); + assertEquals("${validatedValue.sideEffect}", + el.interpolate("${validatedValue.sideEffect}", Collections.emptyMap(), item)); + assertEquals("${validatedValue.hashCode}", + el.interpolate("${validatedValue.hashCode}", Collections.emptyMap(), item)); + assertEquals("${validatedValue.missing}", + el.interpolate("${validatedValue.missing}", Collections.emptyMap(), item)); + assertEquals(0, item.invocations().get()); + } +} diff --git a/bval-jsr/src/test/java/org/apache/bval/jsr/DefaultMessageInterpolatorTest.java b/bval-jsr/src/test/java/org/apache/bval/jsr/DefaultMessageInterpolatorTest.java index 651a51755b..b5e3373fb7 100644 --- a/bval-jsr/src/test/java/org/apache/bval/jsr/DefaultMessageInterpolatorTest.java +++ b/bval-jsr/src/test/java/org/apache/bval/jsr/DefaultMessageInterpolatorTest.java @@ -37,6 +37,7 @@ import jakarta.validation.MessageInterpolator; import jakarta.validation.Validator; import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.metadata.ConstraintDescriptor; @@ -234,6 +235,19 @@ public void testMixedEvaluation() { interpolator.interpolate(context.getConstraintDescriptor().getMessageTemplate(), context)); } + @Test + public void testRecordPropertyEvaluation() { + assumeTrue(elAvailable); + + final MessageInterpolator.Context context = context(new Item("widget"), + () -> validator.getConstraintsForClass(Person.class).getConstraintsForProperty("recordValue") + .getConstraintDescriptors().stream().filter(forConstraintType(NotNull.class)).findFirst() + .orElseThrow(() -> new AssertionError("expected constraint missing"))); + + assertEquals("Record value widget (Display widget) is invalid", + interpolator.interpolate(context.getConstraintDescriptor().getMessageTemplate(), context)); + } + @Test public void testELEscapingTomcatJuel() { assumeTrue(elAvailable); @@ -334,5 +348,14 @@ public static class Person { @Pattern(message = "Mixed message value of length ${validatedValue.length()} should match {regexp}", regexp = "....$") public String mixedMessageValue; + + @NotNull(message = "Record value ${validatedValue.name} (${validatedValue.displayName}) is invalid") + public Item recordValue; + } + + public record Item(String name) { + public String getDisplayName() { + return "Display " + name; + } } }