Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 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
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
138 changes: 117 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,51 @@ 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,
};
}

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 && ValidFloatingPoint(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 +884,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 +904,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 +913,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 +954,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 +965,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 +996,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 +1013,47 @@ 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;
}

var result = operation(aDecimal.Value, bDecimal.Value);

return (double)result;
}

private static bool ValidFloatingPoint(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}"
);
}
return true;
}

[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