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;
+ }
}
}