Skip to content

Commit 9cf2e95

Browse files
committed
feat(record): add spread-aware nested-field lookup
1 parent efadad7 commit 9cf2e95

24 files changed

Lines changed: 346 additions & 15 deletions

Expressif.Testing/Functions/Introspection/FunctionIntrospectorTest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ public void Describe_TypedFunction_ExposesExpressifContract(string name, string
184184
public void Describe_UnconvertedFunctions_AreExplicitlyReported()
185185
=> Assert.That(
186186
Infos.Where(x => !x.Converted).Select(x => x.Name),
187-
Is.EquivalentTo(new[] { "apply", "coalesce", "field", "guard", "neutral", "walk", "with" }));
187+
Is.EquivalentTo(new[] { "apply", "coalesce", "field", "guard", "nested-field", "neutral", "walk", "with" }));
188188

189189
[TestCase("after-substring", "substring", "text")]
190190
[TestCase("first-chars", "length", "integer")]
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
using Expressif.Bindings;
2+
using Expressif.Functions;
3+
using Expressif.Functions.Introspection;
4+
using Expressif.Functions.Record;
5+
using Expressif.Testing.Conformance;
6+
using Expressif.Values;
7+
8+
namespace Expressif.Testing.Functions.Record;
9+
10+
public class NestedFieldTest
11+
{
12+
[Conformance]
13+
public void NestedField_Valid_Value(object? value, string expression, object? expected)
14+
=> Assert.That(Expression.Create(expression).Evaluate(value), Is.EqualTo(expected));
15+
16+
[Conformance]
17+
public void NestedField_Valid_Structured(object? value, string expression, string expected)
18+
=> Assert.That(Expression.Create(expression).Evaluate(value)?.ToString(), Is.EqualTo(expected));
19+
20+
[Conformance]
21+
public void NestedField_Valid_Array(object? value, string expression, decimal[] expected)
22+
=> Assert.That(Expression.Create(expression).Evaluate(value), Is.EqualTo(expected));
23+
24+
[Conformance]
25+
public void NestedField_Valid_Numeric(object? value, string expression, decimal expected)
26+
=> Assert.That(Expression.Create(expression).Evaluate(value), Is.EqualTo(expected));
27+
28+
[TestCase("nested-field()")]
29+
[TestCase("nested-field(...{})")]
30+
public void EmptyPath_ThrowsArgumentError(string source)
31+
=> Assert.That(
32+
() => Expression.Create(source).Evaluate(null),
33+
Throws.TypeOf<ArgumentException>().With.Message.Contains("at least one field name"));
34+
35+
[TestCase("nested-field(42)")]
36+
[TestCase("nested-field(#null)")]
37+
[TestCase("nested-field({\"name\"})")]
38+
[TestCase("nested-field(...{\"missing\", 42})")]
39+
[TestCase("nested-field(...{name := \"value\"})")]
40+
public void NonTextSegment_ThrowsEvenForUnresolvedInput(string source)
41+
=> Assert.That(
42+
() => Expression.Create(source).Evaluate(null),
43+
Throws.TypeOf<ArgumentException>().With.Message.Contains("must be text"));
44+
45+
[TestCase("nested-field(...#null)")]
46+
[TestCase("nested-field(...42)")]
47+
[TestCase("nested-field(...\"name\")")]
48+
public void UnsupportedSpread_UsesSharedError(string source)
49+
=> Assert.That(
50+
() => Expression.Create(source).Evaluate(null),
51+
Throws.TypeOf<SpreadArgumentException>());
52+
53+
[Test]
54+
public void NamedArgument_IsRejectedDuringBinding()
55+
=> Assert.That(
56+
() => Expression.Create("nested-field(path := \"name\")"),
57+
Throws.InstanceOf<BindingException>());
58+
59+
[TestCase("name")]
60+
[TestCase("Name")]
61+
[TestCase("missing")]
62+
public void SingleSegment_MatchesFieldOnObjects(string name)
63+
{
64+
var input = new { Name = "Ada" };
65+
var function = new NestedField(() => [new(_ => name)]);
66+
Assert.That(function.Evaluate(input), Is.EqualTo(new Field(() => name).Evaluate(input)));
67+
}
68+
69+
[Test]
70+
public void PathArguments_UseOriginalInputInOrderAndPreserveSelectedValue()
71+
{
72+
var selected = new RecordValue();
73+
selected.Set("value", 42);
74+
var input = new RecordValue();
75+
input.Set("child", selected);
76+
var seen = new List<object?>();
77+
var function = new NestedField(() =>
78+
[
79+
new(value => { seen.Add(value); return "child"; }),
80+
new(value => { seen.Add(value); return System.Array.Empty<object?>(); }, true),
81+
]);
82+
83+
Assert.That(function.Evaluate(input), Is.SameAs(selected));
84+
Assert.That(seen, Is.EqualTo(new object?[] { input, input }));
85+
}
86+
87+
[Test]
88+
public void BoundExpression_CanBeReusedConcurrently()
89+
{
90+
var expression = Expression.Create("nested-field(.path, \"value\")");
91+
Parallel.For(0, 20, i =>
92+
{
93+
var input = new RecordValue();
94+
var child = new RecordValue();
95+
child.Set("value", i);
96+
input.Set("path", $"child{i}");
97+
input.Set($"child{i}", child);
98+
Assert.That(expression.Evaluate(input), Is.EqualTo(i));
99+
});
100+
}
101+
102+
[Test]
103+
public void Introspection_DescribesVariadicTextPathAndDynamicOutput()
104+
{
105+
var info = new FunctionIntrospector().Describe().Single(info => info.Name == "nested-field");
106+
using (Assert.EnterMultipleScope())
107+
{
108+
Assert.That(info.ImplementationType, Is.EqualTo(typeof(NestedField)));
109+
Assert.That(info.Input, Is.EqualTo("any"));
110+
Assert.That(info.Output, Is.EqualTo("any"));
111+
Assert.That(info.Converted, Is.False);
112+
Assert.That(info.Reason, Is.EqualTo("Output depends on the value selected by the runtime field path."));
113+
Assert.That(info.Parameters.Single().Name, Is.EqualTo("path"));
114+
Assert.That(info.Parameters.Single().Type, Is.EqualTo("text"));
115+
Assert.That(info.Parameters.Single().Variadic, Is.True);
116+
Assert.That(info.Parameters.Single().MinimumCardinality, Is.EqualTo(1));
117+
}
118+
}
119+
}

Expressif/Bindings/ExpressifBinder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ private Function BindFunction(FunctionCallSyntax syntax)
307307
"is-present" or "is-absent" => BindFieldFunction(syntax),
308308
"record" => BindRecordFunction(syntax),
309309
"with" => BindWithFunction(syntax),
310-
"array" or "text" or "tuple" or "grouping" or "dictionary" => Function.FromArguments(syntax.Name, BindSpreadFunctionArguments(syntax)),
310+
"array" or "text" or "tuple" or "grouping" or "dictionary" or "nested-field" => Function.FromArguments(syntax.Name, BindSpreadFunctionArguments(syntax)),
311311
_ => Function.FromArguments(syntax.Name, BindFunctionArguments(syntax)),
312312
};
313313

Expressif/Functions/FunctionAttribute.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public class FunctionAttribute : Attribute
1111
{
1212
public string[] Aliases { get; }
1313
public string? Prefix { get; }
14+
public string? DynamicReason { get; set; }
1415

1516
public FunctionAttribute()
1617
: this(null, System.Array.Empty<string>()) { }

Expressif/Functions/Introspection/DocumentationExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public static class DocumentationExtensions
2222
{
2323
private static readonly HashSet<(Type Type, string Parameter)> VariadicParameters =
2424
[
25+
(typeof(Record.NestedField), "path"),
2526
(typeof(Record.Record), "entries"),
2627
(typeof(Record.Put), "assignments"),
2728
(typeof(Record.PutPresent), "assignments"),
@@ -166,6 +167,7 @@ private static bool IsVariadicParameter(Type declaringType, string parameterName
166167
private static int GetMinimumCardinality(Type declaringType, string parameterName)
167168
=> (declaringType, parameterName) switch
168169
{
170+
(var type, "path") when type == typeof(Record.NestedField) => 1,
169171
(var type, "expressions") when type == typeof(Special.Coalesce) => 2,
170172
(var type, "specifications") when type == typeof(Special.Coerce) => 1,
171173
(var type, "projections") when type == typeof(Record.With) => 1,

Expressif/Functions/Introspection/ExpressifTypeMapper.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ internal static class ExpressifTypeMapper
2727
[("GroupBy", "expressions")] = "expression",
2828
[("Text", ValuesParameter)] = "expression",
2929
[("Record", "entries")] = "entry",
30+
[("NestedField", "path")] = "text",
3031
[("Put", "assignments")] = "entry",
3132
[("PutPresent", "assignments")] = "entry",
3233
[("PutAbsent", "assignments")] = "entry",

Expressif/Functions/Introspection/FunctionContractIntrospector.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ public static FunctionContract Describe(Type implementationType, string name)
4747
"any",
4848
"any",
4949
false,
50-
UntypedReasons.GetValueOrDefault(
50+
implementationType.GetCustomAttributes(typeof(FunctionAttribute), true)
51+
.OfType<FunctionAttribute>().FirstOrDefault()?.DynamicReason
52+
?? UntypedReasons.GetValueOrDefault(
5153
name,
5254
"No unambiguous closed IFunction<TIn, TOut> contract is exposed."));
5355
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using Expressif.Values;
2+
3+
namespace Expressif.Functions.Record;
4+
5+
/// <summary>Returns the value at a nested field path in the input record or object, or null when the path cannot be resolved.</summary>
6+
[Function(prefix: "", DynamicReason = "Output depends on the value selected by the runtime field path.")]
7+
[Scope("record")]
8+
public sealed class NestedField : IFunction, IValueSpreadAware
9+
{
10+
private Func<ValueArgumentEvaluator[]> Path { get; }
11+
12+
/// <param name="path">One or more literal field names in traversal order. Spread arguments expand arrays of names in place.</param>
13+
public NestedField(Func<ValueArgumentEvaluator[]> path) => Path = path;
14+
15+
public object? Evaluate(object? value)
16+
{
17+
var segments = ValueArguments.Evaluate(Path.Invoke(), value).ToArray();
18+
if (segments.Length == 0)
19+
throw new ArgumentException("The nested-field path must contain at least one field name.", "path");
20+
if (segments.Any(segment => segment is not string))
21+
throw new ArgumentException("Every nested-field path segment must be text.", "path");
22+
23+
foreach (var segment in segments.Cast<string>())
24+
{
25+
if (!NamedValueAccessor.TryGetValue(value, segment, out value))
26+
return null;
27+
}
28+
return value;
29+
}
30+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
suite: record
2+
kind: function
3+
operator: nested-field
4+
tests:
5+
- id: nested-field.valid.value
6+
cases:
7+
- id: nested-field.valid.value.explicit
8+
value:
9+
parameters:
10+
- '{customer := {address := {city := "Brussels"}}} | nested-field("customer", "address", "city")'
11+
expected: Brussels
12+
- id: nested-field.valid.value.spread
13+
value:
14+
parameters:
15+
- '{customer := {address := {city := "Brussels"}}} | nested-field(...{"customer", "address", "city"})'
16+
expected: Brussels
17+
- id: nested-field.valid.value.mixed
18+
value:
19+
parameters:
20+
- '{customer := {address := {city := "Brussels"}}} | nested-field(...{"customer"}, ...{}, "address", ...{"city"})'
21+
expected: Brussels
22+
- id: nested-field.valid.value.dynamic
23+
value:
24+
parameters:
25+
- '{first := "customer", last := "city", customer := {city := "Brussels", last := "wrong"}} | nested-field(.first, .last)'
26+
expected: Brussels
27+
- id: nested-field.valid.value.dotted
28+
value:
29+
parameters:
30+
- '{"customer.city" := "Brussels"} | nested-field("customer.city")'
31+
expected: Brussels
32+
- id: nested-field.valid.value.empty-name
33+
value:
34+
parameters:
35+
- '{"":= "Brussels"} | nested-field("")'
36+
expected: Brussels
37+
- id: nested-field.valid.value.missing
38+
value:
39+
parameters:
40+
- '{customer := {city := "Brussels"}} | nested-field("customer", "missing")'
41+
expected:
42+
- id: nested-field.valid.value.missing-parent
43+
value:
44+
parameters:
45+
- '{name := "Ada"} | nested-field("customer", "city")'
46+
expected:
47+
- id: nested-field.valid.value.null-leaf
48+
value:
49+
parameters:
50+
- '{customer := {city := #null}} | nested-field("customer", "city")'
51+
expected:
52+
- id: nested-field.valid.value.null-parent
53+
value:
54+
parameters:
55+
- '{customer := #null} | nested-field("customer", "city")'
56+
expected:
57+
- id: nested-field.valid.value.scalar-parent
58+
value:
59+
parameters:
60+
- '{customer := 42} | nested-field("customer", "city")'
61+
expected:
62+
- id: nested-field.valid.value.null-input
63+
value:
64+
parameters:
65+
- '#null | nested-field("customer")'
66+
expected:
67+
- id: nested-field.valid.value.scalar-input
68+
value:
69+
parameters:
70+
- '42 | nested-field("customer")'
71+
expected:
72+
- id: nested-field.valid.structured
73+
cases:
74+
- id: nested-field.valid.structured.record
75+
value:
76+
parameters:
77+
- '{customer := {city := "Brussels"}} | nested-field("customer")'
78+
expected: '{city := "Brussels"}'
79+
- id: nested-field.valid.array
80+
cases:
81+
- id: nested-field.valid.array.nested
82+
value:
83+
parameters:
84+
- '{customer := {scores := {1, 2}}} | nested-field("customer", "scores")'
85+
expected: [1, 2]
86+
- id: nested-field.valid.numeric
87+
cases:
88+
- id: nested-field.valid.numeric.single
89+
value:
90+
parameters:
91+
- '{score := 42} | nested-field("score")'
92+
expected: 42

docs/_data/function.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,6 +1779,31 @@
17791779
"{name := \"Ada\", score := 10} | field(\"name\") → \"Ada\""
17801780
]
17811781
},
1782+
{
1783+
"Name": "nested-field",
1784+
"IsPublic": true,
1785+
"Aliases": [],
1786+
"Scope": "record",
1787+
"Input": "any",
1788+
"Output": "any",
1789+
"Summary": "Returns the value at a nested field path in the input record or object, or null when the path cannot be resolved.",
1790+
"Parameters": [
1791+
{
1792+
"Name": "path",
1793+
"Type": "text",
1794+
"Optional": false,
1795+
"Variadic": true,
1796+
"MinimumCardinality": 1,
1797+
"Summary": "One or more literal field names in traversal order. Spread arguments expand arrays of names in place."
1798+
}
1799+
],
1800+
"Behavior": "Path expressions are evaluated from left to right against the original input before traversal. Explicit and spread arguments may be mixed; spread uses the shared array expansion rules. Each text segment is one literal field name, including dots and empty text. The result preserves the selected value and its runtime type, including structured values and null. Field-name matching and unresolved paths follow field semantics. An empty expanded path or a non-text segment raises an argument error; unsupported spread values raise a spread error. Only positional arguments are accepted.",
1801+
"Examples": [
1802+
"{customer := {address := {city := \"Brussels\"}}} | nested-field(\"customer\", \"address\", \"city\") → \"Brussels\"",
1803+
"{customer := {address := {city := \"Brussels\"}}} | nested-field(...{\"customer\", \"address\", \"city\"}) → \"Brussels\"",
1804+
"{customer := {address := {city := \"Brussels\"}}} | nested-field(\"customer\", ...{\"address\", \"city\"}) → \"Brussels\""
1805+
]
1806+
},
17821807
{
17831808
"Name": "field-names",
17841809
"IsPublic": true,

0 commit comments

Comments
 (0)