Sitelet https://github.com/Altinn/app-lib-dotnet/commit/1bd6654624a7118980c34721f9e761beb677e52b
Skip to content

Commit 1bd6654

Browse files
authored
Support objects in expressions (#1777)
1 parent 86a9b89 commit 1bd6654

14 files changed

Lines changed: 367 additions & 94 deletions

File tree

‎src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ internal static async Task<ExpressionValue> EvaluateExpression_internal(
147147
ExpressionFunction.multiply => Multiply(args),
148148
ExpressionFunction.divide => Divide(args),
149149
ExpressionFunction.list => List(args),
150+
ExpressionFunction.@object => Object(args),
150151
ExpressionFunction.INVALID => throw new ExpressionEvaluatorTypeErrorException(
151152
$"Function {expr.Args.FirstOrDefault()} not implemented in backend {expr}"
152153
),
@@ -1018,6 +1019,11 @@ private static ExpressionValue List(ExpressionValue[] args)
10181019
return new JsonArray(args.Select(a => JsonSerializer.SerializeToNode(a)).ToArray());
10191020
}
10201021

1022+
private static ExpressionValue Object(ExpressionValue[] args)
1023+
{
1024+
return ObjectFunctionEvaluator.Evaluate(args);
1025+
}
1026+
10211027
/// <summary>
10221028
/// Performs arithmetic operation using decimal precision to avoid floating point precision issues.
10231029
/// Converts doubles to decimal, performs the operation, and converts back to double.

‎src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs‎

Lines changed: 52 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Diagnostics;
22
using System.Globalization;
3+
using System.Numerics;
34
using System.Text.Encodings.Web;
45
using System.Text.Json;
56
using System.Text.Json.Nodes;
@@ -19,7 +20,7 @@ namespace Altinn.App.Core.Internal.Expressions;
1920
// double is a value type where nullable takes extra space, and we only read it when it should be set
2021
private readonly double _numberValue = 0;
2122

22-
// private readonly Dictionary<string, ExpressionValue>? _objectValue = null;
23+
private readonly JsonObject? _objectValue = null;
2324
private readonly JsonArray? _arrayValue = null;
2425

2526
/// <summary>
@@ -84,11 +85,14 @@ private ExpressionValue(string? value)
8485
_stringValue = value;
8586
}
8687

87-
// private ExpressionValue(Dictionary<string, ExpressionValue>? value)
88-
// {
89-
// _valueKind = value is null ? JsonValueKind.Null : JsonValueKind.Object;
90-
// _objectValue = value;
91-
// }
88+
/// <summary>
89+
/// Constructor for object value
90+
/// </summary>
91+
public ExpressionValue(JsonObject value)
92+
{
93+
ValueKind = JsonValueKind.Object;
94+
_objectValue = value;
95+
}
9296

9397
/// <summary>Constructor for array value</summary>
9498
public ExpressionValue(JsonArray value)
@@ -112,10 +116,10 @@ public ExpressionValue(JsonArray value)
112116
/// </summary>
113117
public static implicit operator ExpressionValue(string? value) => new(value);
114118

115-
// /// <summary>
116-
// /// Convert a Dictionary to ExpressionValue
117-
// /// </summary>
118-
// public static implicit operator ExpressionValue(Dictionary<string, ExpressionValue>? value) => new(value);
119+
/// <summary>
120+
/// Convert a Dictionary to ExpressionValue
121+
/// </summary>
122+
public static implicit operator ExpressionValue(JsonObject value) => new(value);
119123

120124
/// <summary>
121125
/// Convert an array to ExpressionValue
@@ -174,17 +178,20 @@ public static ExpressionValue FromObject(object? value)
174178
'"'
175179
) // Trim quotes to match the string representation
176180
,
177-
JsonArray jsonArrayValue => jsonArrayValue,
178-
_ => ToJsonArrayOrNull(value),
181+
BigInteger => Null,
182+
JsonObject jsonObject => jsonObject,
183+
JsonArray jsonArray => jsonArray,
184+
_ => ToJsonNodeOrNull(value),
179185
};
180186
}
181187

182-
private static ExpressionValue ToJsonArrayOrNull(object? value)
188+
private static ExpressionValue ToJsonNodeOrNull(object? value)
183189
{
184190
var node = JsonSerializer.SerializeToNode(value);
185191
return node switch
186192
{
187193
JsonArray jsonArray => jsonArray,
194+
JsonObject jsonObject => jsonObject,
188195
_ => Null,
189196
};
190197
}
@@ -202,7 +209,7 @@ private static ExpressionValue ToJsonArrayOrNull(object? value)
202209
JsonValueKind.False => false,
203210
JsonValueKind.String => String,
204211
JsonValueKind.Number => Number,
205-
// JsonValueKind.Object => Object,
212+
JsonValueKind.Object => Object,
206213
JsonValueKind.Array => Array,
207214
_ => throw new InvalidOperationException("Invalid value kind"),
208215
};
@@ -249,14 +256,19 @@ private static ExpressionValue ToJsonArrayOrNull(object? value)
249256
),
250257
};
251258

252-
// public Dictionary<string, ExpressionValue> Object =>
253-
// _valueKind switch
254-
// {
255-
// JsonValueKind.Object => _objectValue ?? throw new UnreachableException($"{this} is not an object"),
256-
// _ => throw new InvalidCastException(
257-
// $"The .Object property can't be used on an expression value that represent a {_valueKind}"
258-
// ),
259-
// };
259+
#pragma warning disable CA1720
260+
/// <summary>
261+
/// Get the value as an object (or throw if it isn't an object ValueKind)
262+
/// </summary>
263+
public JsonObject Object =>
264+
ValueKind switch
265+
{
266+
JsonValueKind.Object => _objectValue ?? throw new UnreachableException($"{this} is not an object"),
267+
_ => throw new InvalidCastException(
268+
$"The .Object property can't be used on an expression value that represent a {ValueKind}"
269+
),
270+
};
271+
#pragma warning restore CA1720
260272

261273
/// <summary>Get the value as an array (or throw if it isn't an array ValueKind)</summary>
262274
public JsonArray Array =>
@@ -280,8 +292,8 @@ public override string ToString() =>
280292
JsonValueKind.False => "false",
281293
JsonValueKind.String => JsonSerializer.Serialize(String, _unsafeSerializerOptionsForSerializingDates),
282294
JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture),
283-
// JsonValueKind.Object => JsonSerializer.Serialize(Object),
284-
// JsonValueKind.Array => JsonSerializer.Serialize(Array),
295+
JsonValueKind.Object => JsonSerializer.Serialize(Object),
296+
JsonValueKind.Array => JsonSerializer.Serialize(Array),
285297
_ => throw new InvalidOperationException($"Invalid value kind {ValueKind}"),
286298
};
287299

@@ -302,7 +314,7 @@ public override string ToString() =>
302314
JsonValueKind.False => "false",
303315
JsonValueKind.String => String,
304316
JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture),
305-
// JsonValueKind.Object => JsonSerializer.Serialize(Object),
317+
JsonValueKind.Object => JsonSerializer.Serialize(Object),
306318
JsonValueKind.Array => JsonSerializer.Serialize(Array),
307319
_ => throw new InvalidOperationException($"Invalid value kind {ValueKind}"),
308320
};
@@ -634,7 +646,7 @@ public override ExpressionValue Read(ref Utf8JsonReader reader, Type typeToConve
634646
JsonTokenType.String => reader.GetString(),
635647
JsonTokenType.Number => reader.GetDouble(),
636648
JsonTokenType.Null => ExpressionValue.Null,
637-
// JsonTokenType.StartObject => ReadObject(ref reader),
649+
JsonTokenType.StartObject => ReadObject(ref reader, options),
638650
JsonTokenType.StartArray => ReadArray(ref reader, options),
639651
_ => throw new JsonException(),
640652
};
@@ -652,10 +664,17 @@ private static ExpressionValue ReadArray(ref Utf8JsonReader reader, JsonSerializ
652664
return new ExpressionValue(values);
653665
}
654666

655-
// private ExpressionValue ReadObject(ref Utf8JsonReader reader)
656-
// {
657-
// throw new NotImplementedException();
658-
// }
667+
private static ExpressionValue ReadObject(ref Utf8JsonReader reader, JsonSerializerOptions options)
668+
{
669+
if (reader.TokenType != JsonTokenType.StartObject)
670+
{
671+
throw new JsonException("Expected StartObject token.");
672+
}
673+
var value =
674+
JsonSerializer.Deserialize<JsonObject>(ref reader, options)
675+
?? throw new JsonException("Expected JSON object value.");
676+
return new ExpressionValue(value);
677+
}
659678

660679
/// <inheritdoc />
661680
public override void Write(Utf8JsonWriter writer, ExpressionValue value, JsonSerializerOptions options)
@@ -678,9 +697,9 @@ public override void Write(Utf8JsonWriter writer, ExpressionValue value, JsonSer
678697
case JsonValueKind.Number:
679698
writer.WriteNumberValue(value.Number);
680699
break;
681-
// case JsonValueKind.Object:
682-
// JsonSerializer.Serialize(writer, value.Object, options);
683-
// break;
700+
case JsonValueKind.Object:
701+
JsonSerializer.Serialize(writer, value.Object, options);
702+
break;
684703
case JsonValueKind.Array:
685704
JsonSerializer.Serialize(writer, value.Array, options);
686705
break;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Nodes;
3+
using Altinn.App.Core.Models.Expressions;
4+
5+
namespace Altinn.App.Core.Internal.Expressions;
6+
7+
internal static class ObjectFunctionEvaluator
8+
{
9+
public static JsonObject Evaluate(ExpressionValue[] args)
10+
{
11+
AssertEvenNumberOfArguments(args);
12+
string[] keys = ExtractKeys(args);
13+
AssertKeysAreUnique(keys, args);
14+
JsonNode?[] values = ExtractValues(args);
15+
Dictionary<string, JsonNode?> keyValuePairs = DictionaryFromKeysAndValues(keys, values);
16+
return new JsonObject(keyValuePairs);
17+
}
18+
19+
private static void AssertEvenNumberOfArguments(ExpressionValue[] args)
20+
{
21+
if (args.Length % 2 == 1)
22+
{
23+
throw new ExpressionEvaluatorTypeErrorException(
24+
"The object function must have an even number of arguments.",
25+
ExpressionFunction.@object,
26+
args
27+
);
28+
}
29+
}
30+
31+
private static string[] ExtractKeys(ExpressionValue[] args)
32+
{
33+
try
34+
{
35+
return ExtractEvenIndexedArguments(args).Select(v => v.String).ToArray();
36+
}
37+
catch (InvalidCastException)
38+
{
39+
throw new ExpressionEvaluatorTypeErrorException(
40+
"Object keys must be strings.",
41+
ExpressionFunction.@object,
42+
args
43+
);
44+
}
45+
}
46+
47+
private static ExpressionValue[] ExtractEvenIndexedArguments(ExpressionValue[] args) =>
48+
args.Where((_, index) => index % 2 == 0).ToArray();
49+
50+
private static void AssertKeysAreUnique(string[] keys, ExpressionValue[] args)
51+
{
52+
if (keys.Length != keys.Distinct().Count())
53+
{
54+
throw new ExpressionEvaluatorTypeErrorException(
55+
"Object keys must be unique.",
56+
ExpressionFunction.@object,
57+
args
58+
);
59+
}
60+
}
61+
62+
private static JsonNode?[] ExtractValues(ExpressionValue[] args) =>
63+
ExtractOddIndexedArguments(args).Select(v => JsonSerializer.SerializeToNode(v)).ToArray();
64+
65+
private static ExpressionValue[] ExtractOddIndexedArguments(ExpressionValue[] args) =>
66+
args.Where((_, index) => index % 2 == 1).ToArray();
67+
68+
private static Dictionary<string, JsonNode?> DictionaryFromKeysAndValues(string[] keys, JsonNode?[] values) =>
69+
keys.Zip(values, (k, v) => new { k, v }).ToDictionary(x => x.k, x => x.v);
70+
}

‎src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,11 @@ public enum ExpressionFunction
228228

229229
/// <summary>Create a list from the arguments.</summary>
230230
list,
231+
232+
/// <summary>
233+
/// Create a dictionary from the arguments, which must be alternating keys and values.
234+
/// </summary>
235+
#pragma warning disable CA1720
236+
@object,
237+
#pragma warning restore CA1720
231238
}

‎test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,11 @@ public async Task Divide_Theory(string testName, ExpressionTestCaseRoot.TestCase
232232
public async Task List_Theory(string testName, ExpressionTestCaseRoot.TestCaseItem testCaseItem) =>
233233
await RunTestCase(testName, new ExpressionTestCaseRoot(testCaseItem));
234234

235+
[Theory]
236+
[SharedTestCases("object")]
237+
public async Task Object_Theory(string testName, ExpressionTestCaseRoot.TestCaseItem testCaseItem) =>
238+
await RunTestCase(testName, new ExpressionTestCaseRoot(testCaseItem));
239+
235240
private static async Task<ExpressionTestCaseRoot> LoadTestCase(string file, string folder)
236241
{
237242
ExpressionTestCaseRoot testCase = new();
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"name": "Lookup a list",
3+
"layouts": {
4+
"Page1": {
5+
"$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json",
6+
"data": {
7+
"layout": [
8+
{
9+
"id": "some-component",
10+
"type": "Checkboxes",
11+
"dataModelBindings": {
12+
"simpleBinding": "chosenValues"
13+
},
14+
"options": [
15+
{
16+
"value": "a",
17+
"label": "id_KZuKMBAE5eel"
18+
},
19+
{
20+
"value": "b",
21+
"label": "id_Erk5v6GSNDu6"
22+
},
23+
{
24+
"value": "c",
25+
"label": "id_6S71AQBlLcPX"
26+
}
27+
]
28+
}
29+
]
30+
}
31+
}
32+
},
33+
"dataModel": {
34+
"chosenValues": ["a", "c"]
35+
},
36+
"expression": ["component", "some-component"],
37+
"expects": ["a", "c"]
38+
}

‎test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/lookup-list.json‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
"booleanList": [true, false],
1313
"nullList": [null, null],
1414
"multidimensionalList": [[[1, 2], [3, 4]], [[5, 6], [7, 8]]],
15+
"objectList": [{ "a": 1 }, { "a": 2 }],
1516
"emptyList": [],
16-
"differentTypesList": [1, "string", true, null, [null]],
17+
"differentTypesList": [1, "string", true, null, [null], { "a": null }],
1718
"listThatLooksLikeAnExpression": ["equals", 1, 1]
1819
}
1920
}
@@ -44,6 +45,11 @@
4445
"expression": ["dataModel", "multidimensionalList"],
4546
"expects": [[[1, 2], [3, 4]], [[5, 6], [7, 8]]]
4647
},
48+
{
49+
"name": "Object list lookup",
50+
"expression": ["dataModel", "objectList"],
51+
"expects": [{ "a": 1 }, { "a": 2 }]
52+
},
4753
{
4854
"name": "Empty list lookup",
4955
"expression": ["dataModel", "emptyList"],
@@ -52,7 +58,7 @@
5258
{
5359
"name": "Lookup of list with different types",
5460
"expression": ["dataModel", "differentTypesList"],
55-
"expects": [1, "string", true, null, [null]]
61+
"expects": [1, "string", true, null, [null], { "a": null }]
5662
},
5763
{
5864
"name": "Lookup of list that looks like an expression",

0 commit comments

Comments
 (0)