This is an automated email from the ASF dual-hosted git repository.

henrykuijpers pushed a commit to branch feature/SLING-13381-record-support-in-el
in repository 
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-scripting-jsp.git

commit 92e1edaa6bd0d110315bd704fc6678c6782ee7ca
Author: Henry Kuijpers <[email protected]>
AuthorDate: Thu Oct 8 15:44:18 2026 +0200

    SLING-13381 - Support Java records in EL expressions
    
    Add a RecordELResolver that resolves record components through their
    accessor methods, so that ${record.name} calls record.name(). Records
    are read-only. The record API is accessed reflectively, so the bundle
    still targets Java 8 and only resolves records on Java 16+.
    
    The resolver is registered before the BeanELResolver, both for JSP pages
    (JspApplicationContextImpl) and for the ExpressionEvaluator API
    (ELResolverImpl), as the BeanELResolver would otherwise fail on record
    components. Properties that are not record components, like regular
    getters on a record, are still resolved by the BeanELResolver.
    
    The tests compile their records at runtime, as the test sources are
    compiled for Java 8; tests using records are skipped on JVMs older than
    Java 16.
---
 .../scripting/jsp/jasper/el/ELResolverImpl.java    |   1 +
 .../scripting/jsp/jasper/el/RecordELResolver.java  | 216 ++++++++++++++++++
 .../jasper/runtime/JspApplicationContextImpl.java  |   2 +
 .../jsp/jasper/el/ELResolverImplTest.java          | 108 +++++++++
 .../jsp/jasper/el/RecordELResolverTest.java        | 249 +++++++++++++++++++++
 .../sling/scripting/jsp/jasper/el/TestRecords.java | 136 +++++++++++
 .../runtime/JspApplicationContextImplTest.java     |  87 +++++++
 7 files changed, 799 insertions(+)

diff --git 
a/src/main/java/org/apache/sling/scripting/jsp/jasper/el/ELResolverImpl.java 
b/src/main/java/org/apache/sling/scripting/jsp/jasper/el/ELResolverImpl.java
index 310331a..9ec8143 100644
--- a/src/main/java/org/apache/sling/scripting/jsp/jasper/el/ELResolverImpl.java
+++ b/src/main/java/org/apache/sling/scripting/jsp/jasper/el/ELResolverImpl.java
@@ -41,6 +41,7 @@ public final class ELResolverImpl extends ELResolver {
                ((CompositeELResolver) DefaultResolver).add(new 
ResourceBundleELResolver());
                ((CompositeELResolver) DefaultResolver).add(new 
ListELResolver());
                ((CompositeELResolver) DefaultResolver).add(new 
ArrayELResolver());
+               ((CompositeELResolver) DefaultResolver).add(new 
RecordELResolver());
                ((CompositeELResolver) DefaultResolver).add(new 
BeanELResolver());
        }
 
diff --git 
a/src/main/java/org/apache/sling/scripting/jsp/jasper/el/RecordELResolver.java 
b/src/main/java/org/apache/sling/scripting/jsp/jasper/el/RecordELResolver.java
new file mode 100644
index 0000000..7af63d1
--- /dev/null
+++ 
b/src/main/java/org/apache/sling/scripting/jsp/jasper/el/RecordELResolver.java
@@ -0,0 +1,216 @@
+/*
+ * 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.sling.scripting.jsp.jasper.el;
+
+import java.beans.FeatureDescriptor;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import javax.el.ELContext;
+import javax.el.ELException;
+import javax.el.ELResolver;
+import javax.el.PropertyNotFoundException;
+import javax.el.PropertyNotWritableException;
+
+/**
+ * Resolves the components of a Java record (Java 16+) by invoking their
+ * accessor methods, so that <code>${record.name}</code> calls
+ * <code>record.name()</code>. Records are read-only.
+ * <p>
+ * The record API is accessed reflectively, so this class can be compiled for
+ * and loaded on Java versions without records; it then never resolves 
anything.
+ * <p>
+ * Properties that are not record components are left unresolved, so that
+ * regular getters on a record are still handled by the
+ * {@link javax.el.BeanELResolver}.
+ */
+public final class RecordELResolver extends ELResolver {
+
+       private static final Method IS_RECORD;
+       private static final Method GET_RECORD_COMPONENTS;
+       private static final Method GET_NAME;
+       private static final Method GET_ACCESSOR;
+
+       static {
+               Method isRecord = null;
+               Method getRecordComponents = null;
+               Method getName = null;
+               Method getAccessor = null;
+               try {
+                       isRecord = Class.class.getMethod("isRecord");
+                       getRecordComponents = 
Class.class.getMethod("getRecordComponents");
+                       Class<?> recordComponent = 
Class.forName("java.lang.reflect.RecordComponent");
+                       getName = recordComponent.getMethod("getName");
+                       getAccessor = recordComponent.getMethod("getAccessor");
+               } catch (ReflectiveOperationException e) {
+                       // running on a Java version without records
+                       isRecord = null;
+               }
+               IS_RECORD = isRecord;
+               GET_RECORD_COMPONENTS = getRecordComponents;
+               GET_NAME = getName;
+               GET_ACCESSOR = getAccessor;
+       }
+
+       /** Record component accessors per class, keyed by component name; 
empty for non-record classes. */
+       private static final ClassValue<Map<String, Method>> ACCESSORS = new 
ClassValue<Map<String, Method>>() {
+               @Override
+               protected Map<String, Method> computeValue(Class<?> type) {
+                       return findAccessors(type);
+               }
+       };
+
+       public Object getValue(ELContext context, Object base, Object property)
+                       throws NullPointerException, PropertyNotFoundException, 
ELException {
+               if (context == null) {
+                       throw new NullPointerException();
+               }
+
+               Method accessor = getAccessor(base, property);
+               if (accessor == null) {
+                       return null;
+               }
+
+               context.setPropertyResolved(true);
+               try {
+                       return accessor.invoke(base);
+               } catch (InvocationTargetException e) {
+                       throw new ELException(e.getCause());
+               } catch (Exception e) {
+                       throw new ELException(e);
+               }
+       }
+
+       public Class<?> getType(ELContext context, Object base, Object property)
+                       throws NullPointerException, PropertyNotFoundException, 
ELException {
+               if (context == null) {
+                       throw new NullPointerException();
+               }
+
+               Method accessor = getAccessor(base, property);
+               if (accessor == null) {
+                       return null;
+               }
+
+               context.setPropertyResolved(true);
+               return accessor.getReturnType();
+       }
+
+       public void setValue(ELContext context, Object base, Object property,
+                       Object value) throws NullPointerException,
+                       PropertyNotFoundException, PropertyNotWritableException,
+                       ELException {
+               if (context == null) {
+                       throw new NullPointerException();
+               }
+
+               if (getAccessor(base, property) != null) {
+                       context.setPropertyResolved(true);
+                       throw new PropertyNotWritableException("Record 
component '"
+                                       + property + "' of " + 
base.getClass().getName()
+                                       + " is not writable");
+               }
+       }
+
+       public boolean isReadOnly(ELContext context, Object base, Object 
property)
+                       throws NullPointerException, PropertyNotFoundException, 
ELException {
+               if (context == null) {
+                       throw new NullPointerException();
+               }
+
+               if (getAccessor(base, property) != null) {
+                       context.setPropertyResolved(true);
+                       return true;
+               }
+               return false;
+       }
+
+       public Iterator<FeatureDescriptor> getFeatureDescriptors(ELContext 
context, Object base) {
+               if (base == null) {
+                       return null;
+               }
+               Map<String, Method> accessors = ACCESSORS.get(base.getClass());
+               if (accessors.isEmpty()) {
+                       return null;
+               }
+               List<FeatureDescriptor> descriptors = new 
ArrayList<FeatureDescriptor>();
+               for (Map.Entry<String, Method> entry : accessors.entrySet()) {
+                       FeatureDescriptor descriptor = new FeatureDescriptor();
+                       descriptor.setName(entry.getKey());
+                       descriptor.setDisplayName(entry.getKey());
+                       descriptor.setShortDescription("");
+                       descriptor.setExpert(false);
+                       descriptor.setHidden(false);
+                       descriptor.setPreferred(true);
+                       descriptor.setValue(TYPE, 
entry.getValue().getReturnType());
+                       descriptor.setValue(RESOLVABLE_AT_DESIGN_TIME, 
Boolean.TRUE);
+                       descriptors.add(descriptor);
+               }
+               return descriptors.iterator();
+       }
+
+       public Class<?> getCommonPropertyType(ELContext context, Object base) {
+               if (base != null && !ACCESSORS.get(base.getClass()).isEmpty()) {
+                       return Object.class;
+               }
+               return null;
+       }
+
+       private static Method getAccessor(Object base, Object property) {
+               if (base == null || property == null || IS_RECORD == null) {
+                       return null;
+               }
+               return ACCESSORS.get(base.getClass()).get(property.toString());
+       }
+
+       private static Map<String, Method> findAccessors(Class<?> type) {
+               if (IS_RECORD == null) {
+                       return Collections.emptyMap();
+               }
+               try {
+                       if (!((Boolean) IS_RECORD.invoke(type)).booleanValue()) 
{
+                               return Collections.emptyMap();
+                       }
+                       Object[] components = (Object[]) 
GET_RECORD_COMPONENTS.invoke(type);
+                       Map<String, Method> accessors = new HashMap<String, 
Method>();
+                       for (Object component : components) {
+                               Method accessor = (Method) 
GET_ACCESSOR.invoke(component);
+                               makeAccessible(accessor);
+                               accessors.put((String) 
GET_NAME.invoke(component), accessor);
+                       }
+                       return Collections.unmodifiableMap(accessors);
+               } catch (ReflectiveOperationException e) {
+                       return Collections.emptyMap();
+               }
+       }
+
+       private static void makeAccessible(Method accessor) {
+               // accessors of records that aren't publicly reachable (e.g. 
nested private records) can't be invoked otherwise
+               try {
+                       accessor.setAccessible(true);
+               } catch (RuntimeException e) {
+                       // not permitted (e.g. module encapsulation); invoking 
will report the problem
+               }
+       }
+}
diff --git 
a/src/main/java/org/apache/sling/scripting/jsp/jasper/runtime/JspApplicationContextImpl.java
 
b/src/main/java/org/apache/sling/scripting/jsp/jasper/runtime/JspApplicationContextImpl.java
index 836cc9b..1166480 100644
--- 
a/src/main/java/org/apache/sling/scripting/jsp/jasper/runtime/JspApplicationContextImpl.java
+++ 
b/src/main/java/org/apache/sling/scripting/jsp/jasper/runtime/JspApplicationContextImpl.java
@@ -38,6 +38,7 @@ import javax.servlet.jsp.el.ScopedAttributeELResolver;
 
 import org.apache.el.ExpressionFactoryImpl;
 import org.apache.sling.scripting.jsp.jasper.el.ELContextImpl;
+import org.apache.sling.scripting.jsp.jasper.el.RecordELResolver;
 
 /**
  * Implementation of JspApplicationContext
@@ -113,6 +114,7 @@ public class JspApplicationContextImpl implements 
JspApplicationContext {
                        r.add(new ResourceBundleELResolver());
                        r.add(new ListELResolver());
                        r.add(new ArrayELResolver());   
+                       r.add(new RecordELResolver());
                        r.add(new BeanELResolver());
                        r.add(new ScopedAttributeELResolver());
                        this.resolver = r;
diff --git 
a/src/test/java/org/apache/sling/scripting/jsp/jasper/el/ELResolverImplTest.java
 
b/src/test/java/org/apache/sling/scripting/jsp/jasper/el/ELResolverImplTest.java
new file mode 100644
index 0000000..defef45
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/scripting/jsp/jasper/el/ELResolverImplTest.java
@@ -0,0 +1,108 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ 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.sling.scripting.jsp.jasper.el;
+
+import java.util.Collections;
+
+import javax.el.ELContext;
+import javax.el.ExpressionFactory;
+import javax.el.PropertyNotFoundException;
+import javax.el.PropertyNotWritableException;
+import javax.el.ValueExpression;
+
+import org.apache.el.ExpressionFactoryImpl;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests expressions evaluated against {@link ELResolverImpl#DefaultResolver}.
+ */
+public class ELResolverImplTest {
+
+    @ClassRule
+    public static final TestRecords RECORDS = new TestRecords();
+
+    private final ExpressionFactory factory = new ExpressionFactoryImpl();
+
+    private ELContext context;
+
+    @Before
+    public void setUp() {
+        context = new ELContextImpl(ELResolverImpl.DefaultResolver);
+    }
+
+    @Test
+    public void recordComponent() throws Exception {
+        assertEquals("Alice", eval(RECORDS.person("Alice", 42), "${r.name}"));
+    }
+
+    @Test
+    public void recordComponentInArithmetic() throws Exception {
+        assertEquals(43L, eval(RECORDS.person("Alice", 42), "${r.age + 1}"));
+    }
+
+    @Test
+    public void recordGetterIsResolvedAsBeanProperty() throws Exception {
+        assertEquals("Hello Alice", eval(RECORDS.person("Alice", 42), 
"${r.greeting}"));
+    }
+
+    @Test(expected = PropertyNotFoundException.class)
+    public void unknownRecordProperty() throws Exception {
+        eval(RECORDS.person("Alice", 42), "${r.unknown}");
+    }
+
+    @Test
+    public void recordComponentIsReadOnly() throws Exception {
+        assertTrue(expression(RECORDS.person("Alice", 42), 
"${r.name}").isReadOnly(context));
+    }
+
+    @Test(expected = PropertyNotWritableException.class)
+    public void recordComponentCannotBeSet() throws Exception {
+        expression(RECORDS.person("Alice", 42), "${r.name}").setValue(context, 
"Bob");
+    }
+
+    @Test
+    public void beanProperty() {
+        assertEquals("bean", eval(new Bean(), "${r.name}"));
+    }
+
+    @Test
+    public void mapEntry() {
+        assertEquals("map", eval(Collections.singletonMap("name", "map"), 
"${r.name}"));
+    }
+
+    public static class Bean {
+        public String getName() {
+            return "bean";
+        }
+    }
+
+    private Object eval(Object base, String expression) {
+        return expression(base, expression).getValue(context);
+    }
+
+    private ValueExpression expression(Object base, String expression) {
+        context.getVariableMapper().setVariable("r", 
factory.createValueExpression(base, Object.class));
+        return factory.createValueExpression(context, expression, 
Object.class);
+    }
+}
diff --git 
a/src/test/java/org/apache/sling/scripting/jsp/jasper/el/RecordELResolverTest.java
 
b/src/test/java/org/apache/sling/scripting/jsp/jasper/el/RecordELResolverTest.java
new file mode 100644
index 0000000..d7a2e0a
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/scripting/jsp/jasper/el/RecordELResolverTest.java
@@ -0,0 +1,249 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ 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.sling.scripting.jsp.jasper.el;
+
+import java.beans.FeatureDescriptor;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+import javax.el.ELContext;
+import javax.el.ELException;
+import javax.el.ELResolver;
+import javax.el.PropertyNotWritableException;
+
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class RecordELResolverTest {
+
+    @ClassRule
+    public static final TestRecords RECORDS = new TestRecords();
+
+    private RecordELResolver resolver;
+    private ELContext context;
+
+    @Before
+    public void setUp() {
+        resolver = new RecordELResolver();
+        context = new ELContextImpl(resolver);
+    }
+
+    // records
+
+    @Test
+    public void getValueReturnsComponent() throws Exception {
+        Object person = RECORDS.person("Alice", 42);
+
+        assertEquals("Alice", resolver.getValue(context, person, "name"));
+        assertTrue(context.isPropertyResolved());
+    }
+
+    @Test
+    public void getValueReturnsPrimitiveComponent() throws Exception {
+        Object person = RECORDS.person("Alice", 42);
+
+        assertEquals(42, resolver.getValue(context, person, "age"));
+        assertTrue(context.isPropertyResolved());
+    }
+
+    @Test
+    public void getValueOfNonPublicRecord() throws Exception {
+        Object hidden = RECORDS.create(TestRecords.HIDDEN, "s3cr3t");
+
+        assertEquals("s3cr3t", resolver.getValue(context, hidden, "secret"));
+        assertTrue(context.isPropertyResolved());
+    }
+
+    @Test
+    public void getValueLeavesGetterToOtherResolvers() throws Exception {
+        Object person = RECORDS.person("Alice", 42);
+
+        assertNull(resolver.getValue(context, person, "greeting"));
+        assertFalse(context.isPropertyResolved());
+    }
+
+    @Test
+    public void getValueLeavesUnknownPropertyToOtherResolvers() throws 
Exception {
+        Object person = RECORDS.person("Alice", 42);
+
+        assertNull(resolver.getValue(context, person, "unknown"));
+        assertFalse(context.isPropertyResolved());
+    }
+
+    @Test
+    public void getValueLeavesNullPropertyToOtherResolvers() throws Exception {
+        Object person = RECORDS.person("Alice", 42);
+
+        assertNull(resolver.getValue(context, person, null));
+        assertFalse(context.isPropertyResolved());
+    }
+
+    @Test
+    public void getValueWrapsExceptionOfAccessor() throws Exception {
+        Object failing = RECORDS.create(TestRecords.FAILING, "value");
+
+        try {
+            resolver.getValue(context, failing, "value");
+            fail("expected ELException");
+        } catch (ELException e) {
+            assertTrue(e.getCause() instanceof IllegalStateException);
+            assertEquals("accessor failed", e.getCause().getMessage());
+        }
+        assertTrue(context.isPropertyResolved());
+    }
+
+    @Test
+    public void getTypeReturnsComponentType() throws Exception {
+        Object person = RECORDS.person("Alice", 42);
+
+        assertEquals(String.class, resolver.getType(context, person, "name"));
+        assertEquals(int.class, resolver.getType(context, person, "age"));
+        assertTrue(context.isPropertyResolved());
+    }
+
+    @Test
+    public void getTypeLeavesUnknownPropertyToOtherResolvers() throws 
Exception {
+        Object person = RECORDS.person("Alice", 42);
+
+        assertNull(resolver.getType(context, person, "unknown"));
+        assertFalse(context.isPropertyResolved());
+    }
+
+    @Test
+    public void setValueIsNotAllowed() throws Exception {
+        Object person = RECORDS.person("Alice", 42);
+
+        try {
+            resolver.setValue(context, person, "name", "Bob");
+            fail("expected PropertyNotWritableException");
+        } catch (PropertyNotWritableException e) {
+            assertTrue(e.getMessage(), e.getMessage().contains("'name'"));
+        }
+        assertTrue(context.isPropertyResolved());
+        assertEquals("Alice", resolver.getValue(context, person, "name"));
+    }
+
+    @Test
+    public void setValueLeavesUnknownPropertyToOtherResolvers() throws 
Exception {
+        Object person = RECORDS.person("Alice", 42);
+
+        resolver.setValue(context, person, "unknown", "value");
+        assertFalse(context.isPropertyResolved());
+    }
+
+    @Test
+    public void isReadOnlyForComponent() throws Exception {
+        Object person = RECORDS.person("Alice", 42);
+
+        assertTrue(resolver.isReadOnly(context, person, "name"));
+        assertTrue(context.isPropertyResolved());
+    }
+
+    @Test
+    public void isReadOnlyLeavesUnknownPropertyToOtherResolvers() throws 
Exception {
+        Object person = RECORDS.person("Alice", 42);
+
+        assertFalse(resolver.isReadOnly(context, person, "unknown"));
+        assertFalse(context.isPropertyResolved());
+    }
+
+    @Test
+    public void getFeatureDescriptorsDescribesComponents() throws Exception {
+        Object person = RECORDS.person("Alice", 42);
+
+        Map<String, Object> types = new HashMap<String, Object>();
+        for (Iterator<FeatureDescriptor> it = 
resolver.getFeatureDescriptors(context, person); it.hasNext();) {
+            FeatureDescriptor descriptor = it.next();
+            assertEquals(Boolean.TRUE, 
descriptor.getValue(ELResolver.RESOLVABLE_AT_DESIGN_TIME));
+            types.put(descriptor.getName(), 
descriptor.getValue(ELResolver.TYPE));
+        }
+
+        Map<String, Object> expected = new HashMap<String, Object>();
+        expected.put("name", String.class);
+        expected.put("age", int.class);
+        assertEquals(expected, types);
+    }
+
+    @Test
+    public void getCommonPropertyTypeOfRecord() throws Exception {
+        Object person = RECORDS.person("Alice", 42);
+
+        assertEquals(Object.class, resolver.getCommonPropertyType(context, 
person));
+    }
+
+    // no records, these run on every JVM
+
+    @Test
+    public void nonRecordIsLeftToOtherResolvers() {
+        Bean bean = new Bean();
+
+        assertNull(resolver.getValue(context, bean, "name"));
+        assertNull(resolver.getType(context, bean, "name"));
+        resolver.setValue(context, bean, "name", "value");
+        assertFalse(resolver.isReadOnly(context, bean, "name"));
+        assertFalse(context.isPropertyResolved());
+        assertNull(resolver.getFeatureDescriptors(context, bean));
+        assertNull(resolver.getCommonPropertyType(context, bean));
+    }
+
+    @Test
+    public void nullBaseIsLeftToOtherResolvers() {
+        assertNull(resolver.getValue(context, null, "name"));
+        assertNull(resolver.getType(context, null, "name"));
+        resolver.setValue(context, null, "name", "value");
+        assertFalse(resolver.isReadOnly(context, null, "name"));
+        assertFalse(context.isPropertyResolved());
+        assertNull(resolver.getFeatureDescriptors(context, null));
+        assertNull(resolver.getCommonPropertyType(context, null));
+    }
+
+    @Test(expected = NullPointerException.class)
+    public void getValueRequiresContext() {
+        resolver.getValue(null, new Bean(), "name");
+    }
+
+    @Test(expected = NullPointerException.class)
+    public void getTypeRequiresContext() {
+        resolver.getType(null, new Bean(), "name");
+    }
+
+    @Test(expected = NullPointerException.class)
+    public void setValueRequiresContext() {
+        resolver.setValue(null, new Bean(), "name", "value");
+    }
+
+    @Test(expected = NullPointerException.class)
+    public void isReadOnlyRequiresContext() {
+        resolver.isReadOnly(null, new Bean(), "name");
+    }
+
+    public static class Bean {
+        public String getName() {
+            return "bean";
+        }
+    }
+}
diff --git 
a/src/test/java/org/apache/sling/scripting/jsp/jasper/el/TestRecords.java 
b/src/test/java/org/apache/sling/scripting/jsp/jasper/el/TestRecords.java
new file mode 100644
index 0000000..8ca3551
--- /dev/null
+++ b/src/test/java/org/apache/sling/scripting/jsp/jasper/el/TestRecords.java
@@ -0,0 +1,136 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ 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.sling.scripting.jsp.jasper.el;
+
+import java.io.File;
+import java.lang.reflect.Constructor;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.tools.JavaCompiler;
+import javax.tools.ToolProvider;
+
+import org.junit.rules.ExternalResource;
+import org.junit.rules.TemporaryFolder;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assume.assumeTrue;
+
+/**
+ * Provides Java records to tests. The project is compiled for Java 8, so 
records can't be part of
+ * the test sources; they are compiled when running on a JVM that supports 
them (Java 16+). Tests
+ * creating a record are skipped on older JVMs.
+ * <p>
+ * Use as a {@link org.junit.ClassRule}.
+ */
+public class TestRecords extends ExternalResource {
+
+    /** A public record with an additional bean getter, which is not a record 
component. */
+    public static final String PERSON = "records.Person";
+
+    /** A package private record. */
+    public static final String HIDDEN = "records.Hidden";
+
+    /** A record whose accessor throws an {@link IllegalStateException}. */
+    public static final String FAILING = "records.Failing";
+
+    private static final Map<String, String> SOURCES = new 
LinkedHashMap<String, String>();
+
+    static {
+        SOURCES.put(PERSON, "package records;\n"
+                + "public record Person(String name, int age) {\n"
+                + "    public String getGreeting() { return \"Hello \" + name; 
}\n"
+                + "}\n");
+        SOURCES.put(HIDDEN, "package records;\n"
+                + "record Hidden(String secret) {}\n");
+        SOURCES.put(FAILING, "package records;\n"
+                + "public record Failing(String value) {\n"
+                + "    public String value() { throw new 
IllegalStateException(\"accessor failed\"); }\n"
+                + "}\n");
+    }
+
+    private final TemporaryFolder folder = new TemporaryFolder();
+
+    private URLClassLoader classLoader;
+
+    @Override
+    protected void before() throws Throwable {
+        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
+        if (!recordsSupported() || compiler == null) {
+            return;
+        }
+        folder.create();
+        File sources = folder.newFolder("src");
+        File classes = folder.newFolder("classes");
+        List<String> arguments = new ArrayList<String>();
+        arguments.add("-d");
+        arguments.add(classes.getPath());
+        for (Map.Entry<String, String> source : SOURCES.entrySet()) {
+            File file = new File(sources, 
source.getKey().substring(source.getKey().indexOf('.') + 1) + ".java");
+            Files.write(file.toPath(), 
source.getValue().getBytes(StandardCharsets.UTF_8));
+            arguments.add(file.getPath());
+        }
+        assertEquals("compiling the test records failed", 0,
+                compiler.run(null, null, null, arguments.toArray(new 
String[0])));
+        classLoader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, 
getClass().getClassLoader());
+    }
+
+    @Override
+    protected void after() {
+        if (classLoader != null) {
+            try {
+                classLoader.close();
+            } catch (java.io.IOException e) {
+                // ignore, only the temporary folder is affected
+            }
+            classLoader = null;
+        }
+        folder.delete();
+    }
+
+    /**
+     * Creates an instance of the given record using its canonical 
constructor. Skips the calling
+     * test if records aren't supported.
+     */
+    public Object create(String className, Object... components) throws 
Exception {
+        assumeTrue("records require Java 16+ running on a JDK", classLoader != 
null);
+        Constructor<?> constructor = 
classLoader.loadClass(className).getDeclaredConstructors()[0];
+        constructor.setAccessible(true);
+        return constructor.newInstance(components);
+    }
+
+    public Object person(String name, int age) throws Exception {
+        return create(PERSON, name, age);
+    }
+
+    private static boolean recordsSupported() {
+        try {
+            Class.class.getMethod("isRecord");
+            return true;
+        } catch (NoSuchMethodException e) {
+            return false;
+        }
+    }
+}
diff --git 
a/src/test/java/org/apache/sling/scripting/jsp/jasper/runtime/JspApplicationContextImplTest.java
 
b/src/test/java/org/apache/sling/scripting/jsp/jasper/runtime/JspApplicationContextImplTest.java
new file mode 100644
index 0000000..093bbca
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/scripting/jsp/jasper/runtime/JspApplicationContextImplTest.java
@@ -0,0 +1,87 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ 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.sling.scripting.jsp.jasper.runtime;
+
+import javax.el.ELContext;
+import javax.el.ExpressionFactory;
+import javax.el.PropertyNotWritableException;
+import javax.el.ValueExpression;
+import javax.servlet.jsp.JspContext;
+
+import org.apache.el.ExpressionFactoryImpl;
+import org.apache.sling.scripting.jsp.jasper.el.TestRecords;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests expressions evaluated against the resolvers used for JSP pages.
+ */
+public class JspApplicationContextImplTest {
+
+    @ClassRule
+    public static final TestRecords RECORDS = new TestRecords();
+
+    private final ExpressionFactory factory = new ExpressionFactoryImpl();
+
+    private ELContext context;
+
+    @Before
+    public void setUp() {
+        context = new 
JspApplicationContextImpl().createELContext(mock(JspContext.class));
+    }
+
+    @Test
+    public void recordComponent() throws Exception {
+        assertEquals("Alice", eval(RECORDS.person("Alice", 42), "${r.name}"));
+    }
+
+    @Test
+    public void recordGetterIsResolvedAsBeanProperty() throws Exception {
+        assertEquals("Hello Alice", eval(RECORDS.person("Alice", 42), 
"${r.greeting}"));
+    }
+
+    @Test(expected = PropertyNotWritableException.class)
+    public void recordComponentCannotBeSet() throws Exception {
+        expression(RECORDS.person("Alice", 42), "${r.name}").setValue(context, 
"Bob");
+    }
+
+    @Test
+    public void beanProperty() {
+        assertEquals("bean", eval(new Bean(), "${r.name}"));
+    }
+
+    public static class Bean {
+        public String getName() {
+            return "bean";
+        }
+    }
+
+    private Object eval(Object base, String expression) {
+        return expression(base, expression).getValue(context);
+    }
+
+    private ValueExpression expression(Object base, String expression) {
+        context.getVariableMapper().setVariable("r", 
factory.createValueExpression(base, Object.class));
+        return factory.createValueExpression(context, expression, 
Object.class);
+    }
+}

Reply via email to