From 46d7b24af83a8db224a8514cc7bf6001a887df78 Mon Sep 17 00:00:00 2001 From: Andreas Eberhart Date: Thu, 5 Nov 2026 10:36:11 +0100 Subject: [PATCH 1/3] Align partial function application with jsonata-js #95 Partially applying a native function now wraps the function itself as the lambda body (bodyAST.body = native) instead of generating a trampoline expression that calls the builtin by name. As in JS, the signature of the native function is not validated when the partial is invoked. - implement applyNativeFunction / getNativeFunctionArguments - only '?' leaves a parameter unbound, missing args are bound to null (#86) - fix '?' comparison (token value is a Character) - no lookup by name, so unnamed / custom JFunctions work (#83) - arity of a JFunctionCallable is taken from its signature - arrays now reach $each / $merge, which iterate them like JS for..in since #131 (#79) - fix inverted T1007 check Co-Authored-By: Claude Opus 5.5 --- .../java/com/dashjoin/jsonata/Jsonata.java | 86 +++++++++---------- .../java/com/dashjoin/jsonata/Parser.java | 4 +- .../com/dashjoin/jsonata/PartialTest.java | 75 ++++++++++++++++ 3 files changed, 116 insertions(+), 49 deletions(-) create mode 100644 src/test/java/com/dashjoin/jsonata/PartialTest.java diff --git a/src/main/java/com/dashjoin/jsonata/Jsonata.java b/src/main/java/com/dashjoin/jsonata/Jsonata.java index f640c05..2c015a6 100644 --- a/src/main/java/com/dashjoin/jsonata/Jsonata.java +++ b/src/main/java/com/dashjoin/jsonata/Jsonata.java @@ -1742,16 +1742,16 @@ Jsonata getPerThreadInstance() { // trampoline loop - this gets invoked as a result of tail-call optimization // the Object returned a tail-call thunk // unpack it, evaluate its arguments, and apply the tail call - var next = /* await */ evaluate(((Symbol)result).body.procedure, ((Symbol)result).input, ((Symbol)result).environment); - if("variable".equals(((Symbol)result).body.procedure.type)) { + var next = /* await */ evaluate(((Symbol)((Symbol)result).body).procedure, ((Symbol)result).input, ((Symbol)result).environment); + if("variable".equals(((Symbol)((Symbol)result).body).procedure.type)) { if (next instanceof Symbol) // Java: not if JFunction - ((Symbol)next).token = ((Symbol)result).body.procedure.value; + ((Symbol)next).token = ((Symbol)((Symbol)result).body).procedure.value; } if (next instanceof Symbol) // Java: not if JFunction - ((Symbol)next).position = ((Symbol)result).body.procedure.position; + ((Symbol)next).position = ((Symbol)((Symbol)result).body).procedure.position; var evaluatedArgs = new ArrayList<>(); - for(var ii = 0; ii < ((Symbol)result).body.arguments.size(); ii++) { - evaluatedArgs.add(/* await */ evaluate(((Symbol)result).body.arguments.get(ii), ((Symbol)result).input, ((Symbol)result).environment)); + for(var ii = 0; ii < ((Symbol)((Symbol)result).body).arguments.size(); ii++) { + evaluatedArgs.add(/* await */ evaluate(((Symbol)((Symbol)result).body).arguments.get(ii), ((Symbol)result).input, ((Symbol)result).environment)); } result = /* await */ applyInner(next, evaluatedArgs, input, environment); @@ -1908,7 +1908,7 @@ Object evaluateLambda(Symbol expr, Object input, Frame environment) { var evaluatedArgs = new ArrayList<>(); for(var ii = 0; ii < expr.arguments.size(); ii++) { var arg = expr.arguments.get(ii); - if (arg.type.equals("operator") && (arg.value.equals("?"))) { + if (arg.type.equals("operator") && (""+arg.value).equals("?")) { evaluatedArgs.add(arg); } else { evaluatedArgs.add(/* await */ evaluate(arg, input, environment)); @@ -1916,7 +1916,7 @@ Object evaluateLambda(Symbol expr, Object input, Frame environment) { } // lookup the procedure var proc = /* await */ evaluate(expr.procedure, input, environment); - if (proc != null && expr.procedure.type.equals("path") && environment.lookup((String)expr.procedure.steps.get(0).value)!=null) { + if (proc == null && expr.procedure.type.equals("path") && environment.lookup((String)expr.procedure.steps.get(0).value)!=null) { // help the user out here if they simply forgot the leading $ throw new JException("T1007", expr.position, @@ -1973,12 +1973,12 @@ Object validateArguments(Object signature, Object args, Object context) { if (i>=args.size()) break; env.bind(""+proc.arguments.get(i).value, args.get(i)); } - if (proc.body instanceof Symbol) { - result = evaluate(proc.body, proc.input, env); + if (proc.body instanceof JFunction) { + // this is a lambda that wraps a native Object - generated by partially evaluating a native + result = /* await */ applyNativeFunction((JFunction)proc.body, env); + } else if (proc.body instanceof Symbol) { + result = evaluate((Symbol)proc.body, proc.input, env); } else throw new Error("Cannot execute procedure: "+proc+" "+proc.body); - // if (typeof proc.body === "function") { - // // this is a lambda that wraps a native Object - generated by partially evaluating a native - // result = /* await */ applyNativeFunction(proc.body, env); return result; } @@ -1997,7 +1997,7 @@ Object partialApplyProcedure(Symbol proc, List args) { for (var param : proc.arguments) { // proc.arguments.forEach(Object (param, index) { Object arg = index(); - List partArgs = new ArrayList<>(); - for (int i=0; i<_native.getNumberOfArgs(); i++) { - String argName = "$" + (char)('a'+i); - sigArgs.add(argName); - if (i>=args.size() || args.get(i)==null) - partArgs.add(argName); - else - partArgs.add(args.get(i)); - } - - var body = "function(" + String.join(", ", sigArgs) + "){"; - body += "$"+_native.functionName+"("+String.join(", ", sigArgs) + ") }"; - - if (log.isLoggable(Level.FINE)) log.fine("partial trampoline = "+body); - - // var sigArgs = getNativeFunctionArguments(_native); - // sigArgs = sigArgs.stream().map(sigArg -> { - // return "$" + sigArg; - // }).toList(); - // var body = "function(" + String.join(", ", sigArgs) + "){ _ }"; + var sigArgs = getNativeFunctionArguments(_native); + sigArgs = sigArgs.stream().map(sigArg -> { + return "$" + sigArg; + }).toList(); + var body = "function(" + String.join(", ", sigArgs) + "){ _ }"; var bodyAST = parser.parse(body); - //bodyAST.body = _native; + bodyAST.body = _native; var partial = partialApplyProcedure(bodyAST, (List)args); return partial; } - + /** * Apply native function * @param {Object} proc - Procedure @@ -2064,8 +2044,14 @@ Object partialApplyNativeFunction(JFunction _native, List args) { * @returns {*} Result of applying native function */ /* async */ Object applyNativeFunction(JFunction proc, Frame env) { - // Not called in Java - JFunction call directly calls native function - return null; + var sigArgs = getNativeFunctionArguments(proc); + // generate the array of arguments for invoking the function - look them up in the environment + var args = sigArgs.stream().map(sigArg -> { + return env.lookup(sigArg); + }).toList(); + + var result = proc.call(null, args); + return result; } /** @@ -2073,9 +2059,12 @@ Object partialApplyNativeFunction(JFunction _native, List args) { * @param {Function} func - Function * @returns {*|Array} Native Object arguments */ - List getNativeFunctionArguments(JFunction func) { - // Not called in Java - return null; + List getNativeFunctionArguments(JFunction func) { + // Java: parameter names are not available, use a, b, c, ... + var sigArgs = new ArrayList(); + for (int i=0; i arguments; - Symbol body; + Object body; // Symbol, or JFunction for a partially applied native function List predicate; public List stages; public Object input; @@ -1291,7 +1291,7 @@ Symbol processAST(Symbol expr) { result.arguments = expr.arguments; result.signature = expr.signature; result.position = expr.position; - var body = processAST(expr.body); + var body = processAST((Symbol)expr.body); result.body = tailCallOptimize(body); break; case "condition": diff --git a/src/test/java/com/dashjoin/jsonata/PartialTest.java b/src/test/java/com/dashjoin/jsonata/PartialTest.java new file mode 100644 index 0000000..afc894e --- /dev/null +++ b/src/test/java/com/dashjoin/jsonata/PartialTest.java @@ -0,0 +1,75 @@ +package com.dashjoin.jsonata; + +import static com.dashjoin.jsonata.Jsonata.jsonata; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import com.dashjoin.jsonata.Jsonata.JFunction; +import com.dashjoin.jsonata.Jsonata.JFunctionCallable; + +/** + * Partial function application, see issue #95 + */ +public class PartialTest { + + // #79 + @Test + public void eachEmptyArray() { + Assertions.assertNull(jsonata("[] ~> $each(?, function($v) { $v })").evaluate(Map.of())); + } + + // #86 + @Test + public void mapAppend() { + Assertions.assertEquals(Map.of("systems", "asd2"), + jsonata("{'systems': 'asd2'}.$map($, $append(?))").evaluate(null)); + } + + // missing args are bound to undefined, not left unbound + @Test + public void missingArgsBound() { + Assertions.assertEquals("ello", jsonata("$substring(?, 1)('hello')").evaluate(null)); + } + + // #83 + @Test + public void customFunction() { + var expression = jsonata("$add(1, 2) ~> $add(?, 3)"); + expression.registerFunction("add", new JFunction(new JFunctionCallable() { + @SuppressWarnings("rawtypes") + @Override + public Object call(Object input, List args) throws Throwable { + return (Integer) args.get(0) + (Integer) args.get(1); + } + }, "")); + Assertions.assertEquals(6, expression.evaluate(null)); + } + + // JS iterates array indexes, signature is not checked for partially applied natives + @Test + public void eachArray() { + Assertions.assertEquals(List.of("0", "1"), + jsonata("[5,6] ~> $each(?, function($v,$k) { $k })").evaluate(null)); + } + + @Test + public void mergeWithArray() { + Assertions.assertEquals(Map.of("test", "data"), + jsonata("$ ~> $merge(?)").evaluate(List.of(List.of(), Map.of("test", "data")))); + } + + // signature still checked when called directly + @Test + public void eachArrayDirect() { + Assertions.assertThrows(JException.class, + () -> jsonata("$each([5,6], function($v,$k) { $k })").evaluate(null)); + } + + // forgot the leading $ + @Test + public void missingDollar() { + var ex = Assertions.assertThrows(JException.class, () -> jsonata("substring(?, 1)").evaluate(null)); + Assertions.assertEquals("T1007", ex.getError()); + } +} From 291502194f0c342b8863a273ce9e97e653c6b17e Mon Sep 17 00:00:00 2001 From: Andreas Eberhart Date: Wed, 4 Nov 2026 14:09:10 +0100 Subject: [PATCH 2/3] Use Collectors.toList() for JDK 11 compatibility Co-Authored-By: Claude Opus 5.5 --- src/main/java/com/dashjoin/jsonata/Jsonata.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/dashjoin/jsonata/Jsonata.java b/src/main/java/com/dashjoin/jsonata/Jsonata.java index 2c015a6..72266d5 100644 --- a/src/main/java/com/dashjoin/jsonata/Jsonata.java +++ b/src/main/java/com/dashjoin/jsonata/Jsonata.java @@ -2027,7 +2027,7 @@ Object partialApplyNativeFunction(JFunction _native, List args) { var sigArgs = getNativeFunctionArguments(_native); sigArgs = sigArgs.stream().map(sigArg -> { return "$" + sigArg; - }).toList(); + }).collect(Collectors.toList()); var body = "function(" + String.join(", ", sigArgs) + "){ _ }"; var bodyAST = parser.parse(body); @@ -2048,7 +2048,7 @@ Object partialApplyNativeFunction(JFunction _native, List args) { // generate the array of arguments for invoking the function - look them up in the environment var args = sigArgs.stream().map(sigArg -> { return env.lookup(sigArg); - }).toList(); + }).collect(Collectors.toList()); var result = proc.call(null, args); return result; From a1f2a78aa8a0454dcb5a5578b3f48d3a3d98ae43 Mon Sep 17 00:00:00 2001 From: Andreas Eberhart Date: Thu, 5 Nov 2026 10:35:59 +0100 Subject: [PATCH 3/3] Add test cases from #80, #85, #87 Partial application of custom functions without signature (#85 pipe cases) is left for the follow-up on #83. Co-Authored-By: Claude Opus 5.5 --- .../java/com/dashjoin/jsonata/OldPrsTest.java | 65 +++++++++++++++++++ src/test/resources/map_in_pipe/answer.json | 30 +++++++++ src/test/resources/map_in_pipe/input.json | 58 +++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 src/test/java/com/dashjoin/jsonata/OldPrsTest.java create mode 100644 src/test/resources/map_in_pipe/answer.json create mode 100644 src/test/resources/map_in_pipe/input.json diff --git a/src/test/java/com/dashjoin/jsonata/OldPrsTest.java b/src/test/java/com/dashjoin/jsonata/OldPrsTest.java new file mode 100644 index 0000000..7d820f1 --- /dev/null +++ b/src/test/java/com/dashjoin/jsonata/OldPrsTest.java @@ -0,0 +1,65 @@ +package com.dashjoin.jsonata; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import com.dashjoin.jsonata.Jsonata.JFunction; +import com.dashjoin.jsonata.json.Json; + +/** tests from PRs #80, #85, #87, adapted to the existing API (partial application of custom functions without signature: see #83) */ +public class OldPrsTest { + + static Object res(String name) throws Exception { + return Json.parseJson(new InputStreamReader( + OldPrsTest.class.getClassLoader().getResourceAsStream(name), StandardCharsets.UTF_8)); + } + + // #80 + @Test + public void testEachEmptyArray() { + Assertions.assertNull(Jsonata.jsonata("[] ~> $each(?, function($v) { $v })").evaluate("{}")); + } + + @Test + public void testEachArrayWithData() { + Assertions.assertEquals(List.of(123, 321), + Jsonata.jsonata("[123, 321] ~> $each(?, function($v) { $v })").evaluate("{}")); + } + + @Test + public void testMapInPipe() throws Exception { + var expression = Jsonata.jsonata("$sift($.buildings, function($v, $k) { $k ~> /o/ and $v.presentation.*.widget})" + + " ~> $each(?, function($v, $key) {{ $key: $sift($v.presentation, function($v, $k){ $v.widget }) }})" + + " ~> $map(?, function($v){($v.*)})"); + Assertions.assertEquals(res("map_in_pipe/answer.json"), expression.evaluate(res("map_in_pipe/input.json"))); + } + + // #85 (PR adds a name / arity constructor, here: plain constructor) + @SuppressWarnings("unchecked") + @Test + public void testFunctionWithInputData() { + JFunction addWithFactor = new JFunction((input, args) -> { + int factor = Integer.parseInt(((Map) input).get("factor").toString()); + return ((Integer) args.get(0) + (Integer) args.get(1)) * factor; + }, null); + var expression = Jsonata.jsonata("$addWithFactor(1, 2)"); + expression.registerFunction("addWithFactor", addWithFactor); + Assertions.assertEquals(9, expression.evaluate(Map.of("factor", "3"))); + } + + // #87 + @Test + public void testMapWithAppend() { + Assertions.assertEquals(Json.parseJson("{\"systems\": \"asd\"}"), + Jsonata.jsonata("{'systems': 'asd'}.$map($, $append(?))").evaluate(null)); + } + + @Test + public void testMapWithAppend2() { + Object answer = Json.parseJson("{\"systems\": \"asd\"}"); + Assertions.assertEquals(answer, Jsonata.jsonata("$map($, $append(?))").evaluate(answer)); + } +} diff --git a/src/test/resources/map_in_pipe/answer.json b/src/test/resources/map_in_pipe/answer.json new file mode 100644 index 0000000..5c5e15a --- /dev/null +++ b/src/test/resources/map_in_pipe/answer.json @@ -0,0 +1,30 @@ +[ + { + "first": { + "widget": { + "data": "home widget" + } + } + }, + { + "first": { + "widget": { + "data": "work widget" + } + } + }, + { + "first": { + "widget": { + "data": "old widget" + } + } + }, + { + "first": { + "widget": { + "data": "operation widget" + } + } + } +] \ No newline at end of file diff --git a/src/test/resources/map_in_pipe/input.json b/src/test/resources/map_in_pipe/input.json new file mode 100644 index 0000000..5359645 --- /dev/null +++ b/src/test/resources/map_in_pipe/input.json @@ -0,0 +1,58 @@ +{ + "buildings": { + "home": { + "address": "home address", + "period": "11 month on 12", + "presentation": { + "first": { + "widget": { + "data": "home widget" + } + } + } + }, + "work": { + "address": "work address", + "presentation": { + "first": { + "widget": { + "data": "work widget" + } + } + } + }, + "vacancy": { + "address": "vacancy address", + "period": "1 month on 12", + "presentation": { + "first": { + "widget": { + "data": "vacancy widget" + } + } + } + }, + "old": { + "address": "old address", + "period": "2 month on 12", + "presentation": { + "first": { + "widget": { + "data": "old widget" + } + } + } + }, + "operation": { + "address": "operation address", + "period": "3 month on 12", + "presentation": { + "first": { + "widget": { + "data": "operation widget" + } + } + } + } + } +} \ No newline at end of file