Skip to content

Commit 995385c

Browse files
committed
Extended support for name() in GraalJSExpressionHandler and added tests
1 parent b945b70 commit 995385c

5 files changed

Lines changed: 295 additions & 4 deletions

File tree

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<modelVersion>4.0.0</modelVersion>
33
<groupId>de.neuland-bfi</groupId>
44
<artifactId>pug4j</artifactId>
5-
<version>3.0.0-alpha.2-SNAPSHOT</version>
5+
<version>3.0.0-alpha-2-SNAPSHOT</version>
66
<packaging>jar</packaging>
77
<name>pug4j</name>
88
<description>Java implementation of the pug templating language</description>

src/main/java/de/neuland/pug4j/expression/GraalJsExpressionHandler.java

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,15 +127,41 @@ public Object evaluateExpression(String expression, PugModel model) throws Expre
127127
}
128128
return eval.as(Object.class);
129129
} catch (PolyglotException ex) {
130-
if (ex.getMessage() != null && ex.getMessage().startsWith("ReferenceError:")) {
131-
return null;
130+
String msg = ex.getMessage();
131+
if (msg != null) {
132+
if (msg.startsWith("ReferenceError:")) {
133+
return null;
134+
}
135+
// Retry strategy for record components written with method-call syntax under GraalJS.
136+
// If evaluation failed, and the expression contains zero-arg member calls like .name(),
137+
// try rewriting them to property access .name and evaluate once.
138+
if (expression.matches(".*\\.[A-Za-z_$][A-Za-z0-9_$]*\\(\\).*")) {
139+
String generalRewritten = removeAllEmptyMemberCalls(expression);
140+
if (!generalRewritten.equals(expression)) {
141+
try {
142+
Source js2 = generalRewritten.startsWith("{")
143+
? Source.create("js", "(" + generalRewritten + ")")
144+
: Source.create("js", generalRewritten);
145+
Value eval2 = context.parse(js2).execute();
146+
return eval2.as(Object.class);
147+
} catch (PolyglotException retryEx) {
148+
// fall through to normal handling below
149+
}
150+
}
151+
}
132152
}
133153
throw new ExpressionException(expression, ex);
134154
} finally {
135155
context.leave();
136156
}
137157
}
138158

159+
private static String removeAllEmptyMemberCalls(String expression) {
160+
// Replace all occurrences like .name() -> .name (zero-arg member calls)
161+
// This aims to support record component access written as method calls in templates.
162+
return expression.replaceAll("\\.([A-Za-z_$][A-Za-z0-9_$]*)\\(\\)", ".$1");
163+
}
164+
139165
@Override
140166
public String evaluateStringExpression(String expression, PugModel model)
141167
throws ExpressionException {

src/main/java/de/neuland/pug4j/model/RecordWrapper.java

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ public RecordWrapper(Object record) {
4141
extractComponents();
4242
}
4343

44-
/** Extracts all component values from the record using reflection. */
44+
/**
45+
* Extracts all component values from the record using reflection.
46+
*/
4547
private void extractComponents() {
4648
Class<?> recordClass = record.getClass();
4749
RecordComponent[] components = recordClass.getRecordComponents();
@@ -218,6 +220,11 @@ public String toString() {
218220

219221
@Override
220222
public Object getMember(String key) {
223+
// Special internal marker to allow JS-side detection/wrapping
224+
if ("__isRecordWrapper".equals(key)) {
225+
return Boolean.TRUE;
226+
}
227+
221228
// First, check if this is a record component - return the cached value directly
222229
// This ensures record components are accessed as properties (person.name)
223230
// rather than methods (person.name())
@@ -286,6 +293,33 @@ public String toString() {
286293
return record.toString();
287294
}
288295

296+
// Attempt to support method-style access like person.name() on ProxyObject
297+
// GraalJS should dispatch member calls to this if supported by the ProxyObject API
298+
public Object invokeMember(String key, Object... args) {
299+
try {
300+
Method method = record.getClass().getMethod(key);
301+
method.setAccessible(true);
302+
Object result = method.invoke(record);
303+
return wrapIfRecord(result);
304+
} catch (NoSuchMethodException e) {
305+
// Not a no-arg method; try with arity
306+
try {
307+
Class<?>[] types = new Class<?>[args.length];
308+
for (int i = 0; i < args.length; i++) {
309+
types[i] = args[i] == null ? Object.class : args[i].getClass();
310+
}
311+
Method method = record.getClass().getMethod(key, types);
312+
method.setAccessible(true);
313+
Object result = method.invoke(record, args);
314+
return wrapIfRecord(result);
315+
} catch (Exception ex) {
316+
throw new RuntimeException("Failed to invoke member '" + key + "' on record", ex);
317+
}
318+
} catch (Exception e) {
319+
throw new RuntimeException("Failed to invoke member '" + key + "' on record", e);
320+
}
321+
}
322+
289323
@Override
290324
public boolean equals(Object o) {
291325
if (this == o) return true;
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package de.neuland.pug4j.expression;
2+
3+
import de.neuland.pug4j.PugEngine;
4+
import de.neuland.pug4j.RenderContext;
5+
import de.neuland.pug4j.template.PugTemplate;
6+
import de.neuland.pug4j.template.ReaderTemplateLoader;
7+
import org.junit.Test;
8+
9+
import java.io.StringReader;
10+
import java.util.*;
11+
12+
import static org.hamcrest.CoreMatchers.is;
13+
import static org.hamcrest.MatcherAssert.assertThat;
14+
15+
public class RecordsGraalJsTest {
16+
17+
// Define tiny records for tests (Java 17+)
18+
record Address(String city, String street) {}
19+
record Person(String name, int age, Address address) {}
20+
21+
private String render(String pug, Map<String, Object> model) throws Exception {
22+
ReaderTemplateLoader loader = new ReaderTemplateLoader(new StringReader(pug), "inline");
23+
PugEngine engine = PugEngine.builder()
24+
.templateLoader(loader)
25+
.expressionHandler(new GraalJsExpressionHandler())
26+
.build();
27+
PugTemplate template = engine.getTemplate("inline");
28+
RenderContext context = RenderContext.defaults();
29+
return engine.render(template, model, context);
30+
}
31+
32+
@Test
33+
public void record_component_dot_access_works() throws Exception {
34+
Person person = new Person("Alice", 41, new Address("Hamburg", "Reeperbahn"));
35+
Map<String, Object> model = new HashMap<>();
36+
model.put("person", person);
37+
38+
String html = render("p= person.name", model);
39+
assertThat(html, is("<p>Alice</p>"));
40+
41+
html = render("p= person.age + 1", model);
42+
assertThat(html, is("<p>42</p>"));
43+
}
44+
45+
@Test
46+
public void record_component_accidental_parens_are_forgiven() throws Exception {
47+
Person person = new Person("Bob", 19, new Address("Berlin", "Unter den Linden"));
48+
Map<String, Object> model = new HashMap<>();
49+
model.put("person", person);
50+
51+
String html = render("p= person.name()", model);
52+
assertThat(".name() should behave like .name", html, is("<p>Bob</p>"));
53+
54+
html = render("p= person.age() + 1", model);
55+
assertThat(".age() should behave like .age", html, is("<p>20</p>"));
56+
}
57+
58+
@Test
59+
public void nested_record_access_dot_and_parens() throws Exception {
60+
Person person = new Person("Clara", 30, new Address("Cologne", "Domplatz"));
61+
Map<String, Object> model = new HashMap<>();
62+
model.put("person", person);
63+
64+
String html = render("p= person.address.city", model);
65+
assertThat(html, is("<p>Cologne</p>"));
66+
67+
html = render("p= person.address.city()", model);
68+
assertThat(".city() should behave like .city", html, is("<p>Cologne</p>"));
69+
}
70+
71+
@Test
72+
public void list_of_records_in_each_loop() throws Exception {
73+
List<Person> people = Arrays.asList(
74+
new Person("Ann", 21, new Address("Bonn", "Mitte")),
75+
new Person("Eve", 25, new Address("Munich", "Marienplatz"))
76+
);
77+
Map<String, Object> model = new HashMap<>();
78+
model.put("people", people);
79+
80+
String pug = String.join("\n",
81+
"ul",
82+
" each p in people",
83+
" li= p.name + ' (' + p.age + ')'"
84+
);
85+
String html = render(pug, model);
86+
assertThat(html, is("<ul><li>Ann (21)</li><li>Eve (25)</li></ul>"));
87+
}
88+
89+
public static class Pojo {
90+
private final String fullName;
91+
public Pojo(String fullName) { this.fullName = fullName; }
92+
public String fullName() { return fullName; }
93+
}
94+
95+
@Test
96+
public void real_zero_arg_method_calls_on_pojo_are_not_rewritten() throws Exception {
97+
Pojo pojo = new Pojo("Dora Explorer");
98+
Map<String, Object> model = new HashMap<>();
99+
model.put("pojo", pojo);
100+
101+
String html = render("p= pojo.fullName()", model);
102+
assertThat(html, is("<p>Dora Explorer</p>"));
103+
}
104+
105+
@Test
106+
public void missing_component_is_null_and_renders_empty_string() throws Exception {
107+
Person person = new Person("Felix", 7, new Address("Essen", "Zentrum"));
108+
Map<String, Object> model = new HashMap<>();
109+
model.put("person", person);
110+
111+
// Unknown property should evaluate to null and render as empty string
112+
String html = render("p= person.unknown", model);
113+
assertThat(html, is("<p></p>"));
114+
}
115+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package de.neuland.pug4j.expression;
2+
3+
import de.neuland.pug4j.PugEngine;
4+
import de.neuland.pug4j.RenderContext;
5+
import de.neuland.pug4j.template.PugTemplate;
6+
import de.neuland.pug4j.template.ReaderTemplateLoader;
7+
import org.junit.Test;
8+
9+
import java.io.StringReader;
10+
import java.util.*;
11+
12+
import static org.hamcrest.CoreMatchers.is;
13+
import static org.hamcrest.MatcherAssert.assertThat;
14+
15+
public class RecordsJexlTest {
16+
17+
// Define tiny records for tests (Java 17+)
18+
record Address(String city, String street) {}
19+
record Person(String name, int age, Address address) {}
20+
21+
private String render(String pug, Map<String, Object> model) throws Exception {
22+
ReaderTemplateLoader loader = new ReaderTemplateLoader(new StringReader(pug), "inline");
23+
PugEngine engine = PugEngine.builder()
24+
.templateLoader(loader)
25+
.expressionHandler(new JexlExpressionHandler())
26+
.build();
27+
PugTemplate template = engine.getTemplate("inline");
28+
RenderContext context = RenderContext.defaults();
29+
return engine.render(template, model, context);
30+
}
31+
32+
@Test
33+
public void record_component_dot_access_works() throws Exception {
34+
Person person = new Person("Alice", 41, new Address("Hamburg", "Reeperbahn"));
35+
Map<String, Object> model = new HashMap<>();
36+
model.put("person", person);
37+
38+
String html = render("p= person.name", model);
39+
assertThat(html, is("<p>Alice</p>"));
40+
41+
html = render("p= person.age + 1", model);
42+
assertThat(html, is("<p>42</p>"));
43+
}
44+
45+
@Test
46+
public void record_component_parens_and_dot_both_work() throws Exception {
47+
Person person = new Person("Bob", 19, new Address("Berlin", "Unter den Linden"));
48+
Map<String, Object> model = new HashMap<>();
49+
model.put("person", person);
50+
51+
// In JEXL, record accessors are real zero-arg methods, so both styles work
52+
String html = render("p= person.name()", model);
53+
assertThat(html, is("<p>Bob</p>"));
54+
55+
html = render("p= person.age() + 1", model);
56+
assertThat(html, is("<p>20</p>"));
57+
}
58+
59+
@Test
60+
public void nested_record_access_dot_and_parens() throws Exception {
61+
Person person = new Person("Clara", 30, new Address("Cologne", "Domplatz"));
62+
Map<String, Object> model = new HashMap<>();
63+
model.put("person", person);
64+
65+
String html = render("p= person.address.city", model);
66+
assertThat(html, is("<p>Cologne</p>"));
67+
68+
html = render("p= person.address.city()", model);
69+
assertThat(html, is("<p>Cologne</p>"));
70+
}
71+
72+
@Test
73+
public void list_of_records_in_each_loop() throws Exception {
74+
List<Person> people = Arrays.asList(
75+
new Person("Ann", 21, new Address("Bonn", "Mitte")),
76+
new Person("Eve", 25, new Address("Munich", "Marienplatz"))
77+
);
78+
Map<String, Object> model = new HashMap<>();
79+
model.put("people", people);
80+
81+
String pug = String.join("\n",
82+
"ul",
83+
" each p in people",
84+
" li= p.name + ' (' + p.age + ')'"
85+
);
86+
String html = render(pug, model);
87+
assertThat(html, is("<ul><li>Ann (21)</li><li>Eve (25)</li></ul>"));
88+
}
89+
90+
public static class Pojo {
91+
private final String fullName;
92+
public Pojo(String fullName) { this.fullName = fullName; }
93+
public String fullName() { return fullName; }
94+
}
95+
96+
@Test
97+
public void real_zero_arg_method_calls_on_pojo_work_normally() throws Exception {
98+
Pojo pojo = new Pojo("Dora Explorer");
99+
Map<String, Object> model = new HashMap<>();
100+
model.put("pojo", pojo);
101+
102+
String html = render("p= pojo.fullName()", model);
103+
assertThat(html, is("<p>Dora Explorer</p>"));
104+
}
105+
106+
@Test
107+
public void missing_component_is_null_and_renders_empty_string() throws Exception {
108+
Person person = new Person("Felix", 7, new Address("Essen", "Zentrum"));
109+
Map<String, Object> model = new HashMap<>();
110+
model.put("person", person);
111+
112+
// Unknown property should evaluate to null and render as empty string
113+
String html = render("p= person.unknown", model);
114+
assertThat(html, is("<p></p>"));
115+
}
116+
}

0 commit comments

Comments
 (0)