diff --git a/.github/workflows/check-label-added.yml b/.github/workflows/check-label-added.yml
index 8ca645c974..cea2c9297f 100644
--- a/.github/workflows/check-label-added.yml
+++ b/.github/workflows/check-label-added.yml
@@ -1,6 +1,9 @@
name: "Label Check"
on:
pull_request:
+ branches:
+ - "main"
+ - "release/**"
types: [opened, edited, labeled, unlabeled, synchronize]
jobs:
diff --git a/.github/workflows/dotnet-test.yml b/.github/workflows/dotnet-test.yml
index f2eea3bd6d..5efa6d50d7 100644
--- a/.github/workflows/dotnet-test.yml
+++ b/.github/workflows/dotnet-test.yml
@@ -6,13 +6,17 @@ on:
- "main"
- "release/**"
pull_request:
- branches:
- - "main"
- - "release/**"
+ types:
+ - opened
+ - synchronize
+ - reopened
+ - edited
workflow_dispatch:
jobs:
analyze:
+ # Only run on pull_request edit if the base branch was changed, to avoid running when only description or title was edited.
+ if: github.event.action != 'edited' || github.event.changes.base.ref != null
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
diff --git a/src/Altinn.App.Core/Configuration/AppSettings.cs b/src/Altinn.App.Core/Configuration/AppSettings.cs
index 0be2718283..108e99c838 100644
--- a/src/Altinn.App.Core/Configuration/AppSettings.cs
+++ b/src/Altinn.App.Core/Configuration/AppSettings.cs
@@ -8,14 +8,19 @@ public class AppSettings
{
#pragma warning disable CA1707 // Identifiers should not contain underscores
///
- /// Constant for the location of json schema file
+ /// Constant for the suffix on json schema file names
///
public const string JSON_SCHEMA_FILENAME = "schema.json";
///
- /// Constant for the location of validation configuration file
+ /// Constant for the suffix on validation file names
///
public const string VALIDATION_CONFIG_FILENAME = "validation.json";
+
+ ///
+ /// Constant for the suffix on calculation file names
+ ///
+ public const string CALCULATION_CONFIG_FILENAME = "calculation.json";
#pragma warning restore CA1707 // Identifiers should not contain underscores
///
@@ -95,15 +100,20 @@ public class AppSettings
public string RuleConfigurationJSONFileName { get; set; } = "RuleConfiguration.json";
///
- /// Gets or sets The JSON schema file name
+ /// Gets or sets the file names suffix for the json schema files
///
public string JsonSchemaFileName { get; set; } = JSON_SCHEMA_FILENAME;
///
- /// Gets or sets The JSON schema file name
+ /// Gets or sets the file names suffix for the validation files
///
public string ValidationConfigurationFileName { get; set; } = VALIDATION_CONFIG_FILENAME;
+ ///
+ /// Gets or sets the file names suffix for the calculation files
+ ///
+ public string CalculationConfigurationFileName { get; set; } = CALCULATION_CONFIG_FILENAME;
+
///
/// Gets or sets the filename for application meta data
///
diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs
index 5963d2d24a..79d8203e3d 100644
--- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs
+++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs
@@ -189,6 +189,7 @@ IWebHostEnvironment env
#pragma warning restore CS0618, CS0612 // Type or member is obsolete
services.TryAddTransient();
services.TryAddTransient();
+ services.TryAddTransient();
services.TryAddTransient();
services.TryAddTransient();
services.TryAddTransient();
@@ -196,6 +197,7 @@ IWebHostEnvironment env
services.TryAddTransient();
services.TryAddTransient();
services.TryAddTransient();
+ services.AddTransient();
services.AddSingleton();
services.AddTransient();
services.AddSingleton();
diff --git a/src/Altinn.App.Core/Features/DataLists/InstanceDataListsFactory.cs b/src/Altinn.App.Core/Features/DataLists/InstanceDataListsFactory.cs
index f04d517c45..a78f276089 100644
--- a/src/Altinn.App.Core/Features/DataLists/InstanceDataListsFactory.cs
+++ b/src/Altinn.App.Core/Features/DataLists/InstanceDataListsFactory.cs
@@ -11,7 +11,7 @@ public class InstanceDataListsFactory
private readonly AppImplementationFactory _appImplementationFactory;
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
public InstanceDataListsFactory(IServiceProvider serviceProvider)
{
diff --git a/src/Altinn.App.Core/Features/DataProcessing/DataModelFieldCalculator.cs b/src/Altinn.App.Core/Features/DataProcessing/DataModelFieldCalculator.cs
new file mode 100644
index 0000000000..08b174ba36
--- /dev/null
+++ b/src/Altinn.App.Core/Features/DataProcessing/DataModelFieldCalculator.cs
@@ -0,0 +1,198 @@
+using System.Text.Json;
+using Altinn.App.Core.Internal.App;
+using Altinn.App.Core.Internal.Data;
+using Altinn.App.Core.Internal.Expressions;
+using Altinn.App.Core.Models;
+using Altinn.App.Core.Models.Layout;
+using Altinn.Platform.Storage.Interface.Models;
+using Microsoft.Extensions.Logging;
+using ComponentContext = Altinn.App.Core.Models.Expressions.ComponentContext;
+
+namespace Altinn.App.Core.Features.DataProcessing;
+
+internal sealed class DataModelFieldCalculator
+{
+ private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
+ {
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ };
+
+ private readonly ILogger _logger;
+ private readonly IAppResources _appResourceService;
+ private readonly IDataElementAccessChecker _dataElementAccessChecker;
+ private readonly Telemetry? _telemetry;
+
+ public DataModelFieldCalculator(
+ ILogger logger,
+ IAppResources appResourceService,
+ IDataElementAccessChecker dataElementAccessChecker,
+ Telemetry? telemetry = null
+ )
+ {
+ _logger = logger;
+ _appResourceService = appResourceService;
+ _dataElementAccessChecker = dataElementAccessChecker;
+ _telemetry = telemetry;
+ }
+
+ public async Task Calculate(IInstanceDataAccessor dataAccessor, string taskId)
+ {
+ using var activity = _telemetry?.StartCalculateActivity(dataAccessor.Instance.Id, taskId);
+ foreach (var (dataType, dataElement) in dataAccessor.GetDataElementsWithFormDataForTask(taskId))
+ {
+ if (await _dataElementAccessChecker.CanRead(dataAccessor.Instance, dataType) is false)
+ {
+ continue;
+ }
+
+ var calculationConfig = _appResourceService.GetCalculationConfiguration(dataType.Id);
+ if (!string.IsNullOrEmpty(calculationConfig))
+ {
+ await CalculateFormData(dataAccessor, dataElement, calculationConfig);
+ }
+ }
+ }
+
+ internal async Task CalculateFormData(
+ IInstanceDataAccessor dataAccessor,
+ DataElement dataElement,
+ string rawCalculationConfig
+ )
+ {
+ DataElementIdentifier dataElementIdentifier = dataElement;
+ var dataModelFieldCalculations = ParseDataModelFieldCalculationConfig(rawCalculationConfig);
+ var formDataWrapper = await dataAccessor.GetFormDataWrapper(dataElement);
+
+ foreach (var (baseField, calculation) in dataModelFieldCalculations)
+ {
+ var resolvedFields = formDataWrapper.GetResolvedKeys(baseField);
+ foreach (var resolvedField in resolvedFields)
+ {
+ var resolvedFieldReference = new DataReference()
+ {
+ Field = resolvedField,
+ DataElementIdentifier = dataElementIdentifier,
+ };
+ var context = new ComponentContext(
+ dataAccessor,
+ component: null,
+ rowIndices: ExpressionHelper.GetRowIndices(resolvedField),
+ dataElementIdentifier: dataElementIdentifier
+ );
+ var positionalArguments = new ExpressionValue[] { resolvedField };
+
+ await RunCalculation(
+ dataAccessor,
+ context,
+ formDataWrapper,
+ resolvedFieldReference,
+ positionalArguments,
+ calculation
+ );
+ }
+ }
+ }
+
+ private async Task RunCalculation(
+ IInstanceDataAccessor dataAccessor,
+ ComponentContext context,
+ IFormDataWrapper formDataWrapper,
+ DataReference resolvedField,
+ ExpressionValue[] positionalArguments,
+ DataModelFieldCalculation calculation
+ )
+ {
+ try
+ {
+ var calculationResult = await ExpressionEvaluator.EvaluateExpressionToExpressionValue(
+ dataAccessor,
+ calculation.Expression,
+ context,
+ positionalArguments
+ );
+ if (!formDataWrapper.Set(resolvedField.Field, calculationResult))
+ {
+ _logger.LogWarning(
+ "Could not set calculated value for field {Field} in data element {DataElementId}. "
+ + "This is because the type conversion failed.",
+ resolvedField.Field,
+ resolvedField.DataElementIdentifier.Id
+ );
+ }
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Error while evaluating calculation for field {Field}", resolvedField.Field);
+ throw;
+ }
+ }
+
+ private Dictionary ParseDataModelFieldCalculationConfig(
+ string rawCalculationConfig
+ )
+ {
+ JsonDocument calculationConfigDocument;
+ try
+ {
+ calculationConfigDocument = JsonDocument.Parse(
+ rawCalculationConfig,
+ new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip }
+ );
+ }
+ catch (JsonException e)
+ {
+ _logger.LogError(e, "Failed to parse calculation configuration JSON");
+ return new Dictionary();
+ }
+ using (calculationConfigDocument)
+ {
+ var dataModelFieldCalculations = new Dictionary();
+ var hasCalculations = calculationConfigDocument.RootElement.TryGetProperty(
+ "calculations",
+ out JsonElement calculationsObject
+ );
+ if (hasCalculations)
+ {
+ foreach (var calculationArray in calculationsObject.EnumerateObject())
+ {
+ var field = calculationArray.Name;
+ var calculation = calculationArray.Value;
+ var resolvedDataModelFieldCalculation = ResolveDataModelFieldCalculation(field, calculation);
+ if (resolvedDataModelFieldCalculation == null)
+ {
+ _logger.LogError("Calculation for field {Field} could not be resolved", field);
+ continue;
+ }
+ dataModelFieldCalculations[field] = resolvedDataModelFieldCalculation;
+ }
+ }
+ return dataModelFieldCalculations;
+ }
+ }
+
+ private DataModelFieldCalculation? ResolveDataModelFieldCalculation(string field, JsonElement definition)
+ {
+ var dataModelFieldCalculationDefinition = definition.Deserialize(
+ _jsonSerializerOptions
+ );
+ if (dataModelFieldCalculationDefinition == null)
+ {
+ _logger.LogError("Calculation for field {Field} could not be parsed", field);
+ return null;
+ }
+
+ if (dataModelFieldCalculationDefinition.Expression == null)
+ {
+ _logger.LogError("Calculation for field {Field} is missing expression", field);
+ return null;
+ }
+
+ var dataModelFieldCalculation = new DataModelFieldCalculation
+ {
+ Expression = dataModelFieldCalculationDefinition.Expression.Value,
+ };
+
+ return dataModelFieldCalculation;
+ }
+}
diff --git a/src/Altinn.App.Core/Features/DataProcessing/DataModelFieldCalculatorProcessor.cs b/src/Altinn.App.Core/Features/DataProcessing/DataModelFieldCalculatorProcessor.cs
new file mode 100644
index 0000000000..2491a5c484
--- /dev/null
+++ b/src/Altinn.App.Core/Features/DataProcessing/DataModelFieldCalculatorProcessor.cs
@@ -0,0 +1,37 @@
+using Altinn.App.Core.Models;
+
+namespace Altinn.App.Core.Features.DataProcessing;
+
+///
+/// Processing data model fields that is calculated by expressions provided in [modelName].calculation.json.
+///
+internal sealed class DataModelFieldCalculatorProcessor : IDataWriteProcessor
+{
+ private readonly DataModelFieldCalculator _dataModelFieldCalculator;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ public DataModelFieldCalculatorProcessor(DataModelFieldCalculator dataModelFieldCalculator)
+ {
+ _dataModelFieldCalculator = dataModelFieldCalculator;
+ }
+
+ ///
+ /// Processes data write operations on properties in the data model.
+ ///
+ /// Object to fetch data elements not included in changes
+ /// The current task ID
+ /// Not used in this context
+ /// Not used in this context
+ public async Task ProcessDataWrite(
+ IInstanceDataMutator instanceDataMutator,
+ string taskId,
+ DataElementChanges changes,
+ string? language
+ )
+ {
+ await _dataModelFieldCalculator.Calculate(instanceDataMutator, taskId);
+ }
+}
diff --git a/src/Altinn.App.Core/Features/DataProcessing/GenericDataProcessor.cs b/src/Altinn.App.Core/Features/DataProcessing/GenericDataProcessor.cs
index 317191959c..acc961d743 100644
--- a/src/Altinn.App.Core/Features/DataProcessing/GenericDataProcessor.cs
+++ b/src/Altinn.App.Core/Features/DataProcessing/GenericDataProcessor.cs
@@ -16,7 +16,7 @@ public abstract class GenericDataProcessor : IDataProcessor
///
/// Do changes to the model before it is written to storage, and report back to frontend.
- /// Tyipically used to add calculated values to the model.
+ /// Typically used to add calculated values to the model.
///
public abstract Task ProcessDataWrite(
Instance instance,
diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.ApplicationMetadata.Service.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.ApplicationMetadata.Service.cs
index 399cb752f8..ed7a7ae451 100644
--- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.ApplicationMetadata.Service.cs
+++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.ApplicationMetadata.Service.cs
@@ -49,6 +49,9 @@ partial class Telemetry
internal Activity? StartGetValidationConfigurationActivity() =>
ActivitySource.StartActivity($"{Prefix}.GetValidationConfiguration");
+ internal Activity? StartGetCalculationConfigurationActivity() =>
+ ActivitySource.StartActivity($"{Prefix}.GetCalculationConfiguration");
+
internal Activity? StartGetLayoutModelActivity() => ActivitySource.StartActivity($"{Prefix}.GetLayoutModel");
internal Activity? StartGetClassRefActivity() => ActivitySource.StartActivity($"{Prefix}.GetClassRef");
diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.DataFieldValueCalculator.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.DataFieldValueCalculator.cs
new file mode 100644
index 0000000000..f320c85535
--- /dev/null
+++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.DataFieldValueCalculator.cs
@@ -0,0 +1,20 @@
+using System.Diagnostics;
+using static Altinn.App.Core.Features.Telemetry.DataModelFieldCalculator;
+
+namespace Altinn.App.Core.Features;
+
+partial class Telemetry
+{
+ internal Activity? StartCalculateActivity(string instanceId, string taskId)
+ {
+ var activity = ActivitySource.StartActivity($"{Prefix}.Calculate");
+ activity?.SetInstanceId(instanceId);
+ activity?.SetTaskId(taskId);
+ return activity;
+ }
+
+ internal static class DataModelFieldCalculator
+ {
+ internal const string Prefix = "DataModelFieldCalculator";
+ }
+}
diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs
index 7f2b7c26cd..a2ece68c1c 100644
--- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs
+++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs
@@ -25,7 +25,6 @@ public class ExpressionValidator : IValidator
private readonly ILogger _logger;
private readonly IAppResources _appResourceService;
- private readonly ILayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer;
private readonly IAppMetadata _appMetadata;
private readonly IDataElementAccessChecker _dataElementAccessChecker;
@@ -35,14 +34,12 @@ public class ExpressionValidator : IValidator
public ExpressionValidator(
ILogger logger,
IAppResources appResourceService,
- ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer,
IAppMetadata appMetadata,
IServiceProvider serviceProvider
)
{
_logger = logger;
_appResourceService = appResourceService;
- _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer;
_appMetadata = appMetadata;
_dataElementAccessChecker = serviceProvider.GetRequiredService();
}
@@ -96,7 +93,7 @@ public async Task> Validate(
var validationConfig = _appResourceService.GetValidationConfiguration(dataType.Id);
if (!string.IsNullOrEmpty(validationConfig))
{
- var issues = await ValidateFormData(dataElement, dataAccessor, validationConfig, taskId, language);
+ var issues = await ValidateFormData(dataElement, dataAccessor, validationConfig);
validationIssues.AddRange(issues);
}
}
@@ -108,19 +105,12 @@ public async Task> Validate(
internal async Task> ValidateFormData(
DataElement dataElement,
IInstanceDataAccessor dataAccessor,
- string rawValidationConfig,
- string taskId,
- string? language
+ string rawValidationConfig
)
{
- var evaluatorState = await _layoutEvaluatorStateInitializer.Init(
- dataAccessor,
- taskId,
- gatewayAction: null,
- language
- );
+ var formDataWrapper = await dataAccessor.GetFormDataWrapper(dataElement);
var hiddenFields = await LayoutEvaluator.GetHiddenFieldsForRemoval(
- evaluatorState,
+ dataAccessor.GetLayoutEvaluatorState(),
evaluateRemoveWhenHidden: false
);
@@ -130,9 +120,11 @@ internal async Task> ValidateFormData(
foreach (var (baseField, validations) in expressionValidations)
{
- var resolvedFields = await evaluatorState.GetResolvedKeys(
- new DataReference() { Field = baseField, DataElementIdentifier = dataElementIdentifier }
- );
+ var resolvedFields = await dataAccessor
+ .GetLayoutEvaluatorState()
+ .GetResolvedKeys(
+ new DataReference() { Field = baseField, DataElementIdentifier = dataElementIdentifier }
+ );
foreach (var resolvedField in resolvedFields)
{
if (
@@ -147,16 +139,17 @@ internal async Task> ValidateFormData(
var context = new ComponentContext(
dataAccessor,
component: null,
- rowIndices: GetRowIndices(resolvedField.Field),
+ rowIndices: ExpressionHelper.GetRowIndices(resolvedField.Field),
dataElementIdentifier: resolvedField.DataElementIdentifier
);
- var positionalArguments = new object[] { resolvedField.Field };
+ var positionalArguments = new ExpressionValue[] { resolvedField.Field };
foreach (var validation in validations)
{
await RunValidation(
- evaluatorState,
+ dataAccessor,
validationIssues,
resolvedField,
+ formDataWrapper,
context,
positionalArguments,
validation
@@ -168,66 +161,33 @@ await RunValidation(
return validationIssues;
}
- private static int[]? GetRowIndices(string field)
- {
- Span rowIndicesSpan = stackalloc int[200]; // Assuming max 200 indices for simplicity recursion will never go deeper than 3-4
- int count = 0;
- for (int index = 0; index < field.Length; index++)
- {
- if (field[index] == '[')
- {
- int startIndex = index + 1;
- int endIndex = field.IndexOf(']', startIndex);
- if (endIndex == -1)
- {
- throw new InvalidOperationException($"Unpaired [ character in field: {field}");
- }
- string indexString = field[startIndex..endIndex];
- if (int.TryParse(indexString, out int rowIndex))
- {
- rowIndicesSpan[count] = rowIndex;
- count++;
- index = endIndex; // Move index to the end of the current bracket
- }
- else
- {
- throw new InvalidOperationException(
- $"Invalid row index in field: {field} at position {startIndex}"
- );
- }
- }
- }
- if (count == 0)
- {
- return null; // No indices found
- }
- int[] rowIndices = new int[count];
- rowIndicesSpan[..count].CopyTo(rowIndices);
- return rowIndices;
- }
-
private async Task RunValidation(
- LayoutEvaluatorState evaluatorState,
+ IInstanceDataAccessor dataAccessor,
List validationIssues,
DataReference resolvedField,
+ IFormDataWrapper formDataWrapper,
ComponentContext context,
- object[] positionalArguments,
+ ExpressionValue[] positionalArguments,
ExpressionValidation validation
)
{
try
{
- var validationResult = await ExpressionEvaluator.EvaluateExpression(
- evaluatorState,
+ if (formDataWrapper.Get(resolvedField.Field) == null)
+ {
+ return; // Assume that the required validator will catch empty fields.
+ }
+ var validationResult = await ExpressionEvaluator.EvaluateExpressionToExpressionValue(
+ dataAccessor,
validation.Condition,
context,
positionalArguments
);
- switch (validationResult)
+ switch (validationResult.ValueKind)
{
- case true:
- var message = await ExpressionEvaluator.EvaluateExpression(
- evaluatorState,
+ case JsonValueKind.True:
+ var message = await ExpressionEvaluator.EvaluateExpressionToExpressionValue(
+ dataAccessor,
validation.Message,
context,
positionalArguments
@@ -238,13 +198,13 @@ ExpressionValidation validation
Field = resolvedField.Field,
DataElementId = resolvedField.DataElementIdentifier.Id,
Severity = validation.Severity ?? ValidationIssueSeverity.Error,
- CustomTextKey = message as string ?? "",
- Code = message as string ?? "",
+ Code = message.ToStringForText(),
+ CustomTextKey = message.ToStringForText(),
};
validationIssues.Add(validationIssue);
break;
- case false:
+ case JsonValueKind.False:
break;
default:
throw new ArgumentException(
diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs
index 6c2fa1b89a..df16083c49 100644
--- a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs
+++ b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs
@@ -67,7 +67,7 @@ ReadOnlySpan rowIndexes
return currentModel;
}
- var (key, groupIndex) = ParseKeyPart(keys[index]);
+ var (key, groupIndex, _) = ParseKeyPart(keys[index]);
var prop = Array.Find(currentModel.GetType().GetProperties(), p => IsPropertyWithJsonName(p, key));
var childModel = prop?.GetValue(currentModel);
if (childModel is null)
@@ -111,7 +111,10 @@ ReadOnlySpan rowIndexes
}
///
- /// Get all valid indexed keys for the field, depending on the number of rows in repeating groups
+ /// Get all valid indexed keys for the field, depending on the number of rows in repeating groups.
+ /// A collection in the middle of the path is always expanded over its rows. For a collection at
+ /// the end of the path, "group" refers to the collection itself, "group[]" enumerates every row,
+ /// and "group[n]" refers to a single row.
///
///
/// GetResolvedKeys("data.bedrifter.styre.medlemmer") =>
@@ -120,6 +123,13 @@ ReadOnlySpan rowIndexes
/// "data.bedrifter[1].styre.medlemmer"
/// ...
/// ]
+ /// GetResolvedKeys("data.bedrifter[].styre.medlemmer[]") =>
+ /// [
+ /// "data.bedrifter[0].styre.medlemmer[0]",
+ /// "data.bedrifter[0].styre.medlemmer[1]",
+ /// "data.bedrifter[1].styre.medlemmer[0]",
+ /// ...
+ /// ]
///
public string[] GetResolvedKeys(string field)
{
@@ -129,7 +139,8 @@ public string[] GetResolvedKeys(string field)
}
var fieldParts = field.Split('.');
- return GetResolvedKeysRecursive(fieldParts, _dataModel);
+ return GetResolvedKeysRecursive(fieldParts, _dataModel, _dataModel.GetType(), currentIndex: 0, currentKey: "")
+ .ToArray();
}
private static string JoinFieldKeyParts(string? currentKey, string? key)
@@ -146,63 +157,101 @@ private static string JoinFieldKeyParts(string? currentKey, string? key)
return currentKey + "." + key;
}
- private static string[] GetResolvedKeysRecursive(
+ private static IEnumerable GetResolvedKeysRecursive(
string[] keyParts,
- object currentModel,
- int currentIndex = 0,
- string currentKey = ""
+ object? currentModel,
+ Type currentType,
+ int currentIndex,
+ string currentKey
)
{
- if (currentModel is null)
- {
- return [];
- }
-
if (currentIndex == keyParts.Length)
{
return [currentKey];
}
- var (key, groupIndex) = ParseKeyPart(keyParts[currentIndex]);
- var prop = Array.Find(currentModel.GetType().GetProperties(), p => IsPropertyWithJsonName(p, key));
- var childModel = prop?.GetValue(currentModel);
- if (childModel is null)
+ var (key, groupIndex, emptyIndex) = ParseKeyPart(keyParts[currentIndex]);
+ var lookupType = currentModel?.GetType() ?? currentType;
+ var prop = Array.Find(lookupType.GetProperties(), p => IsPropertyWithJsonName(p, key));
+ if (prop is null)
{
return [];
}
- if (childModel is not string && childModel is System.Collections.IEnumerable childModelList)
+ var childType = prop.PropertyType;
+ bool isLastPart = currentIndex == keyParts.Length - 1;
+
+ // Collection (but not string)
+ if (childType != typeof(string) && childType.IsAssignableTo(typeof(System.Collections.IEnumerable)))
{
- // childModel is a list
- if (groupIndex is null)
+ // A bare collection as the last part of the path (e.g. "group") refers to the
+ // collection itself, not its rows, so we return the key without enumerating (just
+ // like a non-collection leaf). Use "group[]" to enumerate every row, or "group[0]"
+ // to refer to a single row.
+ if (isLastPart && groupIndex is null && !emptyIndex)
+ {
+ return [JoinFieldKeyParts(currentKey, key)];
+ }
+
+ // Indexing into, or descending through, the collection requires the instance.
+ var childModel = currentModel is not null ? prop.GetValue(currentModel) : null;
+ if (childModel is not System.Collections.IEnumerable childModelList)
+ {
+ // Null collection: there are no rows to descend into, so nothing resolves.
+ return [];
+ }
+
+ if (groupIndex is not null)
{
- // Index not specified, recurse on all elements
- int i = 0;
- var resolvedKeys = new List();
- foreach (var child in childModelList)
+ var elementAt = GetElementAt(childModelList, groupIndex.Value);
+ if (elementAt is null)
+ return [];
+ return GetResolvedKeysRecursive(
+ keyParts,
+ elementAt,
+ elementAt.GetType(),
+ currentIndex + 1,
+ JoinFieldKeyParts(currentKey, $"{key}[{groupIndex.Value}]")
+ );
+ }
+
+ // Enumerate every row: either an unindexed collection in the middle of the path
+ // (descending towards the rest of the path), or "group[]" at the end.
+ var resolvedKeys = new List();
+ int i = 0;
+ foreach (var child in childModelList)
+ {
+ if (child is not null) // null rows can't be set/resolved, skip them
{
- var newResolvedKeys = GetResolvedKeysRecursive(
- keyParts,
- child,
- currentIndex + 1,
- JoinFieldKeyParts(currentKey, key + "[" + i + "]")
+ resolvedKeys.AddRange(
+ GetResolvedKeysRecursive(
+ keyParts,
+ child,
+ child.GetType(),
+ currentIndex + 1,
+ JoinFieldKeyParts(currentKey, $"{key}[{i}]")
+ )
);
- resolvedKeys.AddRange(newResolvedKeys);
- i++;
}
- return resolvedKeys.ToArray();
+ i++;
}
- // Index specified, recurse on that element
- return GetResolvedKeysRecursive(
- keyParts,
- childModel,
- currentIndex + 1,
- JoinFieldKeyParts(currentKey, key + "[" + groupIndex + "]")
- );
+ return resolvedKeys;
}
- // Otherwise, just recurse
- return GetResolvedKeysRecursive(keyParts, childModel, currentIndex + 1, JoinFieldKeyParts(currentKey, key));
+ // Non-collection: resolve the key (even if the value is null) ...
+ if (isLastPart)
+ {
+ return [JoinFieldKeyParts(currentKey, key)];
+ }
+ // ... otherwise traverse, falling back to the declared type when the value is null
+ var childValue = currentModel is not null ? prop.GetValue(currentModel) : null;
+ return GetResolvedKeysRecursive(
+ keyParts,
+ childValue,
+ childType,
+ currentIndex + 1,
+ JoinFieldKeyParts(currentKey, key)
+ );
}
private static object? GetElementAt(System.Collections.IEnumerable enumerable, int index)
@@ -225,18 +274,24 @@ private static string[] GetResolvedKeysRecursive(
TimeSpan.FromMilliseconds(2)
);
- private static (string key, int? index) ParseKeyPart(string keyPart)
+ private static (string key, int? index, bool emptyIndex) ParseKeyPart(string keyPart)
{
if (keyPart.Length == 0)
{
throw new DataModelException("Tried to parse empty part of dataModel key");
}
- if (keyPart.Last() != ']')
+ if (keyPart[^1] != ']')
+ {
+ return (keyPart, null, false);
+ }
+ // "group[]" refers to every row of the collection (as opposed to "group", which refers
+ // to the collection itself, or "group[n]", which refers to a single row).
+ if (keyPart.EndsWith("[]", StringComparison.Ordinal))
{
- return (keyPart, null);
+ return (keyPart[..^2], null, true);
}
var match = _keyPartRegex.Match(keyPart);
- return (match.Groups[1].Value, int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture));
+ return (match.Groups[1].Value, int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture), false);
}
private static void AddIndexesRecursive(
@@ -250,7 +305,7 @@ ReadOnlySpan indexes
{
return;
}
- var (key, groupIndex) = ParseKeyPart(keys[0]);
+ var (key, groupIndex, _) = ParseKeyPart(keys[0]);
var prop = Array.Find(currentModelType.GetProperties(), p => IsPropertyWithJsonName(p, key));
if (prop is null)
{
@@ -358,7 +413,7 @@ public void RemoveField(string field, RowRemovalOption rowRemovalOption)
{
var fieldSplit = field.Split('.');
var keys = fieldSplit[0..^1];
- var (lastKey, lastGroupIndex) = ParseKeyPart(fieldSplit[^1]);
+ var (lastKey, lastGroupIndex, _) = ParseKeyPart(fieldSplit[^1]);
var containingObject = GetModelDataRecursive(keys, 0, _dataModel, default);
if (containingObject is null)
diff --git a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs
index 95be38aa60..2d36cb2524 100644
--- a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs
+++ b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs
@@ -523,4 +523,21 @@ private static byte[] ReadFileContentsFromLegalPath(string legalPath, string fil
return filedata;
}
+
+ ///
+ public string? GetCalculationConfiguration(string dataTypeId)
+ {
+ using var activity = _telemetry?.StartGetCalculationConfigurationActivity();
+ string legalPath = Path.Join(_settings.AppBasePath, _settings.ModelsFolder);
+ string filename = Path.Join(legalPath, $"{dataTypeId}.{_settings.CalculationConfigurationFileName}");
+ PathHelper.EnsureLegalPath(legalPath, filename);
+
+ string? fileData = null;
+ if (File.Exists(filename))
+ {
+ fileData = File.ReadAllText(filename, Encoding.UTF8);
+ }
+
+ return fileData;
+ }
}
diff --git a/src/Altinn.App.Core/Internal/App/IAppResources.cs b/src/Altinn.App.Core/Internal/App/IAppResources.cs
index 6a08382f28..d6fd507522 100644
--- a/src/Altinn.App.Core/Internal/App/IAppResources.cs
+++ b/src/Altinn.App.Core/Internal/App/IAppResources.cs
@@ -166,4 +166,10 @@ public interface IAppResources
/// Gets the validation configuration for a given data type
///
string? GetValidationConfiguration(string dataTypeId);
+
+ ///
+ /// Gets the calculation configuration for a given data type
+ ///
+ /// The calculation configuration in JSON format represented as string
+ string? GetCalculationConfiguration(string dataTypeId);
}
diff --git a/src/Altinn.App.Core/Internal/Data/IFormDataWrapper.cs b/src/Altinn.App.Core/Internal/Data/IFormDataWrapper.cs
index 4ec781b23b..c556f28a7f 100644
--- a/src/Altinn.App.Core/Internal/Data/IFormDataWrapper.cs
+++ b/src/Altinn.App.Core/Internal/Data/IFormDataWrapper.cs
@@ -3,7 +3,6 @@
using Altinn.App.Core.Helpers;
using Altinn.App.Core.Helpers.DataModel;
using Altinn.App.Core.Internal.Expressions;
-using Altinn.App.Core.Models.Layout;
using Altinn.Platform.Storage.Interface.Models;
namespace Altinn.App.Core.Internal.Data;
@@ -287,26 +286,22 @@ private static int InvokeReturnIntOrError(MethodInfo info, object instance)
}
///
- /// Get a list of all possible keys for the given data model
+ /// Get a list of all possible keys for the given data model at the path
///
///
- /// intro.fnr
- /// group[0].name
- /// group[0].age
- /// group[1].name
- /// group[1].age
+ /// group.name -> ["group[0].name", "group[1].name"]
+ /// group.age -> ["group[0].age", "group[1].age"]
///
- public static DataReference[] GetResolvedKeys(this IFormDataWrapper formDataWrapper, DataReference reference)
+ public static string[] GetResolvedKeys(this IFormDataWrapper formDataWrapper, string path)
{
//TODO: write more efficient code that uses the formDataWrapper to resolve keys instead of reflection in DataModelWrapper
+ // The current implementation also does not throw exceptions when the path ends in an enumerable.
+ // When resolving "group" it is not clear if the result should be "group[0]", "group[1]", or just "group"."
var data = formDataWrapper.BackingData