From b662d8006c25fb950f42d23f0240e3ce32740d1b Mon Sep 17 00:00:00 2001 From: Florian Meyer Date: Fri, 23 Oct 2026 18:47:54 +0200 Subject: [PATCH] Support Java record component properties --- .../jinjava/el/ext/BeanELResolver.java | 30 +++- .../jinjava/el/ExpressionResolverTest.java | 101 ++++++++++++ .../el/ext/JinjavaBeanELResolverTest.java | 144 ++++++++++++++++++ .../jinjava/el/ext/eager/EagerAstDotTest.java | 39 +++++ .../testobjects/EagerAstDotTestObjects.java | 17 +++ .../ExpressionResolverTestObjects.java | 4 + .../JinjavaBeanELResolverTestObjects.java | 29 ++++ 7 files changed, 361 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/hubspot/jinjava/el/ext/BeanELResolver.java b/src/main/java/com/hubspot/jinjava/el/ext/BeanELResolver.java index 9210ca86d..302bd60f8 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/BeanELResolver.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/BeanELResolver.java @@ -24,9 +24,11 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.lang.reflect.RecordComponent; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import javax.el.CompositeELResolver; @@ -39,7 +41,7 @@ import javax.el.PropertyNotWritableException; /** - * Defines property resolution behavior on objects using the JavaBeans component architecture. This + * Defines property resolution behavior for JavaBeans properties and record components. This * resolver handles base objects of any type, as long as the base is not null. It accepts any object * as a property, and coerces it to a string. That string is then used to find a JavaBeans compliant * property on the base object. The value is accessed using JavaBeans getters and setters. This @@ -66,7 +68,7 @@ protected static final class BeanProperties { public BeanProperties(Class baseClass) { PropertyDescriptor[] descriptors; try { - descriptors = Introspector.getBeanInfo(baseClass).getPropertyDescriptors(); + descriptors = getPropertyDescriptors(baseClass); } catch (IntrospectionException e) { throw new ELException(e); } @@ -80,6 +82,28 @@ public BeanProperty getBeanProperty(String property) { } } + private static PropertyDescriptor[] getPropertyDescriptors(Class baseClass) + throws IntrospectionException { + PropertyDescriptor[] descriptors = Introspector + .getBeanInfo(baseClass) + .getPropertyDescriptors(); + if (!baseClass.isRecord()) { + return descriptors; + } + + Map properties = new LinkedHashMap<>(); + for (PropertyDescriptor descriptor : descriptors) { + properties.put(descriptor.getName(), descriptor); + } + for (RecordComponent component : baseClass.getRecordComponents()) { + properties.putIfAbsent( + component.getName(), + new PropertyDescriptor(component.getName(), component.getAccessor(), null) + ); + } + return properties.values().toArray(PropertyDescriptor[]::new); + } + protected static final class BeanProperty { private final PropertyDescriptor descriptor; @@ -236,7 +260,7 @@ public Iterator getFeatureDescriptors( if (isResolvable(base)) { final PropertyDescriptor[] properties; try { - properties = Introspector.getBeanInfo(base.getClass()).getPropertyDescriptors(); + properties = getPropertyDescriptors(base.getClass()); } catch (IntrospectionException e) { return Collections.emptyList().iterator(); } diff --git a/src/test/java/com/hubspot/jinjava/el/ExpressionResolverTest.java b/src/test/java/com/hubspot/jinjava/el/ExpressionResolverTest.java index 83347281a..711648ede 100644 --- a/src/test/java/com/hubspot/jinjava/el/ExpressionResolverTest.java +++ b/src/test/java/com/hubspot/jinjava/el/ExpressionResolverTest.java @@ -10,6 +10,10 @@ import com.hubspot.jinjava.BaseJinjavaTest; import com.hubspot.jinjava.Jinjava; import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.el.ext.AllowlistMethodValidator; +import com.hubspot.jinjava.el.ext.AllowlistReturnTypeValidator; +import com.hubspot.jinjava.el.ext.MethodValidatorConfig; +import com.hubspot.jinjava.el.ext.ReturnTypeValidatorConfig; import com.hubspot.jinjava.interpret.Context; import com.hubspot.jinjava.interpret.Context.Library; import com.hubspot.jinjava.interpret.JinjavaInterpreter; @@ -20,6 +24,7 @@ import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; import com.hubspot.jinjava.objects.date.PyishDate; import com.hubspot.jinjava.testobjects.ExpressionResolverTestObjects; +import java.lang.reflect.Method; import java.math.BigDecimal; import java.util.Date; import java.util.List; @@ -51,6 +56,102 @@ public void teardown() { JinjavaInterpreter.popCurrent(); } + @Test + public void itRendersRecordProperties() { + for (String expression : List.of("person.name", "person['name']", "person.name()")) { + RenderResult result = jinjava.renderForResult( + "{{ " + expression + " }}", + Map.of("person", new ExpressionResolverTestObjects.Person("Ada")) + ); + assertThat(result.getOutput()).as(expression).isEqualTo("Ada"); + assertThat(result.getErrors()).as(expression).isEmpty(); + } + } + + @Test + public void itResolvesRecordComponentTypesAndNames() { + context.put( + "record", + new ExpressionResolverTestObjects.RecordValues( + "Ada", + 37, + true, + new ExpressionResolverTestObjects.Person("Byron") + ) + ); + context.put("person", new ExpressionResolverTestObjects.Person(null)); + assertThat(interpreter.resolveELExpression("record.first_name", -1)).isEqualTo("Ada"); + assertThat(interpreter.resolveELExpression("record['age']", -1)).isEqualTo(37); + assertThat(interpreter.resolveELExpression("record.active", -1)).isEqualTo(true); + assertThat(interpreter.resolveELExpression("record.child.name", -1)) + .isEqualTo("Byron"); + assertThat(interpreter.resolveELExpression("record['child']['name']", -1)) + .isEqualTo("Byron"); + assertThat(interpreter.resolveELExpression("person.name", -1)).isNull(); + assertThat(interpreter.getErrorsCopy()).isEmpty(); + } + + @Test + public void itRejectsRecordAccessorsOutsideTheMethodAllowlist() throws Exception { + List rejectedMethods = Lists.newArrayList(); + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withMethodValidator( + AllowlistMethodValidator.create( + MethodValidatorConfig + .builder() + .addDefaultAllowlistGroups() + .setOnRejectedMethod(rejectedMethods::add) + .build() + ) + ) + .build(); + JinjavaInterpreter restrictedInterpreter = new Jinjava(config).newInterpreter(); + restrictedInterpreter + .getContext() + .put("person", new ExpressionResolverTestObjects.Person("Ada")); + assertThat(restrictedInterpreter.render("{{ person.name }}")).isEmpty(); + assertThat(rejectedMethods) + .contains(ExpressionResolverTestObjects.Person.class.getMethod("name")); + assertThat(restrictedInterpreter.getErrorsCopy()) + .extracting(TemplateError::getItem) + .containsExactly(ErrorItem.PROPERTY); + } + + @Test + public void itRejectsRecordComponentValuesOutsideTheReturnTypeAllowlist() { + List> rejectedTypes = Lists.newArrayList(); + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withReturnTypeValidator( + AllowlistReturnTypeValidator.create( + ReturnTypeValidatorConfig + .builder() + .addDefaultAllowlistGroups() + .addAllowedCanonicalClassNames( + ExpressionResolverTestObjects.RecordValues.class.getCanonicalName() + ) + .setOnRejectedClass(rejectedTypes::add) + .build() + ) + ) + .build(); + JinjavaInterpreter restrictedInterpreter = new Jinjava(config).newInterpreter(); + restrictedInterpreter + .getContext() + .put( + "record", + new ExpressionResolverTestObjects.RecordValues( + "Ada", + 37, + true, + new ExpressionResolverTestObjects.Person("Byron") + ) + ); + assertThat(restrictedInterpreter.render("{{ record.child }}")).isEmpty(); + assertThat(rejectedTypes).contains(ExpressionResolverTestObjects.Person.class); + } + @Test public void itResolvesListLiterals() { Object val = interpreter.resolveELExpression("['0.5','50']", -1); diff --git a/src/test/java/com/hubspot/jinjava/el/ext/JinjavaBeanELResolverTest.java b/src/test/java/com/hubspot/jinjava/el/ext/JinjavaBeanELResolverTest.java index 0456b1491..bdaa5174f 100644 --- a/src/test/java/com/hubspot/jinjava/el/ext/JinjavaBeanELResolverTest.java +++ b/src/test/java/com/hubspot/jinjava/el/ext/JinjavaBeanELResolverTest.java @@ -9,8 +9,16 @@ import com.hubspot.jinjava.interpret.AutoCloseableSupplier; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.testobjects.JinjavaBeanELResolverTestObjects; +import java.beans.FeatureDescriptor; +import java.beans.Introspector; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import javax.el.ELContext; +import javax.el.ELResolver; import javax.el.PropertyNotFoundException; +import javax.el.PropertyNotWritableException; import org.junit.Before; import org.junit.Test; @@ -27,6 +35,142 @@ public void setUp() throws Exception { jinjava = new Jinjava(BaseJinjavaTest.newConfigBuilder().build()); } + @Test + public void itReadsRecordComponentsAsReadOnlyProperties() { + var person = new JinjavaBeanELResolverTestObjects.Person("Ada", 36); + try ( + var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get() + ) { + for (boolean readOnly : new boolean[] { false, true }) { + var resolver = new JinjavaBeanELResolver(readOnly); + elContext.setPropertyResolved(false); + assertThat(resolver.getValue(elContext, person, "name")).isEqualTo("Ada"); + assertThat(elContext.isPropertyResolved()).isTrue(); + assertThat(resolver.getValue(elContext, person, "age")).isEqualTo(36); + + elContext.setPropertyResolved(false); + assertThat(resolver.getType(elContext, person, "name")).isEqualTo(String.class); + assertThat(elContext.isPropertyResolved()).isTrue(); + assertThat(resolver.getType(elContext, person, "age")).isEqualTo(int.class); + + elContext.setPropertyResolved(false); + assertThat(resolver.isReadOnly(elContext, person, "name")).isTrue(); + assertThat(elContext.isPropertyResolved()).isTrue(); + assertThatThrownBy(() -> resolver.setValue(elContext, person, "name", "Grace")) + .isInstanceOf(PropertyNotWritableException.class); + assertThat(person.name()).isEqualTo("Ada"); + + assertThatThrownBy(() -> resolver.getValue(elContext, person, "missing")) + .isInstanceOf(PropertyNotFoundException.class); + assertThatThrownBy(() -> resolver.getType(elContext, person, "missing")) + .isInstanceOf(PropertyNotFoundException.class); + } + } + } + + @Test + public void itUsesCustomAndPrivateRecordAccessors() { + var custom = new JinjavaBeanELResolverTestObjects.CustomAccessor("Ada"); + Object privatePerson = JinjavaBeanELResolverTestObjects.privatePerson("Grace"); + try ( + var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get() + ) { + assertThat(jinjavaBeanELResolver.getValue(elContext, custom, "name")) + .isEqualTo("accessor: Ada"); + assertThat(jinjavaBeanELResolver.getValue(elContext, privatePerson, "name")) + .isEqualTo("Grace"); + } + } + + @Test + public void itDescribesRecordComponentsWithoutDuplicates() { + var person = new JinjavaBeanELResolverTestObjects.Person("Ada", 36); + List descriptors = new ArrayList<>(); + jinjavaBeanELResolver + .getFeatureDescriptors(elContext, person) + .forEachRemaining(descriptors::add); + + assertThat(descriptors) + .extracting(FeatureDescriptor::getName) + .containsExactlyInAnyOrder("class", "name", "age"); + for (FeatureDescriptor descriptor : descriptors) { + if (descriptor.getName().equals("name")) { + assertThat(descriptor.getValue(ELResolver.TYPE)).isEqualTo(String.class); + } else if (descriptor.getName().equals("age")) { + assertThat(descriptor.getValue(ELResolver.TYPE)).isEqualTo(int.class); + } + assertThat(descriptor.getValue(ELResolver.RESOLVABLE_AT_DESIGN_TIME)) + .isEqualTo(true); + } + } + + @Test + public void itPreservesExistingBeanGettersOnRecords() throws Exception { + var record = new JinjavaBeanELResolverTestObjects.BeanGetter("Ada"); + var original = Arrays + .stream(Introspector.getBeanInfo(record.getClass()).getPropertyDescriptors()) + .filter(descriptor -> descriptor.getName().equals("name")) + .findFirst() + .orElseThrow(); + var originalReadMethod = original.getReadMethod(); + try ( + var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get() + ) { + for (boolean readOnly : new boolean[] { false, true }) { + var resolver = new JinjavaBeanELResolver(readOnly); + assertThat(resolver.getValue(elContext, record, "name")).isEqualTo(3); + assertThat(resolver.getType(elContext, record, "name")).isEqualTo(Integer.class); + List descriptors = new ArrayList<>(); + resolver + .getFeatureDescriptors(elContext, record) + .forEachRemaining(descriptors::add); + assertThat(descriptors) + .filteredOn(descriptor -> descriptor.getName().equals("name")) + .extracting(descriptor -> descriptor.getValue(ELResolver.TYPE)) + .containsExactly(Integer.class); + } + } + assertThat(original.getReadMethod()).isEqualTo(originalReadMethod); + assertThat(original.getPropertyType()).isEqualTo(Integer.class); + } + + @Test + public void itPreservesExistingWriteOnlyPropertiesOnRecords() throws Exception { + var writtenValue = new AtomicReference(); + var record = new JinjavaBeanELResolverTestObjects.WriteOnlyProperty( + "Ada", + writtenValue + ); + var original = Arrays + .stream(Introspector.getBeanInfo(record.getClass()).getPropertyDescriptors()) + .filter(descriptor -> descriptor.getName().equals("name")) + .findFirst() + .orElseThrow(); + var originalWriteMethod = original.getWriteMethod(); + assertThat(original.getReadMethod()).isNull(); + try ( + var a = JinjavaInterpreter.closeablePushCurrent(jinjava.newInterpreter()).get() + ) { + for (boolean readOnly : new boolean[] { false, true }) { + var resolver = new JinjavaBeanELResolver(readOnly); + assertThatThrownBy(() -> resolver.getValue(elContext, record, "name")) + .isInstanceOf(PropertyNotFoundException.class); + assertThat(resolver.getType(elContext, record, "name")).isEqualTo(String.class); + assertThat(resolver.isReadOnly(elContext, record, "name")).isEqualTo(readOnly); + if (readOnly) { + assertThatThrownBy(() -> resolver.setValue(elContext, record, "name", "no")) + .isInstanceOf(PropertyNotWritableException.class); + } else { + resolver.setValue(elContext, record, "name", "Grace"); + } + assertThat(writtenValue.get()).isEqualTo("Grace"); + assertThat(record.name()).isEqualTo("Ada"); + } + } + assertThat(original.getReadMethod()).isNull(); + assertThat(original.getWriteMethod()).isEqualTo(originalWriteMethod); + } + @Test public void itInvokesProperStringReplace() { try ( diff --git a/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstDotTest.java b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstDotTest.java index 6b13a7bc8..e61a8b1bb 100644 --- a/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstDotTest.java +++ b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstDotTest.java @@ -75,4 +75,43 @@ public void itDefersNodeWhenNestedDeferredMapDotThrowsDeferredValueException() { .isEqualTo("{{ foo_map.bar.deferred }}"); assertThat(interpreter.getContext().getDeferredNodes()).isNotEmpty(); } + + @Test + public void itResolvesRecordComponents() { + interpreter + .getContext() + .put("record", new EagerAstDotTestObjects.RecordValue("resolved")); + + assertThat(interpreter.render("{{ record.value }} {{ record['value'] }}")) + .isEqualTo("resolved resolved"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itResolvesComponentsAfterARecordIsAvailable() { + String partiallyRendered = interpreter.render("{{ deferred.value }}"); + assertThat(partiallyRendered).isEqualTo("{{ deferred.value }}"); + + interpreter + .getContext() + .put("deferred", new EagerAstDotTestObjects.RecordValue("resolved")); + assertThat(interpreter.render(partiallyRendered)).isEqualTo("resolved"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itDefersWhenRecordAccessorThrowsDeferredValueException() { + interpreter + .getContext() + .put("record", new EagerAstDotTestObjects.DeferredRecordValue(null)); + String partiallyRendered = interpreter.render("{{ record.value }}"); + assertThat(partiallyRendered).isEqualTo("{{ record.value }}"); + assertThat(interpreter.getContext().getDeferredNodes()).isNotEmpty(); + + interpreter + .getContext() + .put("record", new EagerAstDotTestObjects.DeferredRecordValue("resolved")); + assertThat(interpreter.render(partiallyRendered)).isEqualTo("resolved"); + assertThat(interpreter.getErrors()).isEmpty(); + } } diff --git a/src/test/java/com/hubspot/jinjava/testobjects/EagerAstDotTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/EagerAstDotTestObjects.java index 68619a042..6b8f8abbf 100644 --- a/src/test/java/com/hubspot/jinjava/testobjects/EagerAstDotTestObjects.java +++ b/src/test/java/com/hubspot/jinjava/testobjects/EagerAstDotTestObjects.java @@ -5,6 +5,23 @@ public class EagerAstDotTestObjects { + public record RecordValue(String value) {} + + public record DeferredRecordValue(String value) implements PartiallyDeferredValue { + @Override + public String value() { + if (value == null) { + throw new DeferredValueException("record.value is deferred"); + } + return value; + } + + @Override + public Object getOriginalValue() { + return null; + } + } + public static class Foo implements PartiallyDeferredValue { public String getDeferred() { diff --git a/src/test/java/com/hubspot/jinjava/testobjects/ExpressionResolverTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/ExpressionResolverTestObjects.java index a9d030ee5..71a2602a1 100644 --- a/src/test/java/com/hubspot/jinjava/testobjects/ExpressionResolverTestObjects.java +++ b/src/test/java/com/hubspot/jinjava/testobjects/ExpressionResolverTestObjects.java @@ -12,6 +12,10 @@ public class ExpressionResolverTestObjects { + public record Person(String name) {} + + public record RecordValues(String firstName, int age, boolean active, Person child) {} + public static class MyCustomList extends ForwardingList implements PyWrapper { private final List list; diff --git a/src/test/java/com/hubspot/jinjava/testobjects/JinjavaBeanELResolverTestObjects.java b/src/test/java/com/hubspot/jinjava/testobjects/JinjavaBeanELResolverTestObjects.java index 9d10e6cd7..f86fe5452 100644 --- a/src/test/java/com/hubspot/jinjava/testobjects/JinjavaBeanELResolverTestObjects.java +++ b/src/test/java/com/hubspot/jinjava/testobjects/JinjavaBeanELResolverTestObjects.java @@ -1,7 +1,36 @@ package com.hubspot.jinjava.testobjects; +import java.util.concurrent.atomic.AtomicReference; + public class JinjavaBeanELResolverTestObjects { + public record Person(String name, int age) {} + + public record CustomAccessor(String name) { + @Override + public String name() { + return "accessor: " + name; + } + } + + public record BeanGetter(String name) { + public Integer getName() { + return name.length(); + } + } + + public record WriteOnlyProperty(String name, AtomicReference writtenValue) { + public void setName(String value) { + writtenValue.set(value); + } + } + + private record PrivatePerson(String name) {} + + public static Object privatePerson(String name) { + return new PrivatePerson(name); + } + public static class TempItInvokesBestMethodWithSingleParam { public String getResult(int a) {