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