Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
67 changes: 56 additions & 11 deletions src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ internal static async Task<ExpressionValue> EvaluateExpression_internal(
ExpressionFunction.argv => Argv(args, positionalArguments),
ExpressionFunction.gatewayAction => state.GetGatewayAction(),
ExpressionFunction.language => state.GetLanguage(),
// Calculations:
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;
decimal? start = PrepareNumericArg(args[1]);
decimal? end = args.Length == 3 ? PrepareNumericArg(args[2]) : null;
bool hasEnd = args.Length == 3;

if (start == null || (hasEnd && end == null))
Expand Down Expand Up @@ -806,7 +811,7 @@ ExpressionValue[] args
return !PrepareBooleanArg(args[0]);
}

private static (double?, double?) PrepareNumericArgs(ExpressionValue[] args)
private static (decimal?, decimal?) PrepareTwoNumericArgs(ExpressionValue[] args)
{
if (args.Length != 2)
{
Expand All @@ -820,7 +825,7 @@ private static (double?, double?) PrepareNumericArgs(ExpressionValue[] args)
return (a, b);
}

private static double? PrepareNumericArg(ExpressionValue arg)
private static decimal? PrepareNumericArg(ExpressionValue arg)
{
return arg.ValueKind switch
{
Expand Down Expand Up @@ -861,11 +866,11 @@ private static ExpressionValue IfImpl(ExpressionValue[] args)
);
}

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

internal static double? ParseNumber(string s, bool throwException = true)
internal static decimal? ParseNumber(string s, bool throwException = true)
{
if (_numberRegex.IsMatch(s) && double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
if (_numberRegex.IsMatch(s) && decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
{
return d;
}
Expand All @@ -879,7 +884,7 @@ private static ExpressionValue IfImpl(ExpressionValue[] args)

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

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

private static decimal? Plus(ExpressionValue[] args)
{
var (a, b) = PrepareTwoNumericArgs(args);
return a + b;
}

private static decimal? Minus(ExpressionValue[] args)
{
if (args.Length == 0)
{
return 0;
}
var (a, b) = PrepareTwoNumericArgs(args);
return a - b;
}

private static decimal? Multiply(ExpressionValue[] args)
{
if (args.Length <= 1)
{
throw new ExpressionEvaluatorTypeErrorException("Two arguments must be provided");
}
var (a, b) = PrepareTwoNumericArgs(args);
return a * b;
}

private static decimal? Divide(ExpressionValue[] args)
{
if (args.Length <= 1)
{
throw new ExpressionEvaluatorTypeErrorException("At least two arguments must be provided");
}
var (a, b) = PrepareTwoNumericArgs(args);
if (b == 0)
{
throw new ExpressionEvaluatorTypeErrorException("Argument two is 0, cannot divide by 0");
}
return a / b;
}

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

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

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

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

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

if (a is null || b is null)
{
Expand Down
41 changes: 28 additions & 13 deletions src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ namespace Altinn.App.Core.Internal.Expressions;
{
private readonly string? _stringValue = null;

// 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;
// decimal is a value type where nullable takes extra space, and we only read it when it should be set
private readonly decimal _numberValue = 0;

// private readonly Dictionary<string, ExpressionValue>? _objectValue = null;
// private readonly ExpressionValue[]? _arrayValue = null;
Expand Down Expand Up @@ -64,7 +64,7 @@ private ExpressionValue(bool? value)
}
}

private ExpressionValue(double? value)
private ExpressionValue(decimal? value)
{
if (value.HasValue)
{
Expand Down Expand Up @@ -101,9 +101,9 @@ private ExpressionValue(string? value)
public static implicit operator ExpressionValue(bool? value) => new(value);

/// <summary>
/// Convert a nullable double to ExpressionValue
/// Convert a nullable decimal to ExpressionValue
/// </summary>
public static implicit operator ExpressionValue(double? value) => new(value);
public static implicit operator ExpressionValue(decimal? value) => new(value);

/// <summary>
/// Convert a nullable string to ExpressionValue
Expand Down Expand Up @@ -131,8 +131,8 @@ public static ExpressionValue FromObject(object? value)
null => Null,
bool boolValue => boolValue,
string stringValue => stringValue,
float numberValue => numberValue,
double numberValue => numberValue,
float numberValue => ValidateAndCastFloatingPoint(numberValue), // expressions uses decimal which needs an explicit cast
double numberValue => ValidateAndCastFloatingPoint(numberValue), // expressions uses decimal which needs an explicit cast
byte numberValue => numberValue,
sbyte numberValue => numberValue,
short numberValue => numberValue,
Expand All @@ -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 => numberValue,
DateTime dateTimeValue => JsonSerializer
.Serialize(dateTimeValue, _unsafeSerializerOptionsForSerializingDates)
.Trim(
Expand Down Expand Up @@ -229,7 +227,7 @@ public static ExpressionValue FromObject(object? value)
/// <summary>
/// Get the value as a number (or throw if it isn't a number ValueKind)
/// </summary>
public double Number =>
public decimal Number =>
ValueKind switch
{
JsonValueKind.Number => _numberValue,
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 Expand Up @@ -589,6 +587,23 @@ private static bool IsSupportedNumericType(Type type)
|| type == typeof(ushort)
|| type == typeof(sbyte);
}

private static decimal? ValidateAndCastFloatingPoint(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 (decimal?)value;
}
}

/// <summary>
Expand All @@ -604,7 +619,7 @@ public override ExpressionValue Read(ref Utf8JsonReader reader, Type typeToConve
JsonTokenType.True => true,
JsonTokenType.False => false,
JsonTokenType.String => reader.GetString(),
JsonTokenType.Number => reader.GetDouble(),
JsonTokenType.Number => reader.GetDecimal(),
JsonTokenType.Null => ExpressionValue.Null,
// JsonTokenType.StartObject => ReadObject(ref reader),
// JsonTokenType.StartArray => ReadArray(ref reader),
Expand Down
4 changes: 2 additions & 2 deletions src/Altinn.App.Core/Models/Expressions/ExpressionConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public static Expression ReadStatic(JsonElement element) =>
JsonValueKind.True => new Expression(true),
JsonValueKind.False => new Expression(false),
JsonValueKind.String => new Expression(element.GetString()),
JsonValueKind.Number => new Expression(element.GetDouble()),
JsonValueKind.Number => new Expression(element.GetDecimal()),
JsonValueKind.Null => new Expression(ExpressionValue.Null),
JsonValueKind.Array => ReadArray(element),
JsonValueKind.Object => throw new JsonException("Invalid type \"object\""),
Expand All @@ -44,7 +44,7 @@ public static Expression ReadStatic(ref Utf8JsonReader reader, JsonSerializerOpt
JsonTokenType.True => new Expression(true),
JsonTokenType.False => new Expression(false),
JsonTokenType.String => new Expression(reader.GetString()),
JsonTokenType.Number => new Expression(reader.GetDouble()),
JsonTokenType.Number => new Expression(reader.GetDecimal()),
JsonTokenType.Null => new Expression(ExpressionValue.Null),
JsonTokenType.StartArray => ReadArray(ref reader, options),
JsonTokenType.StartObject => throw new JsonException("Invalid type \"object\""),
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 @@ -203,6 +203,26 @@ public TestFunctions(ITestOutputHelper output)
[SharedTest("stringLength")]
public async Task StringLength_Theory(string testName, string folder) => await RunTestCase(testName, folder);

[Theory]
[SharedTest("plus")]
public async Task Plus_Theory(string testName, string folder) => await RunTestCase(testName, folder);

[Theory]
[SharedTest("minus")]
public async Task Minus_Theory(string testName, string folder) => await RunTestCase(testName, folder);

[Theory]
[SharedTest("multiply")]
public async Task Multiply_Theory(string testName, string folder) => await RunTestCase(testName, folder);

[Theory]
[SharedTest("divide")]
public async Task Divide_Theory(string testName, string folder) => await RunTestCase(testName, folder);

[Theory]
[SharedTest("average")]
public async Task Average_Theory(string testName, string folder) => await RunTestCase(testName, folder);

[Theory]
[SharedTest("round")]
public async Task Round_Theory(string testName, string folder) => await RunTestCase(testName, folder);
Expand Down Expand Up @@ -431,7 +451,7 @@ private async Task RunTestCaseItem(
Assert.Null(result);
break;
case JsonValueKind.Number:
Assert.Equal(test.Expects.GetDouble(), result);
Assert.Equal(test.Expects.GetDecimal(), result);
break;
case JsonValueKind.Undefined:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should throw exception when trying to divide by zero",
"expression": ["divide", 15, 0],
"expectsFailure": "At least one of the arguments after the first is 0, cannot divide by 0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should divide the first argument with the second",
"expression": ["divide", 0.1, 0.2],
"expects": 0.5
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should throw exception when more then two arguments is present",
"expression": ["divide", 0.1, 0.2, 15, 1.50],
"expectsFailure": "Invalid number of args for compare"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should divide the first argument with the second",
"expression": ["divide", -0.1, -0.2],
"expects": 0.5
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should throw exception when both of the arguments are null",
"expression": ["divide", null, null],
"expectsFailure": "At least one of the arguments is not a number"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should throw exception when less than two arguments are provided",
"expression": ["divide", 2.1],
"expectsFailure": "At least two arguments must be provided"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should throw exception when one of the arguments is null",
"expression": ["divide", null, 2.1],
"expectsFailure": "At least one of the arguments is not a number"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should subtract the second argument from the first",
"expression": ["minus", 0.1, 0.2],
"expects": -0.1
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should throw exception when more then two arguments is present",
"expression": ["minus", 0.1, 0.2, 15, 1.50],
"expectsFailure": "Invalid number of args for compare"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should subtract the second negative argument from the first",
"expression": ["minus", -0.1, -0.2],
"expects": 0.1
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should return 0 when no arguments are provided",
"expression": ["minus"],
"expects": 0
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should throw exception when both of the arguments are null",
"expression": ["minus", null, null],
"expectsFailure": "At least one of the arguments is not a number"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should throw exception when one of the arguments is null",
"expression": ["minus", null, 2.1],
"expectsFailure": "At least one of the arguments is not a number"
}
Loading
Loading