Skip to content

Commit 274ea5d

Browse files
committed
feat(lib): add binding lookup interface for custom functions
Implements IBindingLookup interface allowing custom built-in functions to access JSONata bindings (variables and functions) via the [BindingLookupArgument] attribute. Parameters marked with this attribute receive an IBindingLookup instance automatically and don't count toward the function's required argument count. - Created IBindingLookup interface with Lookup(string) method - Implemented explicit interface on EvaluationEnvironment - Added public BindingLookupArgumentAttribute with XML documentation - Extended FunctionTokenCsharp to detect, validate, and inject lookup - Added comprehensive tests covering behavior and error cases
1 parent 48186c3 commit 274ea5d

File tree

5 files changed

+185
-5
lines changed

5 files changed

+185
-5
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
using System;
2+
using Jsonata.Net.Native;
3+
using Jsonata.Net.Native.Eval;
4+
using Jsonata.Net.Native.Json;
5+
using NUnit.Framework;
6+
7+
namespace Jsonata.Net.Native.Tests
8+
{
9+
public sealed class BindingLookupTests
10+
{
11+
[Test]
12+
public void CanLookupBoundVariable()
13+
{
14+
EvaluationEnvironment env = new EvaluationEnvironment();
15+
env.BindFunction("getVar", GetVariable);
16+
env.BindValue("myVar", new JValue("test value"));
17+
18+
JsonataQuery query = new JsonataQuery("$getVar()");
19+
JToken result = query.Eval(JValue.CreateNull(), env);
20+
21+
Assert.AreEqual("test value", (string)result);
22+
}
23+
24+
[Test]
25+
public void ReturnsUndefinedWhenVariableNotBound()
26+
{
27+
EvaluationEnvironment env = new EvaluationEnvironment();
28+
env.BindFunction("getVar", GetVariable);
29+
30+
JsonataQuery query = new JsonataQuery("$getVar()");
31+
JToken result = query.Eval(JValue.CreateNull(), env);
32+
33+
Assert.AreEqual(JTokenType.Undefined, result.Type);
34+
}
35+
36+
[Test]
37+
public void CanCombineWithRegularParameters()
38+
{
39+
EvaluationEnvironment env = new EvaluationEnvironment();
40+
env.BindFunction("concat", ConcatWithVariable);
41+
env.BindValue("suffix", new JValue(" world"));
42+
43+
JsonataQuery query = new JsonataQuery("$concat('hello')");
44+
JToken result = query.Eval(JValue.CreateNull(), env);
45+
46+
Assert.AreEqual("hello world", (string)result);
47+
}
48+
49+
[Test]
50+
public void CanAccessMultipleVariables()
51+
{
52+
EvaluationEnvironment env = new EvaluationEnvironment();
53+
env.BindFunction("fullName", BuildFullName);
54+
env.BindValue("firstName", new JValue("John"));
55+
env.BindValue("lastName", new JValue("Doe"));
56+
57+
JsonataQuery query = new JsonataQuery("$fullName()");
58+
JToken result = query.Eval(JValue.CreateNull(), env);
59+
60+
Assert.AreEqual("John Doe", (string)result);
61+
}
62+
63+
[Test]
64+
public void ThrowsExceptionWhenAttributeUsedOnWrongType()
65+
{
66+
EvaluationEnvironment env = new EvaluationEnvironment();
67+
68+
JsonataException? ex = Assert.Throws<JsonataException>(() =>
69+
env.BindFunction("invalid", InvalidParameterType));
70+
71+
Assert.AreEqual("D3200", ex!.Code);
72+
Assert.That(ex!.Message, Does.Contain("IBindingLookup"));
73+
}
74+
75+
[Test]
76+
public void ErrorMessageExcludesInjectedParameterFromCount()
77+
{
78+
EvaluationEnvironment env = new EvaluationEnvironment();
79+
env.BindFunction("test", FunctionRequiringOneArg);
80+
81+
JsonataQuery query = new JsonataQuery("$test()");
82+
JsonataException? ex = Assert.Throws<JsonataException>(() =>
83+
query.Eval(JValue.CreateNull(), env));
84+
85+
Assert.AreEqual("T0410", ex!.Code);
86+
Assert.That(ex!.Message, Does.Contain("requires 1"));
87+
}
88+
89+
public static JToken GetVariable([BindingLookupArgument] IBindingLookup lookup)
90+
{
91+
return lookup.Lookup("myVar");
92+
}
93+
94+
public static JToken ConcatWithVariable(
95+
string prefix,
96+
[BindingLookupArgument] IBindingLookup lookup)
97+
{
98+
JToken suffix = lookup.Lookup("suffix");
99+
if (suffix.Type == JTokenType.String)
100+
{
101+
return new JValue(prefix + (string)suffix);
102+
}
103+
return new JValue(prefix);
104+
}
105+
106+
public static JToken BuildFullName([BindingLookupArgument] IBindingLookup lookup)
107+
{
108+
JToken firstName = lookup.Lookup("firstName");
109+
JToken lastName = lookup.Lookup("lastName");
110+
111+
if (firstName.Type == JTokenType.String && lastName.Type == JTokenType.String)
112+
{
113+
return new JValue($"{(string)firstName} {(string)lastName}");
114+
}
115+
return EvalProcessor.UNDEFINED;
116+
}
117+
118+
public static JToken InvalidParameterType([BindingLookupArgument] string wrongType)
119+
{
120+
return JValue.CreateNull();
121+
}
122+
123+
public static JToken FunctionRequiringOneArg(
124+
string required,
125+
[BindingLookupArgument] IBindingLookup lookup)
126+
{
127+
return new JValue("result");
128+
}
129+
}
130+
}

src/Jsonata.Net.Native/Eval/Attributes.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,23 @@ public OptionalArgumentAttribute(object? defaultValue)
4040
}
4141
}
4242

43-
//provides support for builtin functions that require EvaluationSupplement
4443
[AttributeUsage(AttributeTargets.Parameter)]
4544
internal sealed class EvalSupplementArgumentAttribute : Attribute
4645
{
4746
}
4847

48+
/// <summary>
49+
/// Marks a parameter to receive an <see cref="IBindingLookup"/> instance that provides
50+
/// access to JSONata bindings (variables and functions) during evaluation.
51+
/// The parameter type must be <see cref="IBindingLookup"/>.
52+
/// This parameter is automatically injected and does not count toward the function's
53+
/// required argument count in JSONata expressions.
54+
/// </summary>
55+
[AttributeUsage(AttributeTargets.Parameter)]
56+
public sealed class BindingLookupArgumentAttribute : Attribute
57+
{
58+
}
59+
4960
[AttributeUsage(AttributeTargets.Parameter)]
5061
internal sealed class VariableNumberArgumentAsArrayAttribute : Attribute
5162
{

src/Jsonata.Net.Native/Eval/FunctionTokenCsharp.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ internal sealed class FunctionTokenCsharp : FunctionToken
1818
private readonly string m_functionName;
1919
private readonly bool m_hasContextParameter;
2020
private readonly bool m_hasEnvParameter;
21+
private readonly bool m_hasBindingLookupParameter;
2122

2223
internal FunctionTokenCsharp(string funcName, MethodInfo methodInfo)
2324
:this(funcName, methodInfo, null)
@@ -44,8 +45,9 @@ private FunctionTokenCsharp(string funcName, MethodInfo methodInfo, object? targ
4445
.ToList();
4546
this.m_hasContextParameter = this.m_parameters.Any(p => p.allowContextAsValue);
4647
this.m_hasEnvParameter = this.m_parameters.Any(p => p.isEvaluationSupplement);
48+
this.m_hasBindingLookupParameter = this.m_parameters.Any(p => p.isBindingLookup);
4749

48-
this.RequiredArgsCount = this.m_parameters.Where(p => !p.isOptional && !p.isEvaluationSupplement).Count();
50+
this.RequiredArgsCount = this.m_parameters.Where(p => !p.isOptional && !p.isEvaluationSupplement && !p.isBindingLookup).Count();
4951
}
5052

5153
internal sealed class ArgumentInfo
@@ -58,6 +60,7 @@ internal sealed class ArgumentInfo
5860
internal readonly bool isOptional;
5961
internal readonly object? defaultValueForOptional;
6062
internal readonly bool isEvaluationSupplement;
63+
internal readonly bool isBindingLookup;
6164
internal readonly bool isVariableArgumentsArray;
6265

6366
internal ArgumentInfo(string functionName, ParameterInfo parameterInfo)
@@ -86,6 +89,12 @@ internal ArgumentInfo(string functionName, ParameterInfo parameterInfo)
8689
throw new JsonataException("????", $"Declaration error for function '{functionName}': attribute [{nameof(EvalSupplementArgumentAttribute)}] can only be specified for arguments of type {nameof(EvaluationSupplement)}");
8790
};
8891

92+
this.isBindingLookup = parameterInfo.IsDefined(typeof(BindingLookupArgumentAttribute), false);
93+
if (this.isBindingLookup && parameterInfo.ParameterType != typeof(IBindingLookup))
94+
{
95+
throw new JsonataException("D3200", $"Declaration error for function '{functionName}': attribute [{nameof(BindingLookupArgumentAttribute)}] can only be specified for parameters of type {nameof(IBindingLookup)}");
96+
};
97+
8998
this.isVariableArgumentsArray = parameterInfo.IsDefined(typeof(VariableNumberArgumentAsArrayAttribute), false);
9099
if (this.isVariableArgumentsArray && parameterInfo.ParameterType != typeof(JArray))
91100
{
@@ -165,6 +174,10 @@ internal override JToken Invoke(List<JToken> args, JToken? context, EvaluationEn
165174
{
166175
result[targetIndex] = env.GetEvaluationSupplement();
167176
}
177+
else if (argumentInfo.isBindingLookup)
178+
{
179+
result[targetIndex] = env;
180+
}
168181
else if (sourceIndex >= args.Count)
169182
{
170183
if (argumentInfo.isOptional)
@@ -174,7 +187,7 @@ internal override JToken Invoke(List<JToken> args, JToken? context, EvaluationEn
174187
}
175188
else
176189
{
177-
throw new JsonataException("T0410", $"Function '{m_functionName}' requires {this.m_parameters.Count + (this.m_hasEnvParameter? -1 : 0)} arguments. Passed {args.Count} arguments");
190+
throw new JsonataException("T0410", $"Function '{m_functionName}' requires {this.m_parameters.Count + (this.m_hasEnvParameter? -1 : 0) + (this.m_hasBindingLookupParameter ? -1 : 0)} arguments. Passed {args.Count} arguments");
178191
}
179192
}
180193
else if (argumentInfo.isVariableArgumentsArray)
@@ -202,7 +215,7 @@ internal override JToken Invoke(List<JToken> args, JToken? context, EvaluationEn
202215

203216
if (sourceIndex < args.Count)
204217
{
205-
throw new JsonataException("T0410", $"Function '{m_functionName}' requires {this.m_parameters.Count + (this.m_hasEnvParameter ? -1 : 0)} arguments. Passed {args.Count} arguments");
218+
throw new JsonataException("T0410", $"Function '{m_functionName}' requires {this.m_parameters.Count + (this.m_hasEnvParameter ? -1 : 0) + (this.m_hasBindingLookupParameter ? -1 : 0)} arguments. Passed {args.Count} arguments");
206219
};
207220

208221
return result;

src/Jsonata.Net.Native/EvaluationEnvironment.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
namespace Jsonata.Net.Native
1111
{
12-
public sealed class EvaluationEnvironment
12+
public sealed class EvaluationEnvironment : IBindingLookup
1313
{
1414
public static readonly EvaluationEnvironment DefaultEnvironment;
1515

@@ -105,6 +105,8 @@ internal JToken Lookup(string name)
105105
}
106106
}
107107

108+
JToken IBindingLookup.Lookup(string name) => this.Lookup(name);
109+
108110
internal EvaluationSupplement GetEvaluationSupplement()
109111
{
110112
if (this.m_evaluationSupplement == null)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using Jsonata.Net.Native.Json;
2+
3+
namespace Jsonata.Net.Native
4+
{
5+
/// <summary>
6+
/// Provides access to JSONata bindings (variables and functions) during evaluation.
7+
/// Custom built-in functions can receive this interface via the
8+
/// [BindingLookupArgument] attribute to access bindings in the evaluation environment.
9+
/// </summary>
10+
public interface IBindingLookup
11+
{
12+
/// <summary>
13+
/// Looks up a binding by name in the evaluation environment.
14+
/// Searches the current environment and parent environments
15+
/// hierarchically until found or all environments exhausted.
16+
/// Can return variables, functions, or any bound JToken value.
17+
/// </summary>
18+
/// <param name="name">The binding name to look up (without '$' prefix for variables/functions)</param>
19+
/// <returns>
20+
/// The JToken value bound to the name (can be a value, function, or UNDEFINED if not found)
21+
/// </returns>
22+
JToken Lookup(string name);
23+
}
24+
}

0 commit comments

Comments
 (0)