Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e084500
Support lists in expressions
TomasEng May 27, 2026
4a88789
Simplify ReadArray
TomasEng May 27, 2026
ee7a096
Merge branch 'main' into support-lists-in-expressions
TomasEng May 27, 2026
b58a3af
Merge branch 'main' into support-lists-in-expressions
TomasEng May 27, 2026
739cc4e
Revert irrelevant changes
TomasEng May 29, 2026
ab5d0b1
Support objects in expressions
TomasEng May 28, 2026
f24f67b
Use JsonArray
TomasEng Jun 1, 2026
20dbd11
Update public API test file
TomasEng Jun 1, 2026
c495caa
Remove unused code
TomasEng Jun 1, 2026
3b5d165
Use correct value in tests
TomasEng Jun 1, 2026
f35a9ea
Merge remote-tracking branch 'origin/support-lists-in-expressions' in…
TomasEng Jun 1, 2026
a5fa375
Use JsonObject
TomasEng Jun 1, 2026
d14e010
Seal ObjectFunctionEvaluator
TomasEng Jun 3, 2026
6335458
Update src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs
TomasEng Jun 10, 2026
1b629f7
Update src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs
TomasEng Jun 10, 2026
4f08e2b
Update src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs
TomasEng Jun 10, 2026
e4a90c0
Revert uncomment of unused code
TomasEng Jun 10, 2026
df8320c
Refactor ObjectFunctionEvaluator
TomasEng Jun 10, 2026
2b2a86b
Rename variable
TomasEng Jun 10, 2026
4568156
Correct error message
TomasEng Jun 10, 2026
1ee183c
Correct error message
TomasEng Jun 10, 2026
58c2757
Merge branch 'support-lists-in-expressions' into support-objects-in-e…
TomasEng Jun 10, 2026
44ea2b4
Return ExpressionValue from List
TomasEng Jun 11, 2026
219d38b
Merge branch 'support-lists-in-expressions' into support-objects-in-e…
TomasEng Jun 11, 2026
d794fcf
Uncomment code in ToStringForText
TomasEng Jun 12, 2026
05ab65e
Merge branch 'support-lists-in-expressions' into support-objects-in-e…
TomasEng Jun 12, 2026
965daad
Rename Dictionary to Object
TomasEng Jun 12, 2026
403f245
Make ObjectFunctionEvaluator static
TomasEng Jun 12, 2026
b062e3d
Uncomment code in ToStringForText
TomasEng Jun 12, 2026
8b4cde4
Add data to error messages
TomasEng Jun 12, 2026
7f82123
Return ExpressionValue from Object
TomasEng Jun 12, 2026
9f96406
Merge branch 'main' into support-lists-in-expressions
TomasEng Jun 15, 2026
e533215
Merge branch 'support-lists-in-expressions' into support-objects-in-e…
TomasEng Jun 15, 2026
aa24c44
Merge branch 'main' into support-objects-in-expressions
TomasEng Jun 16, 2026
84e2800
Fix test file
TomasEng Jun 17, 2026
3ac54d3
Empty commit
TomasEng Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
/// <summary>
/// Shortcut for evaluating a boolean expression on a given property on a <see cref="Models.Layout.Components.Base.BaseComponent" />
/// </summary>
[Obsolete("Use ComponentContext.IsHidden or ComponentContext.EvaluateExpression instead")]

Check warning on line 21 in src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&issues=AZ7QVwB6GxvN3xmvm1XM&open=AZ7QVwB6GxvN3xmvm1XM&pullRequest=1777
public static async Task<bool> EvaluateBooleanExpression(
LayoutEvaluatorState state,
ComponentContext context,
Expand Down Expand Up @@ -147,6 +147,7 @@
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}"
),
Expand Down Expand Up @@ -763,7 +764,7 @@
}

var all = true;
foreach (var arg in args)

Check warning on line 767 in src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Loops should be simplified using the "Where" LINQ method

See more on https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&issues=AZ7QVwB6GxvN3xmvm1XN&open=AZ7QVwB6GxvN3xmvm1XN&pullRequest=1777
{
// the LINQ All() method would short-circuit and not evaluate all args, so we do it manually to ensure exceptions are thrown correctly
if (!PrepareBooleanArg(arg))
Expand Down Expand Up @@ -805,7 +806,7 @@
}

bool any = false;
foreach (var arg in args)

Check warning on line 809 in src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Loops should be simplified using the "Where" LINQ method

See more on https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&issues=AZ7QVwB6GxvN3xmvm1XO&open=AZ7QVwB6GxvN3xmvm1XO&pullRequest=1777
{
// the LINQ Any() method would short-circuit and not evaluate all args, so we do it manually to ensure exceptions are thrown correctly
if (PrepareBooleanArg(arg))
Expand Down Expand Up @@ -1018,6 +1019,11 @@
return new JsonArray(args.Select(a => JsonSerializer.SerializeToNode(a)).ToArray());
}

private static ExpressionValue Object(ExpressionValue[] args)
{
return ObjectFunctionEvaluator.Evaluate(args);
}

/// <summary>
/// Performs arithmetic operation using decimal precision to avoid floating point precision issues.
/// Converts doubles to decimal, performs the operation, and converts back to double.
Expand Down
85 changes: 52 additions & 33 deletions src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -19,7 +20,7 @@
// 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<string, ExpressionValue>? _objectValue = null;
private readonly JsonObject? _objectValue = null;
private readonly JsonArray? _arrayValue = null;

/// <summary>
Expand All @@ -28,7 +29,7 @@
public ExpressionValue()
: this(JsonValueKind.Null) { }

private ExpressionValue(JsonValueKind valueKind)

Check warning on line 32 in src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

All 'ExpressionValue' method overloads should be adjacent.

See more on https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&issues=AZ7QVv_BGxvN3xmvm1XH&open=AZ7QVv_BGxvN3xmvm1XH&pullRequest=1777
{
ValueKind = valueKind;
}
Expand Down Expand Up @@ -84,11 +85,14 @@
_stringValue = value;
}

// private ExpressionValue(Dictionary<string, ExpressionValue>? value)
// {
// _valueKind = value is null ? JsonValueKind.Null : JsonValueKind.Object;
// _objectValue = value;
// }
/// <summary>
/// Constructor for object value
/// </summary>
public ExpressionValue(JsonObject value)
{
ValueKind = JsonValueKind.Object;
_objectValue = value;
}

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

// /// <summary>
// /// Convert a Dictionary to ExpressionValue
// /// </summary>
// public static implicit operator ExpressionValue(Dictionary<string, ExpressionValue>? value) => new(value);
/// <summary>
/// Convert a Dictionary to ExpressionValue
/// </summary>
public static implicit operator ExpressionValue(JsonObject value) => new(value);

/// <summary>
/// Convert an array to ExpressionValue
Expand Down Expand Up @@ -174,17 +178,20 @@
'"'
) // Trim quotes to match the string representation
,
JsonArray jsonArrayValue => jsonArrayValue,
_ => 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,
};
}
Expand All @@ -202,7 +209,7 @@
JsonValueKind.False => false,
JsonValueKind.String => String,
JsonValueKind.Number => Number,
// JsonValueKind.Object => Object,
JsonValueKind.Object => Object,
JsonValueKind.Array => Array,
_ => throw new InvalidOperationException("Invalid value kind"),
};
Expand All @@ -228,7 +235,7 @@
/// <summary>
/// Get the value as a string (or throw if it isn't a string ValueKind)
/// </summary>
public string String =>

Check warning on line 238 in src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Identifier 'String' contains type name

See more on https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&issues=AZ7QVv_BGxvN3xmvm1XL&open=AZ7QVv_BGxvN3xmvm1XL&pullRequest=1777
ValueKind switch
{
JsonValueKind.String => _stringValue ?? throw new UnreachableException("Not a string"),
Expand All @@ -249,14 +256,19 @@
),
};

// public Dictionary<string, ExpressionValue> 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}"
// ),
// };
#pragma warning disable CA1720
/// <summary>
/// Get the value as an object (or throw if it isn't an object ValueKind)
/// </summary>
public JsonObject 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}"
),
};
#pragma warning restore CA1720

/// <summary>Get the value as an array (or throw if it isn't an array ValueKind)</summary>
public JsonArray Array =>
Expand All @@ -277,12 +289,12 @@
JsonValueKind.Null => "null",
JsonValueKind.Undefined => "undefined",
JsonValueKind.True => "true",
JsonValueKind.False => "false",

Check warning on line 292 in src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of using this literal 'false' 7 times.

See more on https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&issues=AZ7QVv_BGxvN3xmvm1XI&open=AZ7QVv_BGxvN3xmvm1XI&pullRequest=1777
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(Object),
JsonValueKind.Array => JsonSerializer.Serialize(Array),
_ => throw new InvalidOperationException($"Invalid value kind {ValueKind}"),

Check failure on line 297 in src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this 'throw' expression.

See more on https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&issues=AZ7QVv_BGxvN3xmvm1XJ&open=AZ7QVv_BGxvN3xmvm1XJ&pullRequest=1777
};

/// <summary>
Expand All @@ -302,7 +314,7 @@
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}"),
};
Expand Down Expand Up @@ -340,7 +352,7 @@
{
throw new NotImplementedException("Equals is not used for ExpressionValue");
// First compare value kinds
// if (_valueKind != other._valueKind)

Check warning on line 355 in src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this commented out code.

See more on https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&issues=AZ7QVv_BGxvN3xmvm1XE&open=AZ7QVv_BGxvN3xmvm1XE&pullRequest=1777
// return false;

// // Then compare actual values based on the kind
Expand Down Expand Up @@ -369,7 +381,7 @@
{
throw new NotImplementedException("GetHashCode is not implemented for ExpressionValue");
// return ValueKind switch
// {

Check warning on line 384 in src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this commented out code.

See more on https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&issues=AZ7QVv_BGxvN3xmvm1XF&open=AZ7QVv_BGxvN3xmvm1XF&pullRequest=1777
// JsonValueKind.Null => 0,
// JsonValueKind.True => 1,
// JsonValueKind.False => 0,
Expand Down Expand Up @@ -486,7 +498,7 @@
/// <param name="result">The result (null or default if unsuccessful), but note that null might also be a valid result</param>
/// <param name="type">The type to convert to</param>
/// <returns>Whether the conversion was successful</returns>
public bool TryDeserialize(Type type, out object? result)

Check failure on line 501 in src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 28 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&issues=AZ7QVv_BGxvN3xmvm1XK&open=AZ7QVv_BGxvN3xmvm1XK&pullRequest=1777
{
// Value types can be Nullable<T>, so assign underlyingType accordingly
Type underlyingType;
Expand Down Expand Up @@ -603,7 +615,7 @@

private static bool IsSupportedNumericType(Type type)
{
// TODO: consider supporting enums as numeric types as well, but currently we

Check warning on line 618 in src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this 'TODO' comment.

See more on https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&issues=AZ7QVv_BGxvN3xmvm1XG&open=AZ7QVv_BGxvN3xmvm1XG&pullRequest=1777
// don't use C# enums in datamodels, so it isn't very urgent.
return type == typeof(double)
|| type == typeof(int)
Expand Down Expand Up @@ -634,7 +646,7 @@
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(),
};
Expand All @@ -652,10 +664,17 @@
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 value =
JsonSerializer.Deserialize<JsonObject>(ref reader, options)
?? throw new JsonException("Expected JSON object value.");
return new ExpressionValue(value);
}

/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, ExpressionValue value, JsonSerializerOptions options)
Expand All @@ -678,9 +697,9 @@
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.Object, options);
break;
case JsonValueKind.Array:
JsonSerializer.Serialize(writer, value.Array, options);
break;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Altinn.App.Core.Models.Expressions;

namespace Altinn.App.Core.Internal.Expressions;

internal static class ObjectFunctionEvaluator
{
public static JsonObject Evaluate(ExpressionValue[] args)
{
AssertEvenNumberOfArguments(args);
string[] keys = ExtractKeys(args);
AssertKeysAreUnique(keys, args);
JsonNode?[] values = ExtractValues(args);
Dictionary<string, JsonNode?> keyValuePairs = DictionaryFromKeysAndValues(keys, values);
return new JsonObject(keyValuePairs);
}

private static void AssertEvenNumberOfArguments(ExpressionValue[] args)
{
if (args.Length % 2 == 1)
{
throw new ExpressionEvaluatorTypeErrorException(
"The object function must have an even number of arguments.",
ExpressionFunction.@object,
args
);
}
}

private static string[] ExtractKeys(ExpressionValue[] args)
{
try
{
return ExtractEvenIndexedArguments(args).Select(v => v.String).ToArray();
}
catch (InvalidCastException)
{
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, ExpressionValue[] args)
{
if (keys.Length != keys.Distinct().Count())
{
throw new ExpressionEvaluatorTypeErrorException(
"Object keys must be unique.",
ExpressionFunction.@object,
args
);
}
}

private static JsonNode?[] ExtractValues(ExpressionValue[] args) =>
ExtractOddIndexedArguments(args).Select(v => JsonSerializer.SerializeToNode(v)).ToArray();

private static ExpressionValue[] ExtractOddIndexedArguments(ExpressionValue[] args) =>
args.Where((_, index) => index % 2 == 1).ToArray();

private static Dictionary<string, JsonNode?> DictionaryFromKeysAndValues(string[] keys, JsonNode?[] values) =>
keys.Zip(values, (k, v) => new { k, v }).ToDictionary(x => x.k, x => x.v);
}
7 changes: 7 additions & 0 deletions src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,4 +228,11 @@ public enum ExpressionFunction

/// <summary>Create a list from the arguments.</summary>
list,

/// <summary>
/// Create a dictionary from the arguments, which must be alternating keys and values.
/// </summary>
#pragma warning disable CA1720
@object,
#pragma warning restore CA1720
Comment thread
ivarne marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExpressionTestCaseRoot> LoadTestCase(string file, string folder)
{
ExpressionTestCaseRoot testCase = new();
Expand Down
Original file line number Diff line number Diff line change
@@ -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"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
}
Expand Down Expand Up @@ -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"],
Expand All @@ -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",
Expand Down
Loading
Loading