Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bval-jsr/src/main/java/org/apache/bval/el/ELFacade.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<Set<String>> COMPONENT_NAMES = new ClassValue<>() {
@Override
protected Set<String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
}
Loading