Skip to content
Open
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
4fa6d7f
Added plus and minus functions.
olavsorl Jan 12, 2026
a58fd83
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Jan 20, 2026
bc237c8
Added multiply and devide
olavsorl Jan 28, 2026
65fd994
Added average and more unit tests for function
olavsorl Jan 28, 2026
4882838
Renamed devide to divide
olavsorl Jan 28, 2026
4f04237
Double fix
olavsorl Feb 2, 2026
45396f2
Fixed unit tests
olavsorl Feb 2, 2026
e2ee639
Implemented CodeRabbits suggested changes
olavsorl Feb 3, 2026
fe071e5
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Feb 3, 2026
3528b68
Changed to decimal return type
olavsorl Feb 3, 2026
1cd8211
Implementes suggested changes and added unit tests
olavsorl Feb 3, 2026
364e4b8
improved unit test names and added min value tests
olavsorl Feb 3, 2026
2a01a10
Changed from accepting multiple arguments to just accept two. Removed…
olavsorl Feb 9, 2026
8b89eae
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Feb 9, 2026
5d427f5
Fixed unit tests and consistancy in functions
olavsorl Feb 9, 2026
fdcb872
Merge remote-tracking branch 'origin/feature/expressions-with-list-an…
olavsorl Feb 9, 2026
efd3168
Fixed exception message
olavsorl Feb 9, 2026
a90381c
Fixed test name
olavsorl Feb 9, 2026
46f6067
Implemented suggested changes
olavsorl Feb 11, 2026
1c226fb
Made shared test json file names more consistant
olavsorl Feb 11, 2026
f78d423
Added same-types-negative-positive-decimal tests for consistency with…
olavsorl Feb 11, 2026
9d9d715
Implemented suggested changes by code rabbit
olavsorl Feb 11, 2026
0c11ff4
Implemented suggested changes from code rabbit
olavsorl Feb 11, 2026
1debfe3
Changed to single json file for each arithmetic operation.
olavsorl Feb 11, 2026
362899e
Reverted change to ExpressionValue
olavsorl Feb 11, 2026
200b432
Fixes
olavsorl Feb 12, 2026
5258676
Improved support for unit tests nested in testCases in shared json te…
olavsorl Feb 13, 2026
e63e093
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Feb 13, 2026
597fcaf
Reverted change making expression nullable in ExpressionTestCaseRoot
olavsorl Feb 13, 2026
1255046
Merge remote-tracking branch 'origin/feature/expressions-with-list-an…
olavsorl Feb 13, 2026
eb1220e
Added tests for large numbers formatted as string
olavsorl Feb 16, 2026
374ffaa
Implemented suggested changes by code rabbit
olavsorl Feb 16, 2026
b865d6b
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Feb 16, 2026
299843b
Fixed unit test
olavsorl Feb 16, 2026
3020c24
Merge remote-tracking branch 'origin/feature/expressions-with-list-an…
olavsorl Feb 16, 2026
9d08787
Cleanup
olavsorl Feb 16, 2026
79189e2
Implemented suggested changes by code rabbit
olavsorl Feb 16, 2026
30a9c03
Implemented suggested changes by code rabbit
olavsorl Feb 17, 2026
ae83fb6
Implemented suggested changes
olavsorl Feb 19, 2026
3bc8d30
Made some changes to the max and min limits
olavsorl Feb 19, 2026
a191f76
Implemented suggested changes by code rabbit
olavsorl Feb 19, 2026
51c3ed4
Reverted requested change by code rabbit
olavsorl Feb 19, 2026
aee514a
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Feb 19, 2026
5fbae42
Change to now converting string to double before converting to decima…
olavsorl Feb 20, 2026
90db3c3
Merge remote-tracking branch 'origin/feature/expressions-with-list-an…
olavsorl Feb 20, 2026
0ac8517
Added unit tests also for other functions calling PrepareNumericArgs
olavsorl Feb 23, 2026
a6a869b
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Feb 23, 2026
ffcf72e
Fixed tests for greaterThan og greaterThanEq
olavsorl Feb 23, 2026
cd81b26
Merge remote-tracking branch 'origin/feature/expressions-with-list-an…
olavsorl Feb 23, 2026
776e361
Implemented suggested change from code rabbit
olavsorl Feb 23, 2026
150181d
Implemented suggested code rabbit changes
olavsorl Feb 23, 2026
c6fed3e
Implemented suggested changes by Code Rabbit
olavsorl Feb 23, 2026
5690e42
Implemented suggested changes
olavsorl Feb 24, 2026
a225e5f
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Feb 24, 2026
760aef0
Merge branch 'main' into feature/expressions-with-list-and-object-sup…
olavsorl Mar 4, 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
147 changes: 126 additions & 21 deletions src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.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.Json;
using System.Text.RegularExpressions;
using Altinn.App.Core.Models;
Expand All @@ -11,7 +12,7 @@ namespace Altinn.App.Core.Internal.Expressions;
/// <summary>
/// Static class used to evaluate expressions. Holds the implementation for all expression functions.
/// </summary>
public static class ExpressionEvaluator
public static partial class ExpressionEvaluator
{
/// <summary>
/// Shortcut for evaluating a boolean expression on a given property on a <see cref="Models.Layout.Components.Base.BaseComponent" />
Expand Down Expand Up @@ -127,6 +128,10 @@ internal static async Task<ExpressionValue> EvaluateExpression_internal(
ExpressionFunction.argv => Argv(args, positionalArguments),
ExpressionFunction.gatewayAction => state.GetGatewayAction(),
ExpressionFunction.language => state.GetLanguage(),
ExpressionFunction.plus => Plus(args),
ExpressionFunction.minus => Minus(args),
ExpressionFunction.multiply => Multiply(args),
ExpressionFunction.divide => Divide(args),
ExpressionFunction.INVALID => throw new ExpressionEvaluatorTypeErrorException(
$"Function {expr.Args.FirstOrDefault()} not implemented in backend {expr}"
),
Expand Down Expand Up @@ -610,8 +615,8 @@ private static int StringLength(ExpressionValue[] args)
throw new ExpressionEvaluatorTypeErrorException($"Expected 2-3 arguments, got {args.Length}");
}
string? subject = args[0].ToStringForEquals();
double? start = PrepareNumericArg(args[1]);
double? end = args.Length == 3 ? PrepareNumericArg(args[2]) : null;
double? start = PrepareNumericArg<double>(args[1]);
double? end = args.Length == 3 ? PrepareNumericArg<double>(args[2]) : null;
bool hasEnd = args.Length == 3;

if (start == null || (hasEnd && end == null))
Expand Down Expand Up @@ -659,13 +664,13 @@ private static string Round(ExpressionValue[] args)
);
}

var number = PrepareNumericArg(args[0]) ?? 0;
var number = PrepareNumericArg<double>(args[0]) ?? 0;

int precision = 0;

if (args.Length == 2)
{
precision = (int)(PrepareNumericArg(args[1]) ?? 0);
precision = (int)(PrepareNumericArg<double>(args[1]) ?? 0);
}

return number.ToString($"N{precision}", CultureInfo.InvariantCulture);
Expand Down Expand Up @@ -806,33 +811,53 @@ ExpressionValue[] args
return !PrepareBooleanArg(args[0]);
}

private static (double?, double?) PrepareNumericArgs(ExpressionValue[] args)
private static (T?, T?) PrepareNumericArgs<T>(ExpressionValue[] args)
where T : struct, INumber<T>
{
if (args.Length != 2)
{
throw new ExpressionEvaluatorTypeErrorException("Invalid number of args for compare");
throw new ExpressionEvaluatorTypeErrorException("Invalid number of args");
}

var a = PrepareNumericArg(args[0]);
var a = PrepareNumericArg<T>(args[0]);

var b = PrepareNumericArg(args[1]);
var b = PrepareNumericArg<T>(args[1]);

return (a, b);
}

private static double? PrepareNumericArg(ExpressionValue arg)
private static T? PrepareNumericArg<T>(ExpressionValue arg)
where T : struct, INumber<T>
{
return arg.ValueKind switch
{
JsonValueKind.True or JsonValueKind.False or JsonValueKind.Array or JsonValueKind.Object =>
throw new ExpressionEvaluatorTypeErrorException($"Expected number, got value {arg}"),
JsonValueKind.String => ParseNumber(arg.String, throwException: true),
JsonValueKind.Number => arg.Number,
JsonValueKind.String => ParseNumber<T>(arg.String, throwException: true),
JsonValueKind.Number => CastNumber<T>(arg.Number),

_ => null,
};
}

// Only intended to be used in cases where T is either decimal or double
private static T? CastNumber<T>(double? number)
where T : struct, INumber<T>
{
if (typeof(T) != typeof(decimal))
{
return number.HasValue ? T.CreateChecked(number.Value) : null;
}

if (number.HasValue)
{
EnsureConvertibleToDecimal(number.Value);
return T.CreateChecked(number.Value);
}

return null;
}

private static ExpressionValue IfImpl(ExpressionValue[] args)
{
if (args.Length == 2)
Expand Down Expand Up @@ -861,11 +886,13 @@ private static ExpressionValue IfImpl(ExpressionValue[] args)
);
}

private static readonly Regex _numberRegex = new Regex(@"^-?\d+(\.\d+)?$");

internal static double? ParseNumber(string s, bool throwException = true)
/// <summary>
/// Parses a number from a string representation.
/// </summary>
internal static T? ParseNumber<T>(string s, bool throwException = true)
where T : struct, INumber<T>
{
if (_numberRegex.IsMatch(s) && double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
if (NumberRegex().IsMatch(s) && T.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
{
return d;
}
Expand All @@ -879,7 +906,7 @@ private static ExpressionValue IfImpl(ExpressionValue[] args)

private static bool LessThan(ExpressionValue[] args)
{
var (a, b) = PrepareNumericArgs(args);
var (a, b) = PrepareNumericArgs<double>(args);

if (a is null || b is null)
{
Expand All @@ -888,9 +915,37 @@ private static bool LessThan(ExpressionValue[] args)
return a < b; // Actual implementation
}

private static double? Plus(ExpressionValue[] args)
{
var (a, b) = PrepareNumericArgs<decimal>(args);
return PerformArithmetic(a, b, (x, y) => x + y);
}

private static double? Minus(ExpressionValue[] args)
{
var (a, b) = PrepareNumericArgs<decimal>(args);
return PerformArithmetic(a, b, (x, y) => x - y);
}

private static double? Multiply(ExpressionValue[] args)
{
var (a, b) = PrepareNumericArgs<decimal>(args);
return PerformArithmetic(a, b, (x, y) => x * y);
}

private static double? Divide(ExpressionValue[] args)
{
var (a, b) = PrepareNumericArgs<decimal>(args);
if (a != null && b == 0)
{
throw new ExpressionEvaluatorTypeErrorException("The second argument is 0, cannot divide by 0");
}
return PerformArithmetic(a, b, (x, y) => x / y);
}

private static bool LessThanEq(ExpressionValue[] args)
{
var (a, b) = PrepareNumericArgs(args);
var (a, b) = PrepareNumericArgs<double>(args);

if (a is null || b is null)
{
Expand All @@ -901,7 +956,7 @@ private static bool LessThanEq(ExpressionValue[] args)

private static bool GreaterThan(ExpressionValue[] args)
{
var (a, b) = PrepareNumericArgs(args);
var (a, b) = PrepareNumericArgs<double>(args);

if (a is null || b is null)
{
Expand All @@ -912,7 +967,7 @@ private static bool GreaterThan(ExpressionValue[] args)

private static bool GreaterThanEq(ExpressionValue[] args)
{
var (a, b) = PrepareNumericArgs(args);
var (a, b) = PrepareNumericArgs<double>(args);

if (a is null || b is null)
{
Expand Down Expand Up @@ -943,7 +998,7 @@ private static ExpressionValue Argv(ExpressionValue[] args, ExpressionValue[]? p
throw new ExpressionEvaluatorTypeErrorException($"Expected 1 argument(s), got {args.Length}");
}

var index = (int?)PrepareNumericArg(args[0]);
var index = (int?)PrepareNumericArg<double>(args[0]);
if (!index.HasValue)
{
throw new ExpressionEvaluatorTypeErrorException($"Expected number, got value \"{args[0]}\"");
Expand All @@ -960,4 +1015,54 @@ private static ExpressionValue Argv(ExpressionValue[] args, ExpressionValue[]? p

return positionalArguments[index.Value];
}

/// <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.
/// </summary>
/// <param name="aDecimal">First operand</param>
/// <param name="bDecimal">Second operand</param>
/// <param name="operation">Function that performs the arithmetic operation on two decimals</param>
/// <returns>Result of the operation as double, or null if any operand is null</returns>
private static double? PerformArithmetic(
decimal? aDecimal,
decimal? bDecimal,
Func<decimal, decimal, decimal> operation
)
{
if (aDecimal.HasValue is false || bDecimal.HasValue is false)
{
return null;
}

try
{
var result = operation(aDecimal.Value, bDecimal.Value);
return (double)result;
}
catch (OverflowException)
{
throw new ExpressionEvaluatorTypeErrorException(
$"Arithmetic overflow: result of operation on {aDecimal.Value} and {bDecimal.Value} exceeds supported range"
);
}
}

private static void EnsureConvertibleToDecimal(double value)
{
if (
double.IsNaN(value)
|| double.IsInfinity(value)
|| value > (double)decimal.MaxValue
|| value < (double)decimal.MinValue
)
{
throw new ExpressionEvaluatorTypeErrorException(
$"Cannot convert non-finite or out-of-range number to decimal: {value}"
);
}
}

[GeneratedRegex(@"^-?\d+(\.\d+)?$")]
private static partial Regex NumberRegex();
}
10 changes: 4 additions & 6 deletions src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,7 @@ public static ExpressionValue FromObject(object? value)
uint numberValue => numberValue,
long numberValue => numberValue,
ulong numberValue => numberValue,
decimal numberValue =>
(double?)numberValue // expressions uses double which needs an explicit cast
,
decimal numberValue => (double?)numberValue, // expressions uses double which needs an explicit cast
DateTime dateTimeValue => JsonSerializer
.Serialize(dateTimeValue, _unsafeSerializerOptionsForSerializingDates)
.Trim(
Expand Down Expand Up @@ -422,7 +420,7 @@ public override int GetHashCode()
"0" => false,
{ } sValue when sValue.Equals("true", StringComparison.OrdinalIgnoreCase) => true,
{ } sValue when sValue.Equals("false", StringComparison.OrdinalIgnoreCase) => false,
_ => ExpressionEvaluator.ParseNumber(String, throwException: false) switch
_ => ExpressionEvaluator.ParseNumber<double>(String, throwException: false) switch
{
1 => true,
0 => false,
Expand Down Expand Up @@ -525,7 +523,7 @@ public bool TryDeserialize(Type type, out object? result)
// Support parsing numbers from strings for numeric types
case JsonValueKind.String when IsSupportedNumericType(underlyingType):
{
var parsedNumber = ExpressionEvaluator.ParseNumber(String, throwException: false);
var parsedNumber = ExpressionEvaluator.ParseNumber<double>(String, throwException: false);
if (parsedNumber.HasValue)
{
result = Convert.ChangeType(parsedNumber.Value, underlyingType, CultureInfo.InvariantCulture);
Expand Down Expand Up @@ -560,7 +558,7 @@ public bool TryDeserialize(Type type, out object? result)
try
{
var json = ToString();
result = JsonSerializer.Deserialize(json, type, _unsafeSerializerOptionsForSerializingDates);
result = JsonSerializer.Deserialize(json, type);
return true;
}
catch (JsonException)
Expand Down
20 changes: 20 additions & 0 deletions src/Altinn.App.Core/Models/Expressions/ExpressionFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,24 @@ public enum ExpressionFunction
/// If no translations exist for the current language, we will use the resources for "nb"
/// </summary>
text,

/// <summary>
/// Adding numbers. Use a period (.) as the decimals separator. Must be numeric values.
/// </summary>
plus,

/// <summary>
/// Subtracting all preceding values from the first. Use a period (.) as the decimals separator. Must be numeric values.
/// </summary>
minus,

/// <summary>
/// Multiplying numbers. Use a period (.) as the decimals separator. Must be numeric values.
/// </summary>
multiply,

/// <summary>
/// Divide numbers. Use a period (.) as the decimals separator. Must be numeric values.
/// </summary>
divide,
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests;

public class ExpressionTestCaseRoot
{
public ExpressionTestCaseRoot(TestCaseItem testCaseItem)
{
Name = testCaseItem.Name;
Expression = testCaseItem.Expression;
Expects = testCaseItem.Expects;
ExpectsFailure = testCaseItem.ExpectsFailure;
}

public ExpressionTestCaseRoot() { }

[JsonIgnore]
public string? Filename { get; set; }

Expand Down Expand Up @@ -42,6 +52,9 @@ public class ExpressionTestCaseRoot

public class TestCaseItem
{
[JsonPropertyName("name")]
public string? Name { get; set; }

[JsonPropertyName("expression")]
public required Expression Expression { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading
Loading