From e084500a3ccab3a69905752b95777c96266c1356 Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 27 May 2026 09:00:12 +0200 Subject: [PATCH 01/27] Support lists in expressions --- .../Expressions/ExpressionEvaluator.cs | 6 ++ .../Internal/Expressions/ExpressionValue.cs | 89 ++++++++++++------- .../Models/Expressions/ExpressionFunction.cs | 3 + .../CommonTests/ExpressionTestCaseRoot.cs | 2 +- .../TestBackendExclusiveFunctions.cs | 5 +- .../CommonTests/TestFunctions.cs | 30 ++++--- .../CommonTests/TestInvalid.cs | 7 +- .../functions/component/lookup-list.json | 38 ++++++++ .../functions/dataModel/array-is-null.json | 41 --------- .../functions/dataModel/lookup-list.json | 63 +++++++++++++ .../shared-tests/functions/list/list.json | 35 ++++++++ .../ExpressionEvaluatorTests/EqualsTests.cs | 2 - .../ExpressionValueTests.cs | 21 ++--- .../TestUtilities/DynamicClassBuilder.cs | 12 ++- ...ouldNotChange_Unintentionally.verified.txt | 4 + 15 files changed, 251 insertions(+), 107 deletions(-) create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/lookup-list.json delete mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/array-is-null.json create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/lookup-list.json create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/list/list.json diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index 8e17ee1cfb..f63f3e0186 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -131,6 +131,7 @@ internal static async Task EvaluateExpression_internal( ExpressionFunction.minus => Minus(args), ExpressionFunction.multiply => Multiply(args), ExpressionFunction.divide => Divide(args), + ExpressionFunction.list => List(args), ExpressionFunction.INVALID => throw new ExpressionEvaluatorTypeErrorException( $"Function {expr.Args.FirstOrDefault()} not implemented in backend {expr}" ), @@ -997,6 +998,11 @@ private static ExpressionValue Argv(ExpressionValue[] args, ExpressionValue[]? p return positionalArguments[index.Value]; } + private static ExpressionValue[] List(ExpressionValue[] args) + { + return args; + } + /// /// Performs arithmetic operation using decimal precision to avoid floating point precision issues. /// Converts doubles to decimal, performs the operation, and converts back to double. diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index ddf28e23ce..4ca0328016 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Diagnostics; using System.Globalization; using System.Text.Encodings.Web; @@ -19,7 +20,7 @@ namespace Altinn.App.Core.Internal.Expressions; private readonly double _numberValue = 0; // private readonly Dictionary? _objectValue = null; - // private readonly ExpressionValue[]? _arrayValue = null; + private readonly ExpressionValue[]? _arrayValue = null; /// /// Constructor for NULL value (structs require a public parameterless constructor) @@ -89,11 +90,12 @@ private ExpressionValue(string? value) // _objectValue = value; // } - // private ExpressionValue(ExpressionValue[]? value) - // { - // _valueKind = value is null ? JsonValueKind.Null : JsonValueKind.Array; - // _arrayValue = value; - // } + /// Constructor for array value + public ExpressionValue(ExpressionValue[] value) + { + ValueKind = JsonValueKind.Array; + _arrayValue = value; + } /// /// Convert a nullable boolean to ExpressionValue @@ -114,11 +116,11 @@ private ExpressionValue(string? value) // /// Convert a Dictionary to ExpressionValue // /// // public static implicit operator ExpressionValue(Dictionary? value) => new(value); - // - // /// - // /// Convert an array to ExpressionValue - // /// - // public static implicit operator ExpressionValue(ExpressionValue[]? value) => new(value); + + /// + /// Convert an array to ExpressionValue + /// + public static implicit operator ExpressionValue(ExpressionValue[] value) => new(value); /// /// Convert any of the supported CLR types to an expressionTypeUnion @@ -172,10 +174,23 @@ public static ExpressionValue FromObject(object? value) '"' ) // Trim quotes to match the string representation , + IEnumerable enumerableValue => enumerableValue.Cast().Select(FromObject).ToArray(), + JsonElement jsonElement => FromObject(JsonElementToObject(jsonElement)), _ => Null, }; } + private static object? JsonElementToObject(JsonElement jsonElement) => + jsonElement.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => jsonElement.GetString(), + JsonValueKind.Number => jsonElement.GetDouble(), + JsonValueKind.Array => jsonElement.EnumerateArray().Select(JsonElementToObject).ToArray(), + _ => null, + }; + /// /// Convert the value to the relevant CLR type /// @@ -190,7 +205,7 @@ public static ExpressionValue FromObject(object? value) JsonValueKind.String => String, JsonValueKind.Number => Number, // JsonValueKind.Object => Object, - // JsonValueKind.Array => Array, + JsonValueKind.Array => Array, _ => throw new InvalidOperationException("Invalid value kind"), }; @@ -244,15 +259,16 @@ public static ExpressionValue FromObject(object? value) // $"The .Object property can't be used on an expression value that represent a {_valueKind}" // ), // }; - // - // public ExpressionValue[] Array => - // _valueKind switch - // { - // JsonValueKind.Array => _arrayValue ?? throw new UnreachableException($"{this} is not an array"), - // _ => throw new InvalidCastException( - // $"The .Array property can't be used on an expression value that represent a {_valueKind}" - // ), - // }; + + /// Get the value as an array (or throw if it isn't an array ValueKind) + public ExpressionValue[] Array => + ValueKind switch + { + JsonValueKind.Array => _arrayValue ?? throw new UnreachableException($"{this} is not an array"), + _ => throw new InvalidCastException( + $"The .Array property can't be used on an expression value that represent a {ValueKind}" + ), + }; /// /// Get the value as it would be serialized to JSON @@ -267,7 +283,7 @@ public override string ToString() => JsonValueKind.String => JsonSerializer.Serialize(String, _unsafeSerializerOptionsForSerializingDates), JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), // JsonValueKind.Object => JsonSerializer.Serialize(Object), - // JsonValueKind.Array => JsonSerializer.Serialize(Array), + JsonValueKind.Array => JsonSerializer.Serialize(Array), _ => throw new InvalidOperationException($"Invalid value kind {ValueKind}"), }; @@ -289,7 +305,7 @@ public override string ToString() => JsonValueKind.String => String, JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), // JsonValueKind.Object => JsonSerializer.Serialize(Object), - // JsonValueKind.Array => JsonSerializer.Serialize(Array), + JsonValueKind.Array => JsonSerializer.Serialize(Array), _ => throw new InvalidOperationException($"Invalid value kind {ValueKind}"), }; @@ -317,7 +333,7 @@ public override string ToString() => }, JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), // JsonValueKind.Object => JsonSerializer.Serialize(Object), - // JsonValueKind.Array => JsonSerializer.Serialize(Array), + JsonValueKind.Array => JsonSerializer.Serialize(Array), _ => throw new NotImplementedException($"ToStringForEquals not implemented for {ValueKind}"), }; @@ -621,16 +637,23 @@ public override ExpressionValue Read(ref Utf8JsonReader reader, Type typeToConve JsonTokenType.Number => reader.GetDouble(), JsonTokenType.Null => ExpressionValue.Null, // JsonTokenType.StartObject => ReadObject(ref reader), - // JsonTokenType.StartArray => ReadArray(ref reader), + JsonTokenType.StartArray => ReadArray(ref reader, options), _ => throw new JsonException(), }; } - // private ExpressionValue ReadArray(ref Utf8JsonReader reader) - // { - // throw new NotImplementedException(); - // } - // + private static ExpressionValue ReadArray(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartArray) + { + throw new JsonException("Expected StartArray token."); + } + var values = + JsonSerializer.Deserialize>(ref reader, options) + ?? throw new JsonException("Expected EndArray token."); + return new ExpressionValue(values.ToArray()); + } + // private ExpressionValue ReadObject(ref Utf8JsonReader reader) // { // throw new NotImplementedException(); @@ -660,9 +683,9 @@ public override void Write(Utf8JsonWriter writer, ExpressionValue value, JsonSer // case JsonValueKind.Object: // JsonSerializer.Serialize(writer, value.Object, options); // break; - // case JsonValueKind.Array: - // JsonSerializer.Serialize(writer, value.Array, options); - // break; + case JsonValueKind.Array: + JsonSerializer.Serialize(writer, value.Array, options); + break; default: throw new JsonException(); } diff --git a/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs b/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs index 75d841ac41..66050fe549 100644 --- a/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs +++ b/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs @@ -225,4 +225,7 @@ public enum ExpressionFunction /// Divide numbers. Must be numeric values. /// divide, + + /// Create a list from the arguments. + list, } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs index 2a14aaab2d..0e114ecd1b 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs @@ -40,7 +40,7 @@ public ExpressionTestCaseRoot() { } public string? Name { get; set; } [JsonPropertyName("expression")] - public Expression Expression { get; set; } + public Expression? Expression { get; set; } [JsonPropertyName("context")] public ComponentContextForTestSpec? Context { get; set; } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs index 5ce7e4a049..f806afb1ff 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; @@ -90,7 +91,7 @@ private async Task RunTestCase(string testName, string folder) { await ExpressionEvaluator.EvaluateExpression( state, - test.Expression, + (Expression)test.Expression!, await test.GetContextOrNull(state) ); }; @@ -104,7 +105,7 @@ await test.GetContextOrNull(state) var result = await ExpressionEvaluator.EvaluateExpression( state, - test.Expression, + (Expression)test.Expression!, await test.GetContextOrNull(state)! ); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs index 0db83c6c7d..bf7a0181eb 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs @@ -227,6 +227,11 @@ public async Task Divide_Theory(string testName, ExpressionTestCaseRoot.TestCase [SharedTest("round")] public async Task Round_Theory(string testName, string folder) => await RunTestCase(testName, folder); + [Theory] + [SharedTestCases("list")] + public async Task List_Theory(string testName, ExpressionTestCaseRoot.TestCaseItem testCaseItem) => + await RunTestCase(testName, new ExpressionTestCaseRoot(testCaseItem)); + private static async Task LoadTestCase(string file, string folder) { ExpressionTestCaseRoot testCase = new(); @@ -382,17 +387,20 @@ private async Task RunTestCase(string testName, ExpressionTestCaseRoot test) test.ParsingException.Should().BeNull("Loading of test failed"); - await RunTestCaseItem( - new ExpressionTestCaseRoot.TestCaseItem() - { - Expects = test.Expects, - Expression = test.Expression, - ExpectsFailure = test.ExpectsFailure, - }, - state, - context, - positionalArguments - ); + if (test.Expression != null) + { + await RunTestCaseItem( + new ExpressionTestCaseRoot.TestCaseItem() + { + Expects = test.Expects, + Expression = (Expression)test.Expression, + ExpectsFailure = test.ExpectsFailure, + }, + state, + context, + positionalArguments + ); + } if (test.TestCases != null) { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs index c935f44554..fa7d24fe96 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; @@ -53,7 +54,11 @@ public async Task Simple_Theory(string testName, string folder) test.FrontEndSettings ?? new() ); - await ExpressionEvaluator.EvaluateExpression(state, test.Expression, await test.GetContextOrNull(state)); + await ExpressionEvaluator.EvaluateExpression( + state, + (Expression)test.Expression!, + await test.GetContextOrNull(state) + ); }; (await act.Should().ThrowAsync()).WithMessage(testCase.ExpectsFailure + "*"); } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/lookup-list.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/lookup-list.json new file mode 100644 index 0000000000..3f203d22dc --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/lookup-list.json @@ -0,0 +1,38 @@ +{ + "name": "Lookup a list", + "layouts": { + "Page1": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "some-component", + "type": "Checkboxes", + "dataModelBindings": { + "simpleBinding": "chosenValues" + }, + "options": [ + { + "value": "a", + "label": "id_KZuKMBAE5eel" + }, + { + "value": "b", + "label": "id_Erk5v6GSNDu6" + }, + { + "value": "c", + "label": "id_6S71AQBlLcPX" + } + ] + } + ] + } + } + }, + "dataModel": { + "chosenValues": ["a", "c"] + }, + "expression": ["component", "some-component"], + "expects": ["a", "c"] +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/array-is-null.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/array-is-null.json deleted file mode 100644 index 98c1d83434..0000000000 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/array-is-null.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "Looking up an array returns null", - "expression": ["dataModel", "a"], - "expects": null, - "dataModels": [ - { - "dataElement": { - "id": "00dd7417-5b4e-402a-bb73-007537071f1d", - "dataType": "default" - }, - "data": { - "a": [ - { - "value": "ABC", - "other": null - }, - { - "other": "DEF" - } - ] - } - } - ], - "layouts": { - "Page1": { - "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", - "data": { - "layout": [ - { - "id": "current-component", - "type": "Paragraph" - } - ] - } - } - }, - "context": { - "component": "current-component", - "currentLayout": "Page1" - } -} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/lookup-list.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/lookup-list.json new file mode 100644 index 0000000000..51bd0b4c55 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/lookup-list.json @@ -0,0 +1,63 @@ +{ + "name": "Lookup a list value", + "dataModels": [ + { + "dataElement": { + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", + "dataType": "default" + }, + "data": { + "numberList": [1, 2], + "stringList": ["Lorem", "ipsum"], + "booleanList": [true, false], + "nullList": [null, null], + "multidimensionalList": [[[1, 2], [3, 4]], [[5, 6], [7, 8]]], + "emptyList": [], + "differentTypesList": [1, "string", true, null, [null]], + "listThatLooksLikeAnExpression": ["equals", 1, 1] + } + } + ], + "testCases": [ + { + "name": "Number list lookup", + "expression": ["dataModel", "numberList"], + "expects": [1, 2] + }, + { + "name": "String list lookup", + "expression": ["dataModel", "stringList"], + "expects": ["Lorem", "ipsum"] + }, + { + "name": "Boolean list lookup", + "expression": ["dataModel", "booleanList"], + "expects": [true, false] + }, + { + "name": "Null list lookup", + "expression": ["dataModel", "nullList"], + "expects": [null, null] + }, + { + "name": "Multi-dimensional list lookup", + "expression": ["dataModel", "multidimensionalList"], + "expects": [[[1, 2], [3, 4]], [[5, 6], [7, 8]]] + }, + { + "name": "Empty list lookup", + "expression": ["dataModel", "emptyList"], + "expects": [] + }, + { + "name": "Lookup of list with different types", + "expression": ["dataModel", "differentTypesList"], + "expects": [1, "string", true, null, [null]] + }, + { + "name": "Lookup of list that looks like an expression", + "expression": ["dataModel", "listThatLooksLikeAnExpression"], + "expects": ["equals", 1, 1] + } + ] +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/list/list.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/list/list.json new file mode 100644 index 0000000000..01efa321cf --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/list/list.json @@ -0,0 +1,35 @@ +{ + "name": "List tests", + "testCases": [ + { + "name": "Returns the list", + "expression": ["list", 1, 2], + "expects": [1, 2] + }, + { + "name": "Supports empty lists", + "expression": ["list"], + "expects": [] + }, + { + "name": "Supports lists with different types", + "expression": ["list", 1, "string", true, null], + "expects": [1, "string", true, null] + }, + { + "name": "Supports nested lists", + "expression": ["list", ["list", 1, 2], ["list", 3, 4]], + "expects": [[1, 2], [3, 4]] + }, + { + "name": "Does not evaluate the final expression when the result happens to be a valid expression", + "expression": ["list", "equals", 1, 1], + "expects": ["equals", 1, 1] + }, + { + "name": "Evaluates expressions inside arguments", + "expression": ["list", ["equals", 1, 1]], + "expects": [true] + } + ] +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/EqualsTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/EqualsTests.cs index 938eb0373d..2a973e0484 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/EqualsTests.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/EqualsTests.cs @@ -90,7 +90,6 @@ public void ToStringForEquals_AgreesWithJsonSerializer(object? value) new() { new BigInteger(123), // Not supported by JsonSerializer, but might make sense to support - new object[] { 1, 2, 3 }, new object(), new { @@ -98,7 +97,6 @@ public void ToStringForEquals_AgreesWithJsonSerializer(object? value) B = 2, C = 3, }, - new byte[] { 0x01, 0x02, 0x03 }, }; [Theory] diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs index 3d29ac6e6e..d9ef86cc27 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs @@ -159,6 +159,10 @@ public void TestFromObject() [InlineData("true")] [InlineData("false")] [InlineData("\"test\"")] + [InlineData("[]")] + [InlineData("[1,2,3]")] + [InlineData("[[[1,2],[3,4]],[[5,6],[7,8]]]")] + [InlineData("[1,\"test\",true,null,[]]")] public void TestJsonParsing(string json) { ExpressionValue value = JsonSerializer.Deserialize(json); @@ -176,6 +180,7 @@ public void TestUndefined() Assert.Throws(() => undefinedValue.Bool); Assert.Throws(() => undefinedValue.Number); Assert.Throws(() => undefinedValue.String); + Assert.Throws(() => undefinedValue.Array); Assert.Equal("null", JsonSerializer.Serialize(undefinedValue)); Assert.Throws(() => undefinedValue.GetHashCode()); @@ -192,21 +197,7 @@ public void NullThrowsWhenAccessedAsDifferentType() Assert.Throws(() => _ = nullValue.Bool); Assert.Throws(() => _ = nullValue.Number); Assert.Throws(() => _ = nullValue.String); - } - - [Fact] - public void TestArraysFail() - { - // This is probably temporary - Assert.Throws(() => - { - JsonSerializer.Deserialize("[1, 2, 3]"); - }); - - Assert.Throws(() => - { - JsonSerializer.Deserialize("[\"test\"]"); - }); + Assert.Throws(() => _ = nullValue.Array); } [Fact] diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs index e9867db44c..9a664be3ea 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs @@ -109,7 +109,7 @@ private static Type GetTypeFromJsonElement(JsonElement element, string propertyN private static Type GetArrayType(JsonElement arrayElement, string propertyName, ModuleBuilder moduleBuilder) { - if (arrayElement.GetArrayLength() == 0) + if (arrayElement.GetArrayLength() == 0 || HasDifferentTypes(arrayElement)) { return typeof(object); } @@ -118,6 +118,16 @@ private static Type GetArrayType(JsonElement arrayElement, string propertyName, return GetTypeFromJsonElement(firstElement, propertyName + "Item", moduleBuilder); } + private static bool HasDifferentTypes(JsonElement arrayElement) + { + if (arrayElement.GetArrayLength() == 0) + { + return false; + } + var types = arrayElement.EnumerateArray().Select(element => element.ValueKind).ToArray(); + return types.Distinct().Count() > 1; + } + private static readonly JsonSerializerOptions _options = new JsonSerializerOptions() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index b52fc2c2e4..f957b151e3 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -3230,6 +3230,8 @@ namespace Altinn.App.Core.Internal.Expressions public readonly struct ExpressionValue : System.IEquatable { public ExpressionValue() { } + public ExpressionValue(Altinn.App.Core.Internal.Expressions.ExpressionValue[] value) { } + public Altinn.App.Core.Internal.Expressions.ExpressionValue[] Array { get; } public bool Bool { get; } public double Number { get; } public string String { get; } @@ -3249,6 +3251,7 @@ namespace Altinn.App.Core.Internal.Expressions public bool TryDeserialize(System.Type type, out object? result) { } public bool TryDeserialize(out T? result) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue FromObject(object? value) { } + public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(Altinn.App.Core.Internal.Expressions.ExpressionValue[] value) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(bool? value) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(double? value) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(string? value) { } @@ -4712,6 +4715,7 @@ namespace Altinn.App.Core.Models.Expressions minus = 38, multiply = 39, divide = 40, + list = 41, } } namespace Altinn.App.Core.Models.Layout.Components.Base From 4a88789bc97d53d8e4219aefa8d2693dce4caf6e Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 27 May 2026 11:47:23 +0200 Subject: [PATCH 02/27] Simplify ReadArray --- src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index 4ca0328016..0d35b93372 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -649,9 +649,9 @@ private static ExpressionValue ReadArray(ref Utf8JsonReader reader, JsonSerializ throw new JsonException("Expected StartArray token."); } var values = - JsonSerializer.Deserialize>(ref reader, options) + JsonSerializer.Deserialize(ref reader, options) ?? throw new JsonException("Expected EndArray token."); - return new ExpressionValue(values.ToArray()); + return new ExpressionValue(values); } // private ExpressionValue ReadObject(ref Utf8JsonReader reader) From 739cc4efeb6faafd2c95453d864d68040b9b8a4e Mon Sep 17 00:00:00 2001 From: Tomas Date: Fri, 29 May 2026 09:25:18 +0200 Subject: [PATCH 03/27] Revert irrelevant changes --- src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index 0d35b93372..6c9ca7255d 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -283,7 +283,7 @@ public override string ToString() => JsonValueKind.String => JsonSerializer.Serialize(String, _unsafeSerializerOptionsForSerializingDates), JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), // JsonValueKind.Object => JsonSerializer.Serialize(Object), - JsonValueKind.Array => JsonSerializer.Serialize(Array), + // JsonValueKind.Array => JsonSerializer.Serialize(Array), _ => throw new InvalidOperationException($"Invalid value kind {ValueKind}"), }; @@ -305,7 +305,7 @@ public override string ToString() => JsonValueKind.String => String, JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), // JsonValueKind.Object => JsonSerializer.Serialize(Object), - JsonValueKind.Array => JsonSerializer.Serialize(Array), + // JsonValueKind.Array => JsonSerializer.Serialize(Array), _ => throw new InvalidOperationException($"Invalid value kind {ValueKind}"), }; @@ -333,7 +333,7 @@ public override string ToString() => }, JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), // JsonValueKind.Object => JsonSerializer.Serialize(Object), - JsonValueKind.Array => JsonSerializer.Serialize(Array), + // JsonValueKind.Array => JsonSerializer.Serialize(Array), _ => throw new NotImplementedException($"ToStringForEquals not implemented for {ValueKind}"), }; From ab5d0b18781b110d1b5b67979bc7d1495358e105 Mon Sep 17 00:00:00 2001 From: Tomas Date: Thu, 28 May 2026 11:26:32 +0200 Subject: [PATCH 04/27] Support objects in expressions --- .../Expressions/ExpressionEvaluator.cs | 6 + .../Internal/Expressions/ExpressionValue.cs | 74 ++++++++----- .../Expressions/ObjectFunctionEvaluator.cs | 49 +++++++++ .../Models/Expressions/ExpressionFunction.cs | 5 + .../CommonTests/TestFunctions.cs | 5 + .../functions/dataModel/lookup-list.json | 10 +- .../functions/dataModel/lookup-object.json | 104 ++++++++++++++++++ .../functions/dataModel/object-is-null.json | 35 ------ .../shared-tests/functions/list/list.json | 5 + .../shared-tests/functions/object/object.json | 59 ++++++++++ .../ExpressionEvaluatorTests/EqualsTests.cs | 7 -- .../ExpressionValueTests.cs | 24 ++-- ...ouldNotChange_Unintentionally.verified.txt | 6 +- 13 files changed, 301 insertions(+), 88 deletions(-) create mode 100644 src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/lookup-object.json delete mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/object-is-null.json create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/object/object.json diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index f63f3e0186..8ab501150d 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -132,6 +132,7 @@ internal static async Task EvaluateExpression_internal( ExpressionFunction.multiply => Multiply(args), ExpressionFunction.divide => Divide(args), ExpressionFunction.list => List(args), + ExpressionFunction.@object => Object(args), ExpressionFunction.INVALID => throw new ExpressionEvaluatorTypeErrorException( $"Function {expr.Args.FirstOrDefault()} not implemented in backend {expr}" ), @@ -1003,6 +1004,11 @@ private static ExpressionValue[] List(ExpressionValue[] args) return args; } + private static Dictionary Object(ExpressionValue[] args) + { + return new ObjectFunctionEvaluator(args).Evaluate(); + } + /// /// Performs arithmetic operation using decimal precision to avoid floating point precision issues. /// Converts doubles to decimal, performs the operation, and converts back to double. diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index 6c9ca7255d..a911b14af8 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -19,7 +19,7 @@ namespace Altinn.App.Core.Internal.Expressions; // double is a value type where nullable takes extra space, and we only read it when it should be set private readonly double _numberValue = 0; - // private readonly Dictionary? _objectValue = null; + private readonly Dictionary? _objectValue = null; private readonly ExpressionValue[]? _arrayValue = null; /// @@ -84,11 +84,12 @@ private ExpressionValue(string? value) _stringValue = value; } - // private ExpressionValue(Dictionary? value) - // { - // _valueKind = value is null ? JsonValueKind.Null : JsonValueKind.Object; - // _objectValue = value; - // } + /// Constructor for object value + public ExpressionValue(Dictionary value) + { + ValueKind = JsonValueKind.Object; + _objectValue = value; + } /// Constructor for array value public ExpressionValue(ExpressionValue[] value) @@ -112,10 +113,10 @@ public ExpressionValue(ExpressionValue[] value) /// public static implicit operator ExpressionValue(string? value) => new(value); - // /// - // /// Convert a Dictionary to ExpressionValue - // /// - // public static implicit operator ExpressionValue(Dictionary? value) => new(value); + /// + /// Convert a Dictionary to ExpressionValue + /// + public static implicit operator ExpressionValue(Dictionary value) => new(value); /// /// Convert an array to ExpressionValue @@ -174,8 +175,16 @@ public static ExpressionValue FromObject(object? value) '"' ) // Trim quotes to match the string representation , + IDictionary dictionaryValue => dictionaryValue.ToDictionary( + entry => entry.Key, + entry => FromObject(entry.Value) + ), IEnumerable enumerableValue => enumerableValue.Cast().Select(FromObject).ToArray(), JsonElement jsonElement => FromObject(JsonElementToObject(jsonElement)), + _ when value.GetType().BaseType?.Name == "Object" => value + .GetType() + .GetProperties() + .ToDictionary(prop => prop.Name, prop => FromObject(prop.GetValue(value))), _ => Null, }; } @@ -188,6 +197,9 @@ public static ExpressionValue FromObject(object? value) JsonValueKind.String => jsonElement.GetString(), JsonValueKind.Number => jsonElement.GetDouble(), JsonValueKind.Array => jsonElement.EnumerateArray().Select(JsonElementToObject).ToArray(), + JsonValueKind.Object => jsonElement + .EnumerateObject() + .ToDictionary(prop => prop.Name, prop => JsonElementToObject(prop.Value)), _ => null, }; @@ -204,7 +216,7 @@ public static ExpressionValue FromObject(object? value) JsonValueKind.False => false, JsonValueKind.String => String, JsonValueKind.Number => Number, - // JsonValueKind.Object => Object, + JsonValueKind.Object => Dictionary, JsonValueKind.Array => Array, _ => throw new InvalidOperationException("Invalid value kind"), }; @@ -251,14 +263,15 @@ public static ExpressionValue FromObject(object? value) ), }; - // public Dictionary Object => - // _valueKind switch - // { - // JsonValueKind.Object => _objectValue ?? throw new UnreachableException($"{this} is not an object"), - // _ => throw new InvalidCastException( - // $"The .Object property can't be used on an expression value that represent a {_valueKind}" - // ), - // }; + /// Get the value as an object (or throw if it isn't an object ValueKind) + public Dictionary Dictionary => + ValueKind switch + { + JsonValueKind.Object => _objectValue ?? throw new UnreachableException($"{this} is not an object"), + _ => throw new InvalidCastException( + $"The .Object property can't be used on an expression value that represent a {ValueKind}" + ), + }; /// Get the value as an array (or throw if it isn't an array ValueKind) public ExpressionValue[] Array => @@ -636,7 +649,7 @@ public override ExpressionValue Read(ref Utf8JsonReader reader, Type typeToConve JsonTokenType.String => reader.GetString(), JsonTokenType.Number => reader.GetDouble(), JsonTokenType.Null => ExpressionValue.Null, - // JsonTokenType.StartObject => ReadObject(ref reader), + JsonTokenType.StartObject => ReadObject(ref reader, options), JsonTokenType.StartArray => ReadArray(ref reader, options), _ => throw new JsonException(), }; @@ -654,10 +667,17 @@ private static ExpressionValue ReadArray(ref Utf8JsonReader reader, JsonSerializ return new ExpressionValue(values); } - // private ExpressionValue ReadObject(ref Utf8JsonReader reader) - // { - // throw new NotImplementedException(); - // } + private static ExpressionValue ReadObject(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token."); + } + var values = + JsonSerializer.Deserialize>(ref reader, options) + ?? throw new JsonException("Expected EndObject token."); + return new ExpressionValue(values); + } /// public override void Write(Utf8JsonWriter writer, ExpressionValue value, JsonSerializerOptions options) @@ -680,9 +700,9 @@ public override void Write(Utf8JsonWriter writer, ExpressionValue value, JsonSer case JsonValueKind.Number: writer.WriteNumberValue(value.Number); break; - // case JsonValueKind.Object: - // JsonSerializer.Serialize(writer, value.Object, options); - // break; + case JsonValueKind.Object: + JsonSerializer.Serialize(writer, value.Dictionary, options); + break; case JsonValueKind.Array: JsonSerializer.Serialize(writer, value.Array, options); break; diff --git a/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs new file mode 100644 index 0000000000..e001993655 --- /dev/null +++ b/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs @@ -0,0 +1,49 @@ +namespace Altinn.App.Core.Internal.Expressions; + +internal class ObjectFunctionEvaluator +{ + private readonly ExpressionValue[] _args; + + public ObjectFunctionEvaluator(ExpressionValue[] args) => _args = args; + + public Dictionary Evaluate() + { + AssertEvenNumberOfArguments(); + string[] keys = ExtractKeys(); + AssertKeysAreUnique(keys); + ExpressionValue[] values = ExtractValues(); + return keys.Zip(values, (k, v) => new { k, v }).ToDictionary(x => x.k, x => x.v); + } + + private void AssertEvenNumberOfArguments() + { + if (_args.Length % 2 == 1) + { + throw new ExpressionEvaluatorTypeErrorException( + "The object function must have an even number of arguments." + ); + } + } + + private string[] ExtractKeys() + { + try + { + return _args.Where((_, index) => index % 2 == 0).Select(v => v.String).ToArray(); + } + catch (InvalidCastException) + { + throw new ExpressionEvaluatorTypeErrorException("Object keys must be strings."); + } + } + + private static void AssertKeysAreUnique(string[] keys) + { + if (keys.Length != keys.Distinct().Count()) + { + throw new ExpressionEvaluatorTypeErrorException("Object keys must be unique."); + } + } + + private ExpressionValue[] ExtractValues() => _args.Where((_, index) => index % 2 == 1).ToArray(); +} diff --git a/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs b/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs index 66050fe549..1cba5d5964 100644 --- a/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs +++ b/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs @@ -228,4 +228,9 @@ public enum ExpressionFunction /// Create a list from the arguments. list, + + /// Create a dictionary from the arguments, which must be alternating keys and values. +#pragma warning disable CA1720 + @object, +#pragma warning restore CA1720 } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs index 514199bf09..79109a168b 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs @@ -232,6 +232,11 @@ public async Task Divide_Theory(string testName, ExpressionTestCaseRoot.TestCase public async Task List_Theory(string testName, ExpressionTestCaseRoot.TestCaseItem testCaseItem) => await RunTestCase(testName, new ExpressionTestCaseRoot(testCaseItem)); + [Theory] + [SharedTestCases("object")] + public async Task Object_Theory(string testName, ExpressionTestCaseRoot.TestCaseItem testCaseItem) => + await RunTestCase(testName, new ExpressionTestCaseRoot(testCaseItem)); + private static async Task LoadTestCase(string file, string folder) { ExpressionTestCaseRoot testCase = new(); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/lookup-list.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/lookup-list.json index 51bd0b4c55..ac0d079a69 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/lookup-list.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/lookup-list.json @@ -12,8 +12,9 @@ "booleanList": [true, false], "nullList": [null, null], "multidimensionalList": [[[1, 2], [3, 4]], [[5, 6], [7, 8]]], + "objectList": [{ "a": 1 }, { "a": 2 }], "emptyList": [], - "differentTypesList": [1, "string", true, null, [null]], + "differentTypesList": [1, "string", true, null, [null], { "a": null }], "listThatLooksLikeAnExpression": ["equals", 1, 1] } } @@ -44,6 +45,11 @@ "expression": ["dataModel", "multidimensionalList"], "expects": [[[1, 2], [3, 4]], [[5, 6], [7, 8]]] }, + { + "name": "Object list lookup", + "expression": ["dataModel", "objectList"], + "expects": [{ "a": 1 }, { "a": 2 }] + }, { "name": "Empty list lookup", "expression": ["dataModel", "emptyList"], @@ -52,7 +58,7 @@ { "name": "Lookup of list with different types", "expression": ["dataModel", "differentTypesList"], - "expects": [1, "string", true, null, [null]] + "expects": [1, "string", true, null, [null], { "a": null }] }, { "name": "Lookup of list that looks like an expression", diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/lookup-object.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/lookup-object.json new file mode 100644 index 0000000000..be33e164fc --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/lookup-object.json @@ -0,0 +1,104 @@ +{ + "name": "Lookup an object", + "dataModels": [ + { + "dataElement": { + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", + "dataType": "default" + }, + "data": { + "simpleCity": { + "name": "Oslo", + "numberOfInhabitants": 724290, + "isCapital": true, + "largestVolcano": null + }, + "complexCity": { + "name": "Oslo", + "trainStations": [ + { + "name": "Oslo sentralstasjon", + "numberOfPlatforms": 19, + "isUnderground": false, + "expressTrains": ["F1", "F4", "F5", "F6"], + "connectedBusTerminal": "Oslo bussterminal" + }, + { + "name": "Nationaltheatret", + "numberOfPlatforms": 4, + "isUnderground": true, + "expressTrains": ["F5"], + "connectedBusTerminal": null + } + ] + } + } + } + ], + "testCases": [ + { + "name": "Simple object lookup", + "expression": ["dataModel", "simpleCity"], + "expects": { + "name": "Oslo", + "numberOfInhabitants": 724290, + "isCapital": true, + "largestVolcano": null + } + }, + { + "name": "Complex object lookup", + "expression": ["dataModel", "complexCity"], + "expects": { + "name": "Oslo", + "trainStations": [ + { + "name": "Oslo sentralstasjon", + "numberOfPlatforms": 19, + "isUnderground": false, + "expressTrains": ["F1", "F4", "F5", "F6"], + "connectedBusTerminal": "Oslo bussterminal" + }, + { + "name": "Nationaltheatret", + "numberOfPlatforms": 4, + "isUnderground": true, + "expressTrains": ["F5"], + "connectedBusTerminal": null + } + ] + } + }, + { + "name": "Query returning an object", + "expression": ["dataModel", "complexCity.trainStations[0]"], + "expects": { + "name": "Oslo sentralstasjon", + "numberOfPlatforms": 19, + "isUnderground": false, + "expressTrains": ["F1", "F4", "F5", "F6"], + "connectedBusTerminal": "Oslo bussterminal" + } + }, + { + "name": "Query returning a list of objects", + "expression": ["dataModel", "complexCity.trainStations"], + "expects": [ + { + "name": "Oslo sentralstasjon", + "numberOfPlatforms": 19, + "isUnderground": false, + "expressTrains": ["F1", "F4", "F5", "F6"], + "connectedBusTerminal": "Oslo bussterminal" + }, + { + "name": "Nationaltheatret", + "numberOfPlatforms": 4, + "isUnderground": true, + "expressTrains": ["F5"], + "connectedBusTerminal": null + } + ] + } + ] +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/object-is-null.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/object-is-null.json deleted file mode 100644 index 7b520e9ef6..0000000000 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/object-is-null.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "Looking up an object returns null", - "expression": ["dataModel", "a"], - "expects": null, - "dataModels": [ - { - "dataElement": { - "id": "00dd7417-5b4e-402a-bb73-007537071f1d", - "dataType": "default" - }, - "data": { - "a": { - "value": "ABC" - } - } - } - ], - "layouts": { - "Page1": { - "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", - "data": { - "layout": [ - { - "id": "current-component", - "type": "Paragraph" - } - ] - } - } - }, - "context": { - "component": "current-component", - "currentLayout": "Page1" - } -} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/list/list.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/list/list.json index 01efa321cf..5103e81ac4 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/list/list.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/list/list.json @@ -21,6 +21,11 @@ "expression": ["list", ["list", 1, 2], ["list", 3, 4]], "expects": [[1, 2], [3, 4]] }, + { + "name": "Supports objects", + "expression": ["list", ["object", "a", 1], ["object", "b", 2]], + "expects": [{ "a": 1 }, { "b": 2 }] + }, { "name": "Does not evaluate the final expression when the result happens to be a valid expression", "expression": ["list", "equals", 1, 1], diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/object/object.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/object/object.json new file mode 100644 index 0000000000..0ebf3d3c30 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/object/object.json @@ -0,0 +1,59 @@ +{ + "name": "Object tests", + "testCases": [ + { + "name": "Creates an object from its arguments", + "expression": [ + "object", + "name", + "Oslo", + "numberOfInhabitants", + 724290, + "isCapital", + true, + "largestVolcano", + null + ], + "expects": { + "name": "Oslo", + "numberOfInhabitants": 724290, + "isCapital": true, + "largestVolcano": null + } + }, + { + "name": "Supports empty objects", + "expression": ["object"], + "expects": {} + }, + { + "name": "Supports nested objects", + "expression": ["object", "city", ["object", "name", "Oslo"]], + "expects": { + "city": { "name": "Oslo" } + } + }, + { + "name": "Supports lists", + "expression": ["object", "cities", ["list", "Oslo", "Leikanger", "Brønnøysund"]], + "expects": { + "cities": ["Oslo", "Leikanger", "Brønnøysund"] + } + }, + { + "name": "Fails when an odd number of arguments is passed", + "expression": ["object", "name", "Oslo", "numberOfInhabitants"], + "expectsFailure": "The object function must have an even number of arguments" + }, + { + "name": "Fails when a value that is not a string is used as key", + "expression": ["object", 1, "a"], + "expectsFailure": "Object keys must be strings" + }, + { + "name": "Fails when there are duplicate keys", + "expression": ["object", "name", "Oslo", "numberOfInhabitants", 724290, "name", "Christiania"], + "expectsFailure": "Object keys must be unique" + } + ] +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/EqualsTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/EqualsTests.cs index 2a973e0484..8a25e96945 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/EqualsTests.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/EqualsTests.cs @@ -90,13 +90,6 @@ public void ToStringForEquals_AgreesWithJsonSerializer(object? value) new() { new BigInteger(123), // Not supported by JsonSerializer, but might make sense to support - new object(), - new - { - A = 1, - B = 2, - C = 3, - }, }; [Theory] diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs index d9ef86cc27..8940d9a082 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs @@ -162,7 +162,12 @@ public void TestFromObject() [InlineData("[]")] [InlineData("[1,2,3]")] [InlineData("[[[1,2],[3,4]],[[5,6],[7,8]]]")] - [InlineData("[1,\"test\",true,null,[]]")] + [InlineData("[1,\"test\",true,null,[],{}]")] + [InlineData("{}")] + [InlineData("{\"a\":1,\"b\":\"test\",\"c\":true,\"d\":null,\"e\":[]}")] + [InlineData("{\"a\":{\"b\":1}}")] + [InlineData("{\"a\":[1,2,3]}")] + [InlineData("[{\"a\":1},{\"b\":2}]")] public void TestJsonParsing(string json) { ExpressionValue value = JsonSerializer.Deserialize(json); @@ -181,6 +186,7 @@ public void TestUndefined() Assert.Throws(() => undefinedValue.Number); Assert.Throws(() => undefinedValue.String); Assert.Throws(() => undefinedValue.Array); + Assert.Throws(() => undefinedValue.Dictionary); Assert.Equal("null", JsonSerializer.Serialize(undefinedValue)); Assert.Throws(() => undefinedValue.GetHashCode()); @@ -198,21 +204,7 @@ public void NullThrowsWhenAccessedAsDifferentType() Assert.Throws(() => _ = nullValue.Number); Assert.Throws(() => _ = nullValue.String); Assert.Throws(() => _ = nullValue.Array); - } - - [Fact] - public void TestObjectsFail() - { - // This is probably temporary - Assert.Throws(() => - { - JsonSerializer.Deserialize("{\"key\": \"value\"}"); - }); - - Assert.Throws(() => - { - JsonSerializer.Deserialize("{\"key\": 123}"); - }); + Assert.Throws(() => _ = nullValue.Dictionary); } [Fact] diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index a14210df36..1a0ecb2e8a 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -3229,13 +3229,15 @@ namespace Altinn.App.Core.Internal.Expressions public ExpressionEvaluatorTypeErrorException(string msg, System.Exception innerException) { } } [System.Diagnostics.DebuggerDisplay("{ToString(),nq}")] - [System.Text.Json.Serialization.JsonConverter(typeof(Altinn.App.Core.Internal.Expressions.ExpressionTypeUnionConverter?))] + [System.Text.Json.Serialization.JsonConverter(typeof(Altinn.App.Core.Internal.Expressions.ExpressionTypeUnionConverter))] public readonly struct ExpressionValue : System.IEquatable { public ExpressionValue() { } public ExpressionValue(Altinn.App.Core.Internal.Expressions.ExpressionValue[] value) { } + public ExpressionValue(System.Collections.Generic.Dictionary value) { } public Altinn.App.Core.Internal.Expressions.ExpressionValue[] Array { get; } public bool Bool { get; } + public System.Collections.Generic.Dictionary Dictionary { get; } public double Number { get; } public string String { get; } public System.Text.Json.JsonValueKind ValueKind { get; } @@ -3255,6 +3257,7 @@ namespace Altinn.App.Core.Internal.Expressions public bool TryDeserialize(out T? result) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue FromObject(object? value) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(Altinn.App.Core.Internal.Expressions.ExpressionValue[] value) { } + public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(System.Collections.Generic.Dictionary value) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(bool? value) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(double? value) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(string? value) { } @@ -4721,6 +4724,7 @@ namespace Altinn.App.Core.Models.Expressions multiply = 39, divide = 40, list = 41, + @object = 42, } } namespace Altinn.App.Core.Models.Layout.Components.Base From f24f67b6c936d395132fbe4a7c6dc8613b1e3d6f Mon Sep 17 00:00:00 2001 From: Tomas Date: Mon, 1 Jun 2026 07:43:29 +0200 Subject: [PATCH 05/27] Use JsonArray --- .../Expressions/ExpressionEvaluator.cs | 5 ++-- .../Internal/Expressions/ExpressionValue.cs | 24 +++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index f63f3e0186..13b5f92f00 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Globalization; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.RegularExpressions; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; @@ -998,9 +999,9 @@ private static ExpressionValue Argv(ExpressionValue[] args, ExpressionValue[]? p return positionalArguments[index.Value]; } - private static ExpressionValue[] List(ExpressionValue[] args) + private static JsonArray List(ExpressionValue[] args) { - return args; + return new JsonArray(args.Select(a => JsonSerializer.SerializeToNode(a)).ToArray()); } /// diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index 6c9ca7255d..751da50f91 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace Altinn.App.Core.Internal.Expressions; @@ -20,7 +21,7 @@ namespace Altinn.App.Core.Internal.Expressions; private readonly double _numberValue = 0; // private readonly Dictionary? _objectValue = null; - private readonly ExpressionValue[]? _arrayValue = null; + private readonly JsonArray? _arrayValue = null; /// /// Constructor for NULL value (structs require a public parameterless constructor) @@ -91,7 +92,7 @@ private ExpressionValue(string? value) // } /// Constructor for array value - public ExpressionValue(ExpressionValue[] value) + public ExpressionValue(JsonArray value) { ValueKind = JsonValueKind.Array; _arrayValue = value; @@ -120,7 +121,7 @@ public ExpressionValue(ExpressionValue[] value) /// /// Convert an array to ExpressionValue /// - public static implicit operator ExpressionValue(ExpressionValue[] value) => new(value); + public static implicit operator ExpressionValue(JsonArray value) => new(value); /// /// Convert any of the supported CLR types to an expressionTypeUnion @@ -174,8 +175,17 @@ public static ExpressionValue FromObject(object? value) '"' ) // Trim quotes to match the string representation , - IEnumerable enumerableValue => enumerableValue.Cast().Select(FromObject).ToArray(), - JsonElement jsonElement => FromObject(JsonElementToObject(jsonElement)), + JsonArray jsonArrayValue => jsonArrayValue, + _ => ToJsonArrayOrNull(value), + }; + } + + private static ExpressionValue ToJsonArrayOrNull(object? value) + { + var node = JsonSerializer.SerializeToNode(value); + return node switch + { + JsonArray jsonArray => jsonArray, _ => Null, }; } @@ -261,7 +271,7 @@ public static ExpressionValue FromObject(object? value) // }; /// Get the value as an array (or throw if it isn't an array ValueKind) - public ExpressionValue[] Array => + public JsonArray Array => ValueKind switch { JsonValueKind.Array => _arrayValue ?? throw new UnreachableException($"{this} is not an array"), @@ -649,7 +659,7 @@ private static ExpressionValue ReadArray(ref Utf8JsonReader reader, JsonSerializ throw new JsonException("Expected StartArray token."); } var values = - JsonSerializer.Deserialize(ref reader, options) + JsonSerializer.Deserialize(ref reader, options) ?? throw new JsonException("Expected EndArray token."); return new ExpressionValue(values); } From 20dbd1120b51d92538eab6983f6a12a541ed03ac Mon Sep 17 00:00:00 2001 From: Tomas Date: Mon, 1 Jun 2026 07:47:04 +0200 Subject: [PATCH 06/27] Update public API test file --- ...s.PublicApi_ShouldNotChange_Unintentionally.verified.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index a14210df36..849412f527 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -3233,8 +3233,8 @@ namespace Altinn.App.Core.Internal.Expressions public readonly struct ExpressionValue : System.IEquatable { public ExpressionValue() { } - public ExpressionValue(Altinn.App.Core.Internal.Expressions.ExpressionValue[] value) { } - public Altinn.App.Core.Internal.Expressions.ExpressionValue[] Array { get; } + public ExpressionValue(System.Text.Json.Nodes.JsonArray value) { } + public System.Text.Json.Nodes.JsonArray Array { get; } public bool Bool { get; } public double Number { get; } public string String { get; } @@ -3254,7 +3254,7 @@ namespace Altinn.App.Core.Internal.Expressions public bool TryDeserialize(System.Type type, out object? result) { } public bool TryDeserialize(out T? result) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue FromObject(object? value) { } - public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(Altinn.App.Core.Internal.Expressions.ExpressionValue[] value) { } + public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(System.Text.Json.Nodes.JsonArray value) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(bool? value) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(double? value) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(string? value) { } From c495caabbfdc82870e8afbc3ee44548e1cfeb71a Mon Sep 17 00:00:00 2001 From: Tomas Date: Mon, 1 Jun 2026 07:48:36 +0200 Subject: [PATCH 07/27] Remove unused code --- .../Internal/Expressions/ExpressionValue.cs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index 751da50f91..113141fc2d 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -1,4 +1,3 @@ -using System.Collections; using System.Diagnostics; using System.Globalization; using System.Text.Encodings.Web; @@ -190,17 +189,6 @@ private static ExpressionValue ToJsonArrayOrNull(object? value) }; } - private static object? JsonElementToObject(JsonElement jsonElement) => - jsonElement.ValueKind switch - { - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.String => jsonElement.GetString(), - JsonValueKind.Number => jsonElement.GetDouble(), - JsonValueKind.Array => jsonElement.EnumerateArray().Select(JsonElementToObject).ToArray(), - _ => null, - }; - /// /// Convert the value to the relevant CLR type /// From 3b5d16549726f00b3ac47a94941dce5a4fb273fa Mon Sep 17 00:00:00 2001 From: Tomas Date: Mon, 1 Jun 2026 07:57:10 +0200 Subject: [PATCH 08/27] Use correct value in tests --- .../CommonTests/TestBackendExclusiveFunctions.cs | 5 ++--- .../LayoutExpressions/CommonTests/TestInvalid.cs | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs index e722ab9b76..89d960d734 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Internal.Texts; -using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; @@ -94,7 +93,7 @@ private async Task RunTestCase(string testName, string folder) { await ExpressionEvaluator.EvaluateExpression( state, - (Expression)test.Expression!, + test.Expression!.Value, await test.GetContextOrNull(state) ); }; @@ -108,7 +107,7 @@ await test.GetContextOrNull(state) var result = await ExpressionEvaluator.EvaluateExpression( state, - (Expression)test.Expression!, + test.Expression!.Value, await test.GetContextOrNull(state)! ); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs index 9dff3406a3..5c082003e6 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Internal.Texts; -using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; @@ -62,7 +61,7 @@ public async Task Simple_Theory(string testName, string folder) await ExpressionEvaluator.EvaluateExpression( state, - (Expression)test.Expression!, + test.Expression!.Value, await test.GetContextOrNull(state) ); }; From a5fa3757788858623884ef5ca8eb5b84b1b21ec1 Mon Sep 17 00:00:00 2001 From: Tomas Date: Mon, 1 Jun 2026 09:16:18 +0200 Subject: [PATCH 09/27] Use JsonObject --- .../Expressions/ExpressionEvaluator.cs | 2 +- .../Internal/Expressions/ExpressionValue.cs | 51 ++++++------------- .../Expressions/ObjectFunctionEvaluator.cs | 18 +++++-- ...ouldNotChange_Unintentionally.verified.txt | 6 +-- 4 files changed, 33 insertions(+), 44 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index 6d69479433..5570dca794 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -1005,7 +1005,7 @@ private static JsonArray List(ExpressionValue[] args) return new JsonArray(args.Select(a => JsonSerializer.SerializeToNode(a)).ToArray()); } - private static Dictionary Object(ExpressionValue[] args) + private static JsonObject Object(ExpressionValue[] args) { return new ObjectFunctionEvaluator(args).Evaluate(); } diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index c0dc0dd347..f2ca6354ae 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Globalization; +using System.Numerics; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; @@ -19,7 +20,7 @@ namespace Altinn.App.Core.Internal.Expressions; // double is a value type where nullable takes extra space, and we only read it when it should be set private readonly double _numberValue = 0; - private readonly Dictionary? _objectValue = null; + private readonly JsonObject? _objectValue = null; private readonly JsonArray? _arrayValue = null; /// @@ -85,7 +86,7 @@ private ExpressionValue(string? value) } /// Constructor for object value - public ExpressionValue(Dictionary value) + public ExpressionValue(JsonObject value) { ValueKind = JsonValueKind.Object; _objectValue = value; @@ -116,7 +117,7 @@ public ExpressionValue(JsonArray value) /// /// Convert a Dictionary to ExpressionValue /// - public static implicit operator ExpressionValue(Dictionary value) => new(value); + public static implicit operator ExpressionValue(JsonObject value) => new(value); /// /// Convert an array to ExpressionValue @@ -175,44 +176,24 @@ public static ExpressionValue FromObject(object? value) '"' ) // Trim quotes to match the string representation , - JsonArray jsonArrayValue => jsonArrayValue, - IDictionary dictionaryValue => dictionaryValue.ToDictionary( - entry => entry.Key, - entry => FromObject(entry.Value) - ), - JsonElement jsonElement => FromObject(JsonElementToObject(jsonElement)), - _ when value.GetType().BaseType?.Name == "Object" => value - .GetType() - .GetProperties() - .ToDictionary(prop => prop.Name, prop => FromObject(prop.GetValue(value))), - _ => ToJsonArrayOrNull(value), + BigInteger => Null, + JsonObject jsonObject => jsonObject, + JsonArray jsonArray => jsonArray, + _ => ToJsonNodeOrNull(value), }; } - private static ExpressionValue ToJsonArrayOrNull(object? value) + private static ExpressionValue ToJsonNodeOrNull(object? value) { var node = JsonSerializer.SerializeToNode(value); return node switch { JsonArray jsonArray => jsonArray, + JsonObject jsonObject => jsonObject, _ => Null, }; } - private static object? JsonElementToObject(JsonElement jsonElement) => - jsonElement.ValueKind switch - { - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.String => jsonElement.GetString(), - JsonValueKind.Number => jsonElement.GetDouble(), - JsonValueKind.Array => jsonElement.EnumerateArray().Select(JsonElementToObject).ToArray(), - JsonValueKind.Object => jsonElement - .EnumerateObject() - .ToDictionary(prop => prop.Name, prop => JsonElementToObject(prop.Value)), - _ => null, - }; - /// /// Convert the value to the relevant CLR type /// @@ -274,7 +255,7 @@ private static ExpressionValue ToJsonArrayOrNull(object? value) }; /// Get the value as an object (or throw if it isn't an object ValueKind) - public Dictionary Dictionary => + public JsonObject Dictionary => ValueKind switch { JsonValueKind.Object => _objectValue ?? throw new UnreachableException($"{this} is not an object"), @@ -305,8 +286,8 @@ public override string ToString() => JsonValueKind.False => "false", JsonValueKind.String => JsonSerializer.Serialize(String, _unsafeSerializerOptionsForSerializingDates), JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), - // JsonValueKind.Object => JsonSerializer.Serialize(Object), - // JsonValueKind.Array => JsonSerializer.Serialize(Array), + JsonValueKind.Object => JsonSerializer.Serialize(Dictionary), + JsonValueKind.Array => JsonSerializer.Serialize(Array), _ => throw new InvalidOperationException($"Invalid value kind {ValueKind}"), }; @@ -355,8 +336,8 @@ public override string ToString() => { } sValue => sValue, }, JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), - // JsonValueKind.Object => JsonSerializer.Serialize(Object), - // JsonValueKind.Array => JsonSerializer.Serialize(Array), + JsonValueKind.Object => JsonSerializer.Serialize(Dictionary), + JsonValueKind.Array => JsonSerializer.Serialize(Array), _ => throw new NotImplementedException($"ToStringForEquals not implemented for {ValueKind}"), }; @@ -684,7 +665,7 @@ private static ExpressionValue ReadObject(ref Utf8JsonReader reader, JsonSeriali throw new JsonException("Expected StartObject token."); } var values = - JsonSerializer.Deserialize>(ref reader, options) + JsonSerializer.Deserialize(ref reader, options) ?? throw new JsonException("Expected EndObject token."); return new ExpressionValue(values); } diff --git a/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs index e001993655..084c47a8f9 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs @@ -1,4 +1,7 @@ -namespace Altinn.App.Core.Internal.Expressions; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Altinn.App.Core.Internal.Expressions; internal class ObjectFunctionEvaluator { @@ -6,13 +9,14 @@ internal class ObjectFunctionEvaluator public ObjectFunctionEvaluator(ExpressionValue[] args) => _args = args; - public Dictionary Evaluate() + public JsonObject Evaluate() { AssertEvenNumberOfArguments(); string[] keys = ExtractKeys(); AssertKeysAreUnique(keys); - ExpressionValue[] values = ExtractValues(); - return keys.Zip(values, (k, v) => new { k, v }).ToDictionary(x => x.k, x => x.v); + JsonNode?[] values = ExtractValues(); + Dictionary keyValuePairs = DictionaryFromKeysAndValues(keys, values); + return new JsonObject(keyValuePairs); } private void AssertEvenNumberOfArguments() @@ -45,5 +49,9 @@ private static void AssertKeysAreUnique(string[] keys) } } - private ExpressionValue[] ExtractValues() => _args.Where((_, index) => index % 2 == 1).ToArray(); + private JsonNode?[] ExtractValues() => + _args.Where((_, index) => index % 2 == 1).Select(v => JsonSerializer.SerializeToNode(v)).ToArray(); + + private static Dictionary DictionaryFromKeysAndValues(string[] keys, JsonNode?[] values) => + keys.Zip(values, (k, v) => new { k, v }).ToDictionary(x => x.k, x => x.v); } diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index 07a1c4b936..e86317f85b 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -3233,11 +3233,11 @@ namespace Altinn.App.Core.Internal.Expressions public readonly struct ExpressionValue : System.IEquatable { public ExpressionValue() { } - public ExpressionValue(System.Collections.Generic.Dictionary value) { } public ExpressionValue(System.Text.Json.Nodes.JsonArray value) { } + public ExpressionValue(System.Text.Json.Nodes.JsonObject value) { } public System.Text.Json.Nodes.JsonArray Array { get; } public bool Bool { get; } - public System.Collections.Generic.Dictionary Dictionary { get; } + public System.Text.Json.Nodes.JsonObject Dictionary { get; } public double Number { get; } public string String { get; } public System.Text.Json.JsonValueKind ValueKind { get; } @@ -3257,7 +3257,7 @@ namespace Altinn.App.Core.Internal.Expressions public bool TryDeserialize(out T? result) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue FromObject(object? value) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(System.Text.Json.Nodes.JsonArray value) { } - public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(System.Collections.Generic.Dictionary value) { } + public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(System.Text.Json.Nodes.JsonObject value) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(bool? value) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(double? value) { } public static Altinn.App.Core.Internal.Expressions.ExpressionValue op_Implicit(string? value) { } From d14e010bdb674f9782e8ae092ecc4103e47a0fe8 Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 3 Jun 2026 07:59:20 +0200 Subject: [PATCH 10/27] Seal ObjectFunctionEvaluator --- .../Internal/Expressions/ObjectFunctionEvaluator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs index 084c47a8f9..9e511ebb69 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs @@ -3,7 +3,7 @@ namespace Altinn.App.Core.Internal.Expressions; -internal class ObjectFunctionEvaluator +internal sealed class ObjectFunctionEvaluator { private readonly ExpressionValue[] _args; From 63354588425faeb2d3a24e0fa80f31b9f5806c1d Mon Sep 17 00:00:00 2001 From: Tomas Engebretsen Date: Wed, 10 Jun 2026 08:27:19 +0200 Subject: [PATCH 11/27] Update src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs Co-authored-by: olavsorl --- src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index f2ca6354ae..cca6652f54 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -85,7 +85,9 @@ private ExpressionValue(string? value) _stringValue = value; } - /// Constructor for object value + /// + /// Constructor for object value + /// public ExpressionValue(JsonObject value) { ValueKind = JsonValueKind.Object; From 1b629f701250d254d20b7473dc219d7dbea4c645 Mon Sep 17 00:00:00 2001 From: Tomas Engebretsen Date: Wed, 10 Jun 2026 08:27:35 +0200 Subject: [PATCH 12/27] Update src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs Co-authored-by: olavsorl --- src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index cca6652f54..71bf9f9656 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -256,7 +256,9 @@ private static ExpressionValue ToJsonNodeOrNull(object? value) ), }; - /// Get the value as an object (or throw if it isn't an object ValueKind) + /// + /// Get the value as an object (or throw if it isn't an object ValueKind) + /// public JsonObject Dictionary => ValueKind switch { From 4f08e2b6ccd9eb5ddb839059697b50643fac8a3b Mon Sep 17 00:00:00 2001 From: Tomas Engebretsen Date: Wed, 10 Jun 2026 08:27:53 +0200 Subject: [PATCH 13/27] Update src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs Co-authored-by: olavsorl --- src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs b/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs index 1cba5d5964..7068b97d96 100644 --- a/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs +++ b/src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs @@ -229,7 +229,9 @@ public enum ExpressionFunction /// Create a list from the arguments. list, - /// Create a dictionary from the arguments, which must be alternating keys and values. + /// + /// Create a dictionary from the arguments, which must be alternating keys and values. + /// #pragma warning disable CA1720 @object, #pragma warning restore CA1720 From e4a90c0ffc20c81451a5a2604e0692a997fbf243 Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 10 Jun 2026 08:53:28 +0200 Subject: [PATCH 14/27] Revert uncomment of unused code --- src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index 71bf9f9656..be1b19dc51 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -340,8 +340,8 @@ public override string ToString() => { } sValue => sValue, }, JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), - JsonValueKind.Object => JsonSerializer.Serialize(Dictionary), - JsonValueKind.Array => JsonSerializer.Serialize(Array), + // JsonValueKind.Object => JsonSerializer.Serialize(Object), + // JsonValueKind.Array => JsonSerializer.Serialize(Array), _ => throw new NotImplementedException($"ToStringForEquals not implemented for {ValueKind}"), }; From df8320c900be0a5b45006416f527c942f2d603a4 Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 10 Jun 2026 09:14:16 +0200 Subject: [PATCH 15/27] Refactor ObjectFunctionEvaluator --- .../Internal/Expressions/ObjectFunctionEvaluator.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs index 9e511ebb69..eca4bae62c 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs @@ -33,7 +33,7 @@ private string[] ExtractKeys() { try { - return _args.Where((_, index) => index % 2 == 0).Select(v => v.String).ToArray(); + return ExtractEvenIndexedArguments().Select(v => v.String).ToArray(); } catch (InvalidCastException) { @@ -41,6 +41,8 @@ private string[] ExtractKeys() } } + private ExpressionValue[] ExtractEvenIndexedArguments() => _args.Where((_, index) => index % 2 == 0).ToArray(); + private static void AssertKeysAreUnique(string[] keys) { if (keys.Length != keys.Distinct().Count()) @@ -50,7 +52,9 @@ private static void AssertKeysAreUnique(string[] keys) } private JsonNode?[] ExtractValues() => - _args.Where((_, index) => index % 2 == 1).Select(v => JsonSerializer.SerializeToNode(v)).ToArray(); + ExtractOddIndexedArguments().Select(v => JsonSerializer.SerializeToNode(v)).ToArray(); + + private ExpressionValue[] ExtractOddIndexedArguments() => _args.Where((_, index) => index % 2 == 1).ToArray(); private static Dictionary DictionaryFromKeysAndValues(string[] keys, JsonNode?[] values) => keys.Zip(values, (k, v) => new { k, v }).ToDictionary(x => x.k, x => x.v); From 2b2a86b7d92927af919ddc9e3924903e4f4dba16 Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 10 Jun 2026 09:16:42 +0200 Subject: [PATCH 16/27] Rename variable --- src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index be1b19dc51..790f7ef08b 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -668,10 +668,10 @@ private static ExpressionValue ReadObject(ref Utf8JsonReader reader, JsonSeriali { throw new JsonException("Expected StartObject token."); } - var values = + var value = JsonSerializer.Deserialize(ref reader, options) ?? throw new JsonException("Expected EndObject token."); - return new ExpressionValue(values); + return new ExpressionValue(value); } /// From 4568156b04c3a140c7e4c3bdf4d3e079e9af684e Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 10 Jun 2026 09:17:51 +0200 Subject: [PATCH 17/27] Correct error message --- src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index 790f7ef08b..1a0b6433dd 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -670,7 +670,7 @@ private static ExpressionValue ReadObject(ref Utf8JsonReader reader, JsonSeriali } var value = JsonSerializer.Deserialize(ref reader, options) - ?? throw new JsonException("Expected EndObject token."); + ?? throw new JsonException("Expected JSON object value."); return new ExpressionValue(value); } From 1ee183c56962d7e7791a902674e66a8d0f36f95b Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 10 Jun 2026 11:32:48 +0200 Subject: [PATCH 18/27] Correct error message --- src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index 113141fc2d..4d34fe3202 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -648,7 +648,7 @@ private static ExpressionValue ReadArray(ref Utf8JsonReader reader, JsonSerializ } var values = JsonSerializer.Deserialize(ref reader, options) - ?? throw new JsonException("Expected EndArray token."); + ?? throw new JsonException("Expected JSON array value."); return new ExpressionValue(values); } From 44ea2b4c29354d40f063b9bd82c325aa77e32229 Mon Sep 17 00:00:00 2001 From: Tomas Date: Thu, 11 Jun 2026 14:21:27 +0200 Subject: [PATCH 19/27] Return ExpressionValue from List --- src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index 13b5f92f00..2288020b37 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -999,7 +999,7 @@ private static ExpressionValue Argv(ExpressionValue[] args, ExpressionValue[]? p return positionalArguments[index.Value]; } - private static JsonArray List(ExpressionValue[] args) + private static ExpressionValue List(ExpressionValue[] args) { return new JsonArray(args.Select(a => JsonSerializer.SerializeToNode(a)).ToArray()); } From d794fcf2c72e0beee0f2d177b85871a7704d0c4e Mon Sep 17 00:00:00 2001 From: Tomas Date: Fri, 12 Jun 2026 09:43:25 +0200 Subject: [PATCH 20/27] Uncomment code in ToStringForText --- src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index 4d34fe3202..2b0640199c 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -303,7 +303,7 @@ public override string ToString() => JsonValueKind.String => String, JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), // JsonValueKind.Object => JsonSerializer.Serialize(Object), - // JsonValueKind.Array => JsonSerializer.Serialize(Array), + JsonValueKind.Array => JsonSerializer.Serialize(Array), _ => throw new InvalidOperationException($"Invalid value kind {ValueKind}"), }; From 965daadffa8853e22304a671837e6a152f9e826d Mon Sep 17 00:00:00 2001 From: Tomas Date: Fri, 12 Jun 2026 11:25:54 +0200 Subject: [PATCH 21/27] Rename Dictionary to Object --- .../Internal/Expressions/ExpressionValue.cs | 10 ++++++---- .../ExpressionEvaluatorTests/ExpressionValueTests.cs | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index 645e461879..cb4c701288 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -209,7 +209,7 @@ private static ExpressionValue ToJsonNodeOrNull(object? value) JsonValueKind.False => false, JsonValueKind.String => String, JsonValueKind.Number => Number, - JsonValueKind.Object => Dictionary, + JsonValueKind.Object => Object, JsonValueKind.Array => Array, _ => throw new InvalidOperationException("Invalid value kind"), }; @@ -256,10 +256,11 @@ private static ExpressionValue ToJsonNodeOrNull(object? value) ), }; +#pragma warning disable CA1720 /// /// Get the value as an object (or throw if it isn't an object ValueKind) /// - public JsonObject Dictionary => + public JsonObject Object => ValueKind switch { JsonValueKind.Object => _objectValue ?? throw new UnreachableException($"{this} is not an object"), @@ -267,6 +268,7 @@ private static ExpressionValue ToJsonNodeOrNull(object? value) $"The .Object property can't be used on an expression value that represent a {ValueKind}" ), }; +#pragma warning restore CA1720 /// Get the value as an array (or throw if it isn't an array ValueKind) public JsonArray Array => @@ -290,7 +292,7 @@ public override string ToString() => JsonValueKind.False => "false", JsonValueKind.String => JsonSerializer.Serialize(String, _unsafeSerializerOptionsForSerializingDates), JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), - JsonValueKind.Object => JsonSerializer.Serialize(Dictionary), + JsonValueKind.Object => JsonSerializer.Serialize(Object), JsonValueKind.Array => JsonSerializer.Serialize(Array), _ => throw new InvalidOperationException($"Invalid value kind {ValueKind}"), }; @@ -696,7 +698,7 @@ public override void Write(Utf8JsonWriter writer, ExpressionValue value, JsonSer writer.WriteNumberValue(value.Number); break; case JsonValueKind.Object: - JsonSerializer.Serialize(writer, value.Dictionary, options); + JsonSerializer.Serialize(writer, value.Object, options); break; case JsonValueKind.Array: JsonSerializer.Serialize(writer, value.Array, options); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs index 8940d9a082..fbfe5e8677 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs @@ -186,7 +186,7 @@ public void TestUndefined() Assert.Throws(() => undefinedValue.Number); Assert.Throws(() => undefinedValue.String); Assert.Throws(() => undefinedValue.Array); - Assert.Throws(() => undefinedValue.Dictionary); + Assert.Throws(() => undefinedValue.Object); Assert.Equal("null", JsonSerializer.Serialize(undefinedValue)); Assert.Throws(() => undefinedValue.GetHashCode()); @@ -204,7 +204,7 @@ public void NullThrowsWhenAccessedAsDifferentType() Assert.Throws(() => _ = nullValue.Number); Assert.Throws(() => _ = nullValue.String); Assert.Throws(() => _ = nullValue.Array); - Assert.Throws(() => _ = nullValue.Dictionary); + Assert.Throws(() => _ = nullValue.Object); } [Fact] From 403f245ccb4c3f31cf21ccce4e9f41c033b86e3b Mon Sep 17 00:00:00 2001 From: Tomas Date: Fri, 12 Jun 2026 11:51:55 +0200 Subject: [PATCH 22/27] Make ObjectFunctionEvaluator static --- .../Expressions/ExpressionEvaluator.cs | 2 +- .../Expressions/ObjectFunctionEvaluator.cs | 32 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index 98a4e08e8d..2158238e2b 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -1007,7 +1007,7 @@ private static ExpressionValue List(ExpressionValue[] args) private static JsonObject Object(ExpressionValue[] args) { - return new ObjectFunctionEvaluator(args).Evaluate(); + return ObjectFunctionEvaluator.Evaluate(args); } /// diff --git a/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs index eca4bae62c..58e6038c38 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs @@ -3,25 +3,21 @@ namespace Altinn.App.Core.Internal.Expressions; -internal sealed class ObjectFunctionEvaluator +internal static class ObjectFunctionEvaluator { - private readonly ExpressionValue[] _args; - - public ObjectFunctionEvaluator(ExpressionValue[] args) => _args = args; - - public JsonObject Evaluate() + public static JsonObject Evaluate(ExpressionValue[] args) { - AssertEvenNumberOfArguments(); - string[] keys = ExtractKeys(); + AssertEvenNumberOfArguments(args); + string[] keys = ExtractKeys(args); AssertKeysAreUnique(keys); - JsonNode?[] values = ExtractValues(); + JsonNode?[] values = ExtractValues(args); Dictionary keyValuePairs = DictionaryFromKeysAndValues(keys, values); return new JsonObject(keyValuePairs); } - private void AssertEvenNumberOfArguments() + private static void AssertEvenNumberOfArguments(ExpressionValue[] args) { - if (_args.Length % 2 == 1) + if (args.Length % 2 == 1) { throw new ExpressionEvaluatorTypeErrorException( "The object function must have an even number of arguments." @@ -29,11 +25,11 @@ private void AssertEvenNumberOfArguments() } } - private string[] ExtractKeys() + private static string[] ExtractKeys(ExpressionValue[] args) { try { - return ExtractEvenIndexedArguments().Select(v => v.String).ToArray(); + return ExtractEvenIndexedArguments(args).Select(v => v.String).ToArray(); } catch (InvalidCastException) { @@ -41,7 +37,8 @@ private string[] ExtractKeys() } } - private ExpressionValue[] ExtractEvenIndexedArguments() => _args.Where((_, index) => index % 2 == 0).ToArray(); + private static ExpressionValue[] ExtractEvenIndexedArguments(ExpressionValue[] args) => + args.Where((_, index) => index % 2 == 0).ToArray(); private static void AssertKeysAreUnique(string[] keys) { @@ -51,10 +48,11 @@ private static void AssertKeysAreUnique(string[] keys) } } - private JsonNode?[] ExtractValues() => - ExtractOddIndexedArguments().Select(v => JsonSerializer.SerializeToNode(v)).ToArray(); + private static JsonNode?[] ExtractValues(ExpressionValue[] args) => + ExtractOddIndexedArguments(args).Select(v => JsonSerializer.SerializeToNode(v)).ToArray(); - private ExpressionValue[] ExtractOddIndexedArguments() => _args.Where((_, index) => index % 2 == 1).ToArray(); + private static ExpressionValue[] ExtractOddIndexedArguments(ExpressionValue[] args) => + args.Where((_, index) => index % 2 == 1).ToArray(); private static Dictionary DictionaryFromKeysAndValues(string[] keys, JsonNode?[] values) => keys.Zip(values, (k, v) => new { k, v }).ToDictionary(x => x.k, x => x.v); From b062e3de9f845a75292b45a128afc5097cf0a972 Mon Sep 17 00:00:00 2001 From: Tomas Date: Fri, 12 Jun 2026 11:53:16 +0200 Subject: [PATCH 23/27] Uncomment code in ToStringForText --- src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index cb4c701288..79a0bda9cd 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -314,7 +314,7 @@ public override string ToString() => JsonValueKind.False => "false", JsonValueKind.String => String, JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), - // JsonValueKind.Object => JsonSerializer.Serialize(Object), + JsonValueKind.Object => JsonSerializer.Serialize(Object), JsonValueKind.Array => JsonSerializer.Serialize(Array), _ => throw new InvalidOperationException($"Invalid value kind {ValueKind}"), }; From 8b4cde404c1a048b4c8ca2583af8d46c91a40478 Mon Sep 17 00:00:00 2001 From: Tomas Date: Fri, 12 Jun 2026 12:00:59 +0200 Subject: [PATCH 24/27] Add data to error messages --- .../Expressions/ObjectFunctionEvaluator.cs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs index 58e6038c38..ec8ce0fb0e 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ObjectFunctionEvaluator.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Nodes; +using Altinn.App.Core.Models.Expressions; namespace Altinn.App.Core.Internal.Expressions; @@ -9,7 +10,7 @@ public static JsonObject Evaluate(ExpressionValue[] args) { AssertEvenNumberOfArguments(args); string[] keys = ExtractKeys(args); - AssertKeysAreUnique(keys); + AssertKeysAreUnique(keys, args); JsonNode?[] values = ExtractValues(args); Dictionary keyValuePairs = DictionaryFromKeysAndValues(keys, values); return new JsonObject(keyValuePairs); @@ -20,7 +21,9 @@ private static void AssertEvenNumberOfArguments(ExpressionValue[] args) if (args.Length % 2 == 1) { throw new ExpressionEvaluatorTypeErrorException( - "The object function must have an even number of arguments." + "The object function must have an even number of arguments.", + ExpressionFunction.@object, + args ); } } @@ -33,18 +36,26 @@ private static string[] ExtractKeys(ExpressionValue[] args) } catch (InvalidCastException) { - throw new ExpressionEvaluatorTypeErrorException("Object keys must be strings."); + throw new ExpressionEvaluatorTypeErrorException( + "Object keys must be strings.", + ExpressionFunction.@object, + args + ); } } private static ExpressionValue[] ExtractEvenIndexedArguments(ExpressionValue[] args) => args.Where((_, index) => index % 2 == 0).ToArray(); - private static void AssertKeysAreUnique(string[] keys) + private static void AssertKeysAreUnique(string[] keys, ExpressionValue[] args) { if (keys.Length != keys.Distinct().Count()) { - throw new ExpressionEvaluatorTypeErrorException("Object keys must be unique."); + throw new ExpressionEvaluatorTypeErrorException( + "Object keys must be unique.", + ExpressionFunction.@object, + args + ); } } From 7f82123666f4c43f584f3e3a4f67f97c9a25eef0 Mon Sep 17 00:00:00 2001 From: Tomas Date: Fri, 12 Jun 2026 14:39:19 +0200 Subject: [PATCH 25/27] Return ExpressionValue from Object --- src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index 2158238e2b..5540e9b995 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -1005,7 +1005,7 @@ private static ExpressionValue List(ExpressionValue[] args) return new JsonArray(args.Select(a => JsonSerializer.SerializeToNode(a)).ToArray()); } - private static JsonObject Object(ExpressionValue[] args) + private static ExpressionValue Object(ExpressionValue[] args) { return ObjectFunctionEvaluator.Evaluate(args); } From 84e2800ff534bc648d1070cbfcd0af08fcdbea0c Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 17 Jun 2026 08:01:57 +0200 Subject: [PATCH 26/27] Fix test file --- ...Tests.PublicApi_ShouldNotChange_Unintentionally.verified.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index d8f0d34d10..dd551543a9 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -3252,8 +3252,8 @@ namespace Altinn.App.Core.Internal.Expressions public ExpressionValue(System.Text.Json.Nodes.JsonObject value) { } public System.Text.Json.Nodes.JsonArray Array { get; } public bool Bool { get; } - public System.Text.Json.Nodes.JsonObject Dictionary { get; } public double Number { get; } + public System.Text.Json.Nodes.JsonObject Object { get; } public string String { get; } public System.Text.Json.JsonValueKind ValueKind { get; } public static Altinn.App.Core.Internal.Expressions.ExpressionValue False { get; } From 3ac54d3dbeaa1a2a4950102598028de761138c72 Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 17 Jun 2026 10:54:07 +0200 Subject: [PATCH 27/27] Empty commit