From b618fa5a67db1967468e317b2dead8dbab3e6e7f Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Tue, 19 Aug 2025 15:33:05 +0200 Subject: [PATCH 1/3] Use IFormDataWrapper in app code --- .../Controllers/ActionsController.cs | 3 +- .../Controllers/DataController.cs | 3 +- .../Helpers/Patch/InternalPatchService.cs | 3 +- .../Features/IInstanceDataAccessor.cs | 130 ++++++++++-- .../Telemetry.InstanceDataAccessor.cs | 33 ++++ .../Validation/Default/ExpressionValidator.cs | 41 +++- .../Helpers/DataModel/DataModel.cs | 3 + .../Helpers/DataModel/DataModelWrapper.cs | 54 +---- .../DataModel/FormDataWrapperFactory.cs | 31 +++ .../Internal/Data/InstanceDataUnitOfWork.cs | 108 +++++----- .../Data/InstanceDataUnitOfWorkInitializer.cs | 14 +- .../Expressions/LayoutEvaluatorState.cs | 81 +++++++- .../LayoutEvaluatorStateInitializer.cs | 18 +- .../Common/ProcessTaskFinalizer.cs | 9 +- ...sNext_PdfFails_DataIsUnlocked.verified.txt | 10 + .../Controllers/SigningControllerTests.cs | 3 + .../Controllers/ValidateControllerTests.cs | 3 + .../ValidateControllerValidateDataTests.cs | 3 + .../Helpers/Patch/PatchServiceTests.cs | 2 + .../ValidationServiceOldTests.cs | 11 +- .../ValidationServiceTests.cs | 13 +- .../ExpressionsExclusiveGatewayTests.cs | 2 + .../Internal/Process/ProcessEngineTest.cs | 3 + .../Internal/Process/ProcessNavigatorTests.cs | 2 + .../Common/ProcessTaskFinalizerTests.cs | 3 +- .../ProcessTasks/SigningProcessTaskTests.cs | 3 + .../LayoutExpressions/TestDataModel.cs | 185 ++++++++---------- .../TestUtilities/InstanceDataAccessorFake.cs | 20 +- ...ouldNotChange_Unintentionally.verified.txt | 17 +- 29 files changed, 549 insertions(+), 262 deletions(-) create mode 100644 src/Altinn.App.Core/Features/Telemetry/Telemetry.InstanceDataAccessor.cs create mode 100644 src/Altinn.App.Core/Helpers/DataModel/FormDataWrapperFactory.cs diff --git a/src/Altinn.App.Api/Controllers/ActionsController.cs b/src/Altinn.App.Api/Controllers/ActionsController.cs index e84832caf4..77b63eb7bb 100644 --- a/src/Altinn.App.Api/Controllers/ActionsController.cs +++ b/src/Altinn.App.Api/Controllers/ActionsController.cs @@ -4,6 +4,7 @@ using Altinn.App.Core.Features; using Altinn.App.Core.Features.Action; using Altinn.App.Core.Features.Auth; +using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Validation; @@ -203,7 +204,7 @@ await Task.WhenAll( { // If the data mutator missed a that was returned with the deprecated UpdatedDataModels // we still need to return it to the frontend, but we assume it was already saved to storage - dataMutator.SetFormData(new DataElementIdentifier(elementId), data); + dataMutator.SetFormData(new DataElementIdentifier(elementId), FormDataWrapperFactory.Create(data)); } } #pragma warning restore CS0618 // Type or member is obsolete diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 6acee23716..38f99d9834 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -13,6 +13,7 @@ using Altinn.App.Core.Features.FileAnalysis; using Altinn.App.Core.Features.FileAnalyzis; using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; @@ -1118,7 +1119,7 @@ private async Task PutFormData( // Get the previous service model for dataProcessing to work var oldServiceModel = await dataMutator.GetFormData(dataElement); // Set the new service model so that dataAccessors see the new state - dataMutator.SetFormData(dataElement, serviceModel); + dataMutator.SetFormData(dataElement, FormDataWrapperFactory.Create(serviceModel)); var requestedChange = new FormDataChange() { diff --git a/src/Altinn.App.Api/Helpers/Patch/InternalPatchService.cs b/src/Altinn.App.Api/Helpers/Patch/InternalPatchService.cs index a6a64acda3..7b426366ee 100644 --- a/src/Altinn.App.Api/Helpers/Patch/InternalPatchService.cs +++ b/src/Altinn.App.Api/Helpers/Patch/InternalPatchService.cs @@ -4,6 +4,7 @@ using Altinn.App.Api.Extensions; using Altinn.App.Api.Models; using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Validation; using Altinn.App.Core.Models; @@ -117,7 +118,7 @@ public async Task> ApplyPatches( var newModel = newModelResult.Ok; // Reset dataAccessor to provide the patched model. - dataAccessor.SetFormData(dataElement, newModel); + dataAccessor.SetFormData(dataElementIdentifier, FormDataWrapperFactory.Create(newModel)); changesAfterPatch.Add( new FormDataChange diff --git a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs index ee901f0fb0..ab5f20e217 100644 --- a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs @@ -13,6 +13,11 @@ public interface IInstanceDataAccessor /// Instance Instance { get; } + /// + /// Get the data types from application metadata. + /// + IReadOnlyCollection DataTypes { get; } + /// /// Get the actual data represented in the data element. /// @@ -20,6 +25,13 @@ public interface IInstanceDataAccessor /// when identifier does not exist in instance.Data with an applogic data type Task GetFormData(DataElementIdentifier dataElementIdentifier); + /// + /// Get the actual data represented in the data element wrapped in an . + /// + /// The deserialized data model for this data element + /// when identifier does not exist in instance.Data with an applogic data type + Task GetFormDataWrapper(DataElementIdentifier dataElementIdentifier); + /// /// Gets the raw binary data from a DataElement. /// @@ -32,13 +44,6 @@ public interface IInstanceDataAccessor /// /// If the data element is not found on the instance DataElement GetDataElement(DataElementIdentifier dataElementIdentifier); - - /// - /// Get the data type from application with the given type. - /// - /// DataType.Id (from applicationmetadata.json) - /// The data type (or null if it does not exist) - DataType? GetDataType(string dataTypeId); } /// @@ -46,6 +51,20 @@ public interface IInstanceDataAccessor /// public static class IInstanceDataAccessorExtensions { + /// + /// Get the data type from application with the given type. + /// + /// The data type (or null if it does not exist) + public static DataType GetDataType(this IInstanceDataAccessor dataAccessor, string dataTypeId) + { + return dataAccessor.DataTypes.FirstOrDefault(dataType => + dataTypeId.Equals(dataType.Id, StringComparison.Ordinal) + ) + ?? throw new InvalidOperationException( + $"Data type {dataTypeId} not found in applicationmetadata.json (found: {string.Join(", ", dataAccessor.DataTypes.Select(d => d.Id))})" + ); + } + /// /// Get the dataType of a data element. /// @@ -56,15 +75,33 @@ DataElementIdentifier dataElementIdentifier ) { var dataElement = dataAccessor.GetDataElement(dataElementIdentifier); - var dataType = dataAccessor.GetDataType(dataElement.DataType); - if (dataType is null) + return dataAccessor.GetDataType(dataElement.DataType); + } + + /// + /// Get the data type from a C# class reference. + /// + /// Note that this throws an error if multiple data types have a ClassRef that matches the given type. + /// + /// If multiple dataType have a ClassRef that matches the given type + public static DataType GetDataType(this IInstanceDataAccessor accessor) + { + var dataTypes = accessor.DataTypes.Where(d => d.AppLogic?.ClassRef == typeof(T).FullName).ToArray(); + if (dataTypes.Length == 1) + { + return dataTypes[0]; + } + + if (dataTypes.Length == 0) { throw new InvalidOperationException( - $"Data type {dataElement.DataType} not found in applicationmetadata.json" + $"Data type for {typeof(T).FullName} not found in applicationmetadata.json" ); } - return dataType; + throw new InvalidOperationException( + $"Multiple data types found that references {typeof(T).FullName} found for multiple in applicationmetadata.json ({string.Join(", ", dataTypes.Select(d => d.Id))}). This means you can't access data just based on type parameter." + ); } /// @@ -77,12 +114,73 @@ DataElementIdentifier dataElementIdentifier ) where T : class { - object data = await accessor.GetFormData(dataElementIdentifier); - if (data is T t) + IFormDataWrapper data = await accessor.GetFormDataWrapper(dataElementIdentifier); + return data.BackingData(); + } + + /// + /// Extension method to get formdata from a type parameter that must match dataType.AppLogic.ClassRef. + /// + /// + /// This method only supports data types with MaxCount = 1. + /// + public static async Task GetFormData(this IInstanceDataAccessor accessor) + where T : class + { + var dataType = accessor.GetDataType(); + return await accessor.GetFormData(dataType); + } + + /// + /// Get form data from a specific data type. (The data type must have MaxCount = 1) + /// + public static async Task GetFormData(this IInstanceDataAccessor accessor, DataType dataType) + where T : class + { + if (dataType.MaxCount != 1) + { + throw new InvalidOperationException( + $"Data type {dataType.Id} is not a single instance data type, but has MaxCount = {dataType.MaxCount}" + ); + } + + var dataTypeId = dataType.Id; + + var dataElement = accessor.Instance.Data.FirstOrDefault(dataElement => + dataTypeId.Equals(dataElement.DataType, StringComparison.Ordinal) + ); + if (dataElement is null) { - return t; + return null; } - throw new InvalidOperationException($"Data element {dataElementIdentifier} is not of type {typeof(T)}"); + + return await accessor.GetFormData(dataElement); + } + + /// + /// Get an array of all the form data elements that has the given type as AppLogic.ClassRef. + /// + public static async Task GetAllFormData(this IInstanceDataAccessor accessor) + where T : class + { + var dataType = accessor.GetDataType(); + return await accessor.GetAllFormData(dataType); + } + + /// + /// Get an array of all the form data elements that has the given DataType + /// + public static async Task GetAllFormData(this IInstanceDataAccessor accessor, DataType dataType) + where T : class + { + var dataElements = accessor.GetDataElementsForType(dataType).ToArray(); + var result = new T[dataElements.Length]; + for (int i = 0; i < dataElements.Length; i++) + { + result[i] = await accessor.GetFormData(dataElements[i]); + } + + return result; } /// @@ -104,8 +202,6 @@ DataType dataType /// /// Retrieves the data elements associated with a specific task. /// - /// The instance data accessor. - /// The identifier of the task. /// An enumerable collection of tuples containing the data type and data element associated with the specified task. public static IEnumerable<(DataType dataType, DataElement dataElement)> GetDataElementsForTask( this IInstanceDataAccessor accessor, diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.InstanceDataAccessor.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.InstanceDataAccessor.cs new file mode 100644 index 0000000000..bf0e9d3906 --- /dev/null +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.InstanceDataAccessor.cs @@ -0,0 +1,33 @@ +using System.Diagnostics; +using Altinn.App.Core.Models; +using static Altinn.App.Core.Features.Telemetry.InstanceDataAccessor; + +namespace Altinn.App.Core.Features; + +partial class Telemetry +{ + private const string ActivityName = "Telemetry.InstanceDataAccessor"; + + internal Activity? StartVerifyDataElementsUnchangedSincePreviousChanges() + { + var activity = ActivitySource.StartActivity($"{ActivityName}.VerifyDataElementsUnchangedSincePreviousChanges"); + return activity; + } + + internal Activity? StartRemoveHiddenDataForValidation() + { + return ActivitySource.StartActivity($"{Prefix}.RemoveHiddenDataForValidation"); + } + + internal Activity? StartSaveChanges(DataElementChanges instance) + { + var activity = ActivitySource.StartActivity($"{Prefix}.SaveChanges"); + activity?.AddTag("numberOfChangedDataElements", instance.AllChanges.Count); + return activity; + } + + internal static class InstanceDataAccessor + { + internal const string Prefix = "InstanceDataAccessor"; + } +} diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index b56a437739..b4862faa8e 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; @@ -145,7 +144,7 @@ internal async Task> ValidateFormData( var context = new ComponentContext( evaluatorState, component: null, - rowIndices: DataModel.GetRowIndices(resolvedField.Field), + rowIndices: GetRowIndices(resolvedField.Field), dataElementIdentifier: resolvedField.DataElementIdentifier ); var positionalArguments = new object[] { resolvedField.Field }; @@ -166,6 +165,44 @@ await RunValidation( return validationIssues; } + private static int[]? GetRowIndices(string field) + { + Span rowIndicesSpan = stackalloc int[20]; // Assuming max 20 indices for simplicity + 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, List validationIssues, diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index e6d01f8df4..9151edd987 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -9,6 +9,9 @@ namespace Altinn.App.Core.Helpers.DataModel; /// /// Get data fields from a model, using string keys (like "Bedrifter[1].Ansatte[1].Alder") /// +[Obsolete( + "This class is deprecated and will be removed in a future version. Use the new LayoutEvaluatorState instead." +)] public class DataModel { private readonly IInstanceDataAccessor _dataAccessor; diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs index bd2a58dd18..d9742be8bd 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs @@ -7,6 +7,7 @@ namespace Altinn.App.Core.Helpers.DataModel; /// /// Get data fields from a model, using string keys (like "Bedrifter[1].Ansatte[1].Alder") /// +[Obsolete("Will be removed in v9 use Altinn.App.Core.Helpers.DataModel.FormDataWrapperFactory instead")] public class DataModelWrapper { private readonly object _dataModel; @@ -296,7 +297,7 @@ ReadOnlySpan indexes } /// - /// Return a full dataModelBiding from a context aware binding by adding indicies + /// Return a full dataModelBiding from a context aware binding by adding indexes /// /// /// key = "bedrift.ansatte.navn" @@ -411,55 +412,4 @@ public void RemoveField(string field, RowRemovalOption rowRemovalOption) property.SetValue(containingObject, nullValue); } } - - // /// - // /// Verify that a key is valid for the model - // /// - // public bool VerifyKey(string field) - // { - // return VerifyKeyRecursive(field.Split('.'), 0, _dataModel.GetType()); - // } - - // private bool VerifyKeyRecursive(string[] keys, int index, Type currentModel) - // { - // if (index == keys.Length) - // { - // return true; - // } - // if (keys[index].Length == 0) - // { - // return false; // invalid key part - // } - - // var (key, groupIndex) = ParseKeyPart(keys[index]); - // var prop = currentModel.GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); - // if (prop is null) - // { - // return false; - // } - - // var childType = prop.PropertyType; - - // // Strings are enumerable in C# - // // Other enumerable types is treated as an collection - // if (childType != typeof(string) && childType.IsAssignableTo(typeof(System.Collections.IEnumerable))) - // { - // var childTypeEnumerableParameter = childType - // .GetInterfaces() - // .Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)) - // .Select(t => t.GetGenericArguments()[0]) - // .FirstOrDefault(); - - // if (childTypeEnumerableParameter is not null) - // { - // return VerifyKeyRecursive(keys, index + 1, childTypeEnumerableParameter); - // } - // } - // else if (groupIndex is not null) - // { - // return false; // Key parts with group index must be IEnumerable - // } - - // return VerifyKeyRecursive(keys, index + 1, childType); - // } } diff --git a/src/Altinn.App.Core/Helpers/DataModel/FormDataWrapperFactory.cs b/src/Altinn.App.Core/Helpers/DataModel/FormDataWrapperFactory.cs new file mode 100644 index 0000000000..a58612e914 --- /dev/null +++ b/src/Altinn.App.Core/Helpers/DataModel/FormDataWrapperFactory.cs @@ -0,0 +1,31 @@ +using System.Collections.Frozen; +using System.Diagnostics; +using System.Reflection; +using Altinn.App.Core.Features; + +namespace Altinn.App.Core.Helpers.DataModel; + +internal static class FormDataWrapperFactory +{ + private static readonly FrozenDictionary _pathAccessors = InitializePathAccessorLookup(); + + private static FrozenDictionary InitializePathAccessorLookup() + { + return Assembly + .GetExecutingAssembly() + .GetTypes() + .Where(t => t.IsClass && t.IsAssignableTo(typeof(IFormDataWrapper<>))) + .ToFrozenDictionary(k => k.GenericTypeArguments[0], v => v); + } + + public static IFormDataWrapper Create(object dataModel) + { + if (_pathAccessors.TryGetValue(dataModel.GetType(), out var accessorType)) + { + return Activator.CreateInstance(accessorType, dataModel) as IFormDataWrapper + ?? throw new UnreachableException($"Failed to create path accessor for {dataModel.GetType().FullName}"); + } + + return new ReflectionFormDataWrapper(dataModel); + } +} diff --git a/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs b/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs index bdd27b811d..bd5a0e845b 100644 --- a/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs +++ b/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs @@ -4,9 +4,11 @@ using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Instances; +using Altinn.App.Core.Internal.Texts; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; @@ -31,8 +33,14 @@ internal sealed class InstanceDataUnitOfWork : IInstanceDataMutator private readonly ApplicationMetadata _appMetadata; private readonly ModelSerializationService _modelSerializationService; - // Cache for the most up to date form data (can be mutated or replaced with SetFormData(dataElementId, data)) - private readonly DataElementCache _formDataCache = new(); + // private readonly IAppResources _appResources; + // private readonly IOptions _frontEndSettings; + // private readonly string? _taskId; + // private readonly string? _language; + private readonly Telemetry? _telemetry; + + // Cache for the most up-to-date form data (can be mutated or replaced with SetFormData(dataElementId, data)) + private readonly DataElementCache _formDataCache = new(); // Cache for the binary content of the file as currently in storage (updated on save) private readonly DataElementCache> _binaryCache = new(); @@ -43,20 +51,20 @@ internal sealed class InstanceDataUnitOfWork : IInstanceDataMutator // Form data not yet saved to storage (thus no dataElementId) private readonly ConcurrentBag _changesForCreation = []; - // The update functions returns updated data elements. - // We want to make sure that the data elements are updated in the instance object - private readonly ConcurrentBag _savedDataElements = []; + // private readonly ITranslationService _translationService; public InstanceDataUnitOfWork( Instance instance, IDataClient dataClient, IInstanceClient instanceClient, ApplicationMetadata appMetadata, + ITranslationService translationService, ModelSerializationService modelSerializationService, IAppResources appResources, IOptions frontEndSettings, string? taskId, - string? language + string? language, + Telemetry? telemetry ) { if (instance.Id is not null) @@ -67,16 +75,31 @@ public InstanceDataUnitOfWork( } Instance = instance; + DataTypes = appMetadata.DataTypes; _dataClient = dataClient; _appMetadata = appMetadata; + // _translationService = translationService; _modelSerializationService = modelSerializationService; + // _taskId = taskId; + // _language = language; + // _frontEndSettings = frontEndSettings; + // _appResources = appResources; _instanceClient = instanceClient; + _telemetry = telemetry; } public Instance Instance { get; } + public IReadOnlyCollection DataTypes { get; } + /// public async Task GetFormData(DataElementIdentifier dataElementIdentifier) + { + return (await GetFormDataWrapper(dataElementIdentifier)).BackingData(); + } + + /// + public async Task GetFormDataWrapper(DataElementIdentifier dataElementIdentifier) { return await _formDataCache.GetOrCreate( dataElementIdentifier, @@ -84,9 +107,11 @@ public async Task GetFormData(DataElementIdentifier dataElementIdentifie { var binaryData = await GetBinaryData(dataElementIdentifier); - return _modelSerializationService.DeserializeFromStorage( - binaryData.Span, - this.GetDataType(dataElementIdentifier) + return FormDataWrapperFactory.Create( + _modelSerializationService.DeserializeFromStorage( + binaryData.Span, + this.GetDataType(dataElementIdentifier) + ) ); } ); @@ -125,9 +150,6 @@ public DataElement GetDataElement(DataElementIdentifier dataElementIdentifier) ); } - /// - public DataType? GetDataType(string dataTypeId) => _appMetadata.DataTypes.Find(d => d.Id == dataTypeId); - /// public FormDataChange AddFormDataElement(string dataTypeId, object model) { @@ -212,18 +234,9 @@ ReadOnlyMemory bytes /// public void RemoveDataElement(DataElementIdentifier dataElementIdentifier) { - var dataElement = Instance.Data.Find(d => d.Id == dataElementIdentifier.Id); - if (dataElement is null) - { - throw new InvalidOperationException( - $"Data element with id {dataElementIdentifier.Id} not found in instance" - ); - } - var dataType = - GetDataType(dataElement.DataType) - ?? throw new InvalidOperationException( - $"Data element {dataElement.Id} has data type {dataElement.DataType}, but the data type is not found in app metadata" - ); + var dataElement = GetDataElement(dataElementIdentifier); + var dataType = this.GetDataType(dataElement.DataType); + if (_changesForDeletion.Any(c => c.DataElementIdentifier == dataElementIdentifier)) { throw new InvalidOperationException( @@ -298,7 +311,7 @@ public DataElementChanges GetDataElementChanges(bool initializeAltinnRowId) } var dataType = this.GetDataType(dataElementIdentifier); - if (!_formDataCache.TryGetCachedValue(dataElementIdentifier, out object? data)) + if (!_formDataCache.TryGetCachedValue(dataElementIdentifier, out IFormDataWrapper? dataWrapper)) { // We don't support making updates to binary data elements (attachments) in IInstanceDataMutator continue; @@ -322,10 +335,13 @@ out ReadOnlyMemory cachedBinary if (initializeAltinnRowId) { - ObjectUtils.InitializeAltinnRowId(data); + dataWrapper.InitializeAltinnRowIds(); } - var (currentBinary, _) = _modelSerializationService.SerializeToStorage(data, dataType); + var (currentBinary, _) = _modelSerializationService.SerializeToStorage( + dataWrapper.BackingData(), + dataType + ); if (!currentBinary.Span.SequenceEqual(cachedBinary.Span)) { @@ -336,7 +352,7 @@ out ReadOnlyMemory cachedBinary DataElement = dataElement, ContentType = dataElement.ContentType, DataType = dataType, - CurrentFormData = data, + CurrentFormData = dataWrapper.BackingData(), // For patch requests we could get the previous data from the patch, but it's not available here // and deserializing twice is not a big deal PreviousFormData = _modelSerializationService.DeserializeFromStorage( @@ -380,7 +396,7 @@ DataElementChange change _binaryCache.Set(dataElement, bytes); if (change is FormDataChange formDataChange) { - _formDataCache.Set(dataElement, formDataChange.CurrentFormData); + _formDataCache.Set(dataElement, FormDataWrapperFactory.Create(formDataChange.CurrentFormData)); } createdDataElements.TryAdd(change, dataElement); } @@ -392,14 +408,13 @@ private async Task UpdateDataElement( ReadOnlyMemory bytes ) { - var newDataElement = await _dataClient.UpdateBinaryData( + await _dataClient.UpdateBinaryData( new InstanceIdentifier(Instance), contentType, filename, dataElementIdentifier.Guid, new MemoryAsStream(bytes) ); - _savedDataElements.Add(newDataElement); } internal async Task UpdateInstanceData(DataElementChanges changes) @@ -453,14 +468,11 @@ await _dataClient.DeleteData( // update data elements on new elements foreach (var change in changes.AllChanges) { - if (change.DataElement is null) - { - change.DataElement = createdDataElements.TryGetValue(change, out var value) - ? value - : throw new InvalidOperationException( - "DataElementChange without DataElement must be a new data element" - ); - } + change.DataElement ??= createdDataElements.TryGetValue(change, out var value) + ? value + : throw new InvalidOperationException( + "DataElementChange without DataElement must be a new data element" + ); if (change is FormDataChange formDataChange) { //Update DataValues and presentation texts @@ -477,6 +489,7 @@ await UpdatePresentationTextsOnInstance( internal async Task SaveChanges(DataElementChanges changes) { + using var activity = _telemetry?.StartSaveChanges(changes); if (HasAbandonIssues) { throw new InvalidOperationException("AbandonAllChanges has been called, and no changes should be saved"); @@ -514,20 +527,20 @@ internal async Task SaveChanges(DataElementChanges changes) /// /// Add or replace existing data element data in the cache /// - internal void SetFormData(DataElementIdentifier dataElementIdentifier, object data) + internal void SetFormData(DataElementIdentifier dataElementIdentifier, IFormDataWrapper formDataWrapper) { var dataType = this.GetDataType(dataElementIdentifier); if (dataType.AppLogic?.ClassRef is not { } classRef) { throw new InvalidOperationException($"Data element {dataElementIdentifier.Id} don't have app logic"); } - if (data.GetType().FullName != classRef) + if (formDataWrapper.BackingDataType.FullName != classRef) { throw new InvalidOperationException( - $"Data object registered for {dataElementIdentifier.Id} is not of type {classRef} as specified in application metadata for data type {dataType.Id}, but {data.GetType().FullName}" + $"Data object registered for {dataElementIdentifier.Id} is not of type {classRef} as specified in application metadata for data type {dataType.Id}, but {formDataWrapper.BackingDataType.FullName}" ); } - _formDataCache.Set(dataElementIdentifier, data); + _formDataCache.Set(dataElementIdentifier, formDataWrapper); } private DataType GetDataTypeByString(string dataTypeString) @@ -543,17 +556,18 @@ private DataType GetDataTypeByString(string dataTypeString) internal void VerifyDataElementsUnchangedSincePreviousChanges(DataElementChanges previousChanges) { + using var activity = _telemetry?.StartVerifyDataElementsUnchangedSincePreviousChanges(); var changes = GetDataElementChanges(initializeAltinnRowId: false); if (changes.AllChanges.Count != previousChanges.AllChanges.Count) { - throw new Exception("Number of data elements have changed by validators"); + throw new InvalidOperationException("Number of data elements have changed by validators"); } foreach (var previousChange in previousChanges.AllChanges) { var currentChange = changes.AllChanges.FirstOrDefault(c => c.DataElement?.Id == previousChange.DataElement?.Id) - ?? throw new Exception("Number of data elements have changed by validators"); + ?? throw new InvalidOperationException("Number of data elements have changed by validators"); var equal = (currentChange, previousChange) switch { @@ -564,11 +578,11 @@ internal void VerifyDataElementsUnchangedSincePreviousChanges(DataElementChanges (BinaryDataChange current, BinaryDataChange previous) => current.CurrentBinaryData.Span.SequenceEqual( previous.CurrentBinaryData.Span ), - _ => throw new Exception("Data element type has changed by validators"), + _ => throw new InvalidOperationException("Data element type has changed by validators"), }; if (!equal) { - throw new Exception( + throw new InvalidOperationException( $"Data element {previousChange.DataType.Id} with id {previousChange.DataElement?.Id} has been changed by validators" ); } diff --git a/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWorkInitializer.cs b/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWorkInitializer.cs index c8c8d69318..3a4d6c1bad 100644 --- a/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWorkInitializer.cs +++ b/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWorkInitializer.cs @@ -1,7 +1,9 @@ using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features; using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Instances; +using Altinn.App.Core.Internal.Texts; using Altinn.Platform.Storage.Interface.Models; using Microsoft.Extensions.Options; @@ -14,9 +16,11 @@ internal class InstanceDataUnitOfWorkInitializer { private readonly IDataClient _dataClient; private readonly IInstanceClient _instanceClient; + private readonly ITranslationService _translationService; private readonly ModelSerializationService _modelSerializationService; private readonly IAppResources _appResources; private readonly IOptions _frontEndSettings; + private readonly Telemetry? _telemetry; private readonly IAppMetadata _applicationMetadata; /// @@ -26,16 +30,20 @@ public InstanceDataUnitOfWorkInitializer( IDataClient dataClient, IInstanceClient instanceClient, IAppMetadata applicationMetadata, + ITranslationService translationService, ModelSerializationService modelSerializationService, IAppResources appResources, - IOptions frontEndSettings + IOptions frontEndSettings, + Telemetry? telemetry = null ) { _dataClient = dataClient; _instanceClient = instanceClient; + _translationService = translationService; _modelSerializationService = modelSerializationService; _appResources = appResources; _frontEndSettings = frontEndSettings; + _telemetry = telemetry; _applicationMetadata = applicationMetadata; } @@ -51,11 +59,13 @@ internal async Task Init(Instance instance, string? task _dataClient, _instanceClient, applicationMetadata, + _translationService, _modelSerializationService, _appResources, _frontEndSettings, taskId, - language + language, + _telemetry ); } } diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index 2f476eda3c..9290736589 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -1,7 +1,6 @@ using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; using Altinn.App.Core.Helpers; -using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.Texts; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; @@ -15,7 +14,6 @@ namespace Altinn.App.Core.Internal.Expressions; /// public class LayoutEvaluatorState { - private readonly DataModel _dataModel; private readonly LayoutModel? _componentModel; private readonly ITranslationService _translationService; private readonly FrontEndSettings _frontEndSettings; @@ -23,6 +21,8 @@ public class LayoutEvaluatorState private readonly string? _language; private readonly TimeZoneInfo? _timeZone; private List? _rootContext; + private readonly IInstanceDataAccessor _dataAccessor; + private readonly Dictionary _dataIdsByType = []; /// /// Constructor for LayoutEvaluatorState. Usually called via that can be fetched from dependency injection. @@ -44,7 +44,14 @@ public LayoutEvaluatorState( TimeZoneInfo? timeZone = null ) { - _dataModel = new DataModel(dataAccessor); + foreach (var (dataType, dataElement) in dataAccessor.GetDataElements()) + { + if (dataType is { MaxCount: 1, AppLogic.ClassRef: not null }) + { + _dataIdsByType.TryAdd(dataElement.DataType, dataElement); + } + } + _dataAccessor = dataAccessor; _componentModel = componentModel; _translationService = translationService; _frontEndSettings = frontEndSettings; @@ -177,7 +184,9 @@ private static bool RowIndexMatch(int[]? searchRowIndexes, int[]? componentRowIn int[]? indexes ) { - return await _dataModel.GetModelData(key, defaultDataElementIdentifier, indexes); + var elementIdentifier = ResolveDataElementIdentifier(key, defaultDataElementIdentifier); + var model = await _dataAccessor.GetFormDataWrapper(elementIdentifier); + return model.Get(key.Field, indexes); } /// @@ -185,7 +194,8 @@ private static bool RowIndexMatch(int[]? searchRowIndexes, int[]? componentRowIn /// public async Task GetResolvedKeys(DataReference reference) { - return await _dataModel.GetResolvedKeys(reference); + var data = await _dataAccessor.GetFormDataWrapper(reference.DataElementIdentifier); + return data.GetResolvedKeys(reference); } /// @@ -193,7 +203,8 @@ public async Task GetResolvedKeys(DataReference reference) /// public async Task RemoveDataField(DataReference key, RowRemovalOption rowRemovalOption) { - await _dataModel.RemoveField(key, rowRemovalOption); + var dataWrapper = await _dataAccessor.GetFormDataWrapper(key.DataElementIdentifier); + dataWrapper.RemoveField(key.Field, rowRemovalOption); } /// @@ -245,7 +256,47 @@ public int CountDataElements(string dataTypeId) /// public async Task AddInidicies(ModelBinding binding, ComponentContext context) { - return await _dataModel.AddIndexes(binding, context.DataElementIdentifier, context.RowIndices); + var dataElementId = ResolveDataElementIdentifier(binding, context.DataElementIdentifier); + var formDataWrapper = await _dataAccessor.GetFormDataWrapper(dataElementId); + + var field = formDataWrapper.AddIndexToPath(binding.Field, context.RowIndices) ?? ""; + return new DataReference() { Field = field, DataElementIdentifier = dataElementId }; + } + + private DataElementIdentifier ResolveDataElementIdentifier( + ModelBinding key, + DataElementIdentifier defaultDataElementIdentifier + ) + { + if ( + key.DataType == null + || defaultDataElementIdentifier.DataTypeId == key.DataType + || _dataAccessor.GetDataType(defaultDataElementIdentifier).Id == key.DataType + ) + { + return defaultDataElementIdentifier; + } + + if (_dataIdsByType.TryGetValue(key.DataType, out var dataElementId)) + { + return dataElementId; + } + if (_dataAccessor.GetDataType(key.DataType) is { } dataType) + { + if (dataType.MaxCount != 1) + { + throw new InvalidOperationException( + $"{key.DataType} has maxCount different from 1 in applicationmetadata.json or don't have a classRef in appLogic" + ); + } + throw new InvalidOperationException( + $"{key.DataType} has no classRef in applicationmetadata.json and can't be used as a data model in layouts" + ); + } + + throw new InvalidOperationException( + $"Data model with type {key.DataType} not found in applicationmetadata.json" + ); } /// @@ -257,7 +308,17 @@ public async Task AddInidicies( int[]? indexes ) { - return await _dataModel.AddIndexes(binding, dataElementIdentifier, indexes); + var dataElementId = ResolveDataElementIdentifier(binding, dataElementIdentifier); + var formDataWrapper = await _dataAccessor.GetFormDataWrapper(dataElementId); + return new DataReference() + { + DataElementIdentifier = dataElementId, + Field = + formDataWrapper.AddIndexToPath(binding.Field, indexes) + ?? throw new InvalidOperationException( + $"Failed to add indexes to path {binding.Field} with indexes {(indexes == null ? "null" : string.Join(", ", indexes))} on {dataElementId}" + ), + }; } /// @@ -285,7 +346,9 @@ public async Task TranslateText(string textKey, ComponentContext context int[]? indexes ) { - return await _dataModel.GetModelDataCount(groupBinding, defaultDataElementIdentifier, indexes); + var dataElementId = ResolveDataElementIdentifier(groupBinding, defaultDataElementIdentifier); + var model = await _dataAccessor.GetFormDataWrapper(dataElementId); + return model.GetRowCount(groupBinding.Field, indexes); } // /// diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 1ac5bd0a8c..7777003355 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Texts; using Altinn.App.Core.Models; @@ -46,7 +47,7 @@ private sealed class SingleDataElementAccessor : IInstanceDataAccessor { private readonly DataElement _dataElement; private readonly ApplicationMetadata _applicationMetadata; - private readonly object _data; + private readonly IFormDataWrapper _data; public SingleDataElementAccessor( Instance instance, @@ -58,16 +59,23 @@ object data Instance = instance; _dataElement = dataElement; _applicationMetadata = applicationMetadata; - _data = data; + _data = FormDataWrapperFactory.Create(data); } public Instance Instance { get; } - public Task GetFormData(DataElementIdentifier dataElementIdentifier) + public IReadOnlyCollection DataTypes => _applicationMetadata.DataTypes; + + public async Task GetFormData(DataElementIdentifier dataElementIdentifier) + { + return (await GetFormDataWrapper(dataElementIdentifier)).BackingData(); + } + + public Task GetFormDataWrapper(DataElementIdentifier dataElementIdentifier) { if (dataElementIdentifier != _dataElement) { - return Task.FromException( + return Task.FromException( new InvalidOperationException( "Use the new ILayoutEvaluatorStateInitializer interface to support multiple data models and subforms" ) @@ -88,8 +96,6 @@ public DataElement GetDataElement(DataElementIdentifier dataElementIdentifier) $"Data element of id {dataElementIdentifier.Id} not found on instance" ); } - - public DataType? GetDataType(string dataTypeId) => _applicationMetadata.DataTypes.Find(d => d.Id == dataTypeId); } /// diff --git a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs index 753c7376c3..7b5c16af5d 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Altinn.App.Core.Configuration; using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; @@ -81,10 +82,12 @@ private async Task RemoveFieldsOnTaskComplete( string? language = null ) { - var data = await dataAccessor.GetFormData(dataElement); + var formDataWrapper = await dataAccessor.GetFormDataWrapper(dataElement); // remove AltinnRowIds - ObjectUtils.RemoveAltinnRowId(data); + formDataWrapper.RemoveAltinnRowIds(); + + var data = formDataWrapper.BackingData(); // Remove hidden data before validation, ignore hidden rows. if (_appSettings.Value?.RemoveHiddenData == true) @@ -136,7 +139,7 @@ private async Task RemoveFieldsOnTaskComplete( ?? throw new JsonException( "Could not deserialize back datamodel after removing shadow fields. Data was \"null\"" ); - dataAccessor.SetFormData(dataElement, newData); + dataAccessor.SetFormData(dataElement, FormDataWrapperFactory.Create(newData)); } } } diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt index 928aa02574..939951d1f3 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt @@ -135,6 +135,16 @@ ], HasParent: true }, + { + Name: InstanceDataAccessor.SaveChanges, + IdFormat: W3C, + Tags: [ + { + numberOfChangedDataElements: 1 + } + ], + HasParent: true + }, { Name: PUT {org}/{app}/instances/{instanceOwnerPartyId:int}/{instanceGuid:guid}/process/next, Kind: Server, diff --git a/test/Altinn.App.Api.Tests/Controllers/SigningControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/SigningControllerTests.cs index a44fd022b3..09285b19f8 100644 --- a/test/Altinn.App.Api.Tests/Controllers/SigningControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/SigningControllerTests.cs @@ -12,6 +12,7 @@ using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; +using Altinn.App.Core.Internal.Texts; using Altinn.App.Core.Models; using Altinn.Platform.Register.Models; using Altinn.Platform.Storage.Interface.Models; @@ -33,6 +34,7 @@ public class SigningControllerTests private readonly Mock _signingServiceMock = new(MockBehavior.Strict); private readonly Mock _dataClientMock = new(MockBehavior.Strict); private readonly Mock _applicationMetadataMock = new(MockBehavior.Strict); + private readonly Mock _translationServiceMock = new(MockBehavior.Strict); private readonly Mock _appModelMock = new(MockBehavior.Strict); private readonly Mock _appResourcesMock = new(MockBehavior.Strict); private readonly ServiceCollection _serviceCollection = new(); @@ -63,6 +65,7 @@ public SigningControllerTests(ITestOutputHelper output) _serviceCollection.AddSingleton(_appModelMock.Object); _serviceCollection.AddSingleton(_dataClientMock.Object); _serviceCollection.AddSingleton(_applicationMetadataMock.Object); + _serviceCollection.AddSingleton(_translationServiceMock.Object); _serviceCollection.AddSingleton(_appResourcesMock.Object); _serviceCollection.AddSingleton(_processReaderMock.Object); _serviceCollection.AddSingleton(_httpContextAccessorMock.Object); diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs index b78d3202b0..d8d8019bb2 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs @@ -8,6 +8,7 @@ using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Instances; +using Altinn.App.Core.Internal.Texts; using Altinn.App.Core.Internal.Validation; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Validation; @@ -32,6 +33,7 @@ public class ValidateControllerTests private readonly Mock _validationMock = new(MockBehavior.Strict); private readonly Mock _dataClientMock = new(MockBehavior.Strict); private readonly Mock _appModelMock = new(MockBehavior.Strict); + private readonly Mock _translationServiceMock = new(MockBehavior.Strict); private readonly Mock _appResourcesMock = new(MockBehavior.Strict); private readonly ServiceCollection _services = new(); @@ -46,6 +48,7 @@ public ValidateControllerTests() _services.AddSingleton(_validationMock.Object); _services.AddSingleton(_dataClientMock.Object); _services.AddSingleton(_appModelMock.Object); + _services.AddSingleton(_translationServiceMock.Object); _services.AddSingleton(_appResourcesMock.Object); _services.AddSingleton(Options.Create(new FrontEndSettings())); _services.AddTransient(); diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs index 53254985e0..493cd9ef24 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs @@ -9,6 +9,7 @@ using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Language; +using Altinn.App.Core.Internal.Texts; using Altinn.App.Core.Internal.Validation; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Validation; @@ -194,6 +195,7 @@ public class ValidationControllerValidateDataTests private readonly Mock _validationMock = new(MockBehavior.Strict); private readonly Mock _dataClientMock = new(MockBehavior.Strict); private readonly Mock _appModelMock = new(MockBehavior.Strict); + private readonly Mock _translationServiceMock = new(MockBehavior.Strict); private readonly Mock _appResourcesMock = new(MockBehavior.Strict); private readonly ServiceCollection _services = new(); @@ -262,6 +264,7 @@ private void SetupMocks(string app, string org, int instanceOwnerId, ValidateDat _services.AddSingleton(_validationMock.Object); _services.AddSingleton(_dataClientMock.Object); _services.AddSingleton(_appModelMock.Object); + _services.AddSingleton(_translationServiceMock.Object); _services.AddSingleton(_appResourcesMock.Object); _services.AddSingleton(Options.Create(new FrontEndSettings())); _services.AddTransient(); diff --git a/test/Altinn.App.Api.Tests/Helpers/Patch/PatchServiceTests.cs b/test/Altinn.App.Api.Tests/Helpers/Patch/PatchServiceTests.cs index e4af06a440..addc235b5e 100644 --- a/test/Altinn.App.Api.Tests/Helpers/Patch/PatchServiceTests.cs +++ b/test/Altinn.App.Api.Tests/Helpers/Patch/PatchServiceTests.cs @@ -21,6 +21,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Moq; using DataType = Altinn.Platform.Storage.Interface.Models.DataType; @@ -111,6 +112,7 @@ public PatchServiceTests() services.AddSingleton(_appMetadataMock.Object); services.AddSingleton(_dataProcessorMock.Object); services.AddSingleton(_appResourcesMock.Object); + services.AddSingleton(_translationServiceMock.Object); services.AddSingleton(_dataClientMock.Object); services.AddSingleton(_instanceClientMock.Object); services.AddSingleton(_dataElementAccessCheckerMock.Object); diff --git a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceOldTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceOldTests.cs index 0427cc7872..e1c9aeedc4 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceOldTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceOldTests.cs @@ -63,6 +63,7 @@ public ValidationServiceOldTests() _serviceCollection.AddSingleton(); _serviceCollection.AddSingleton(_dataElementAccessCheckerMock.Object); _serviceCollection.AddSingleton(Microsoft.Extensions.Options.Options.Create(new GeneralSettings())); + _serviceCollection.AddSingleton(Microsoft.Extensions.Options.Options.Create(new AppSettings())); _appMetadataMock.Setup(am => am.GetApplicationMetadata()).ReturnsAsync(_applicationMetadata); } @@ -83,7 +84,7 @@ public async Task FileScanEnabled_VirusFound_ValidationShouldFail() var instance = new Instance() { Data = [dataElement] }; var dataAccessor = new Mock(MockBehavior.Strict); dataAccessor.SetupGet(da => da.Instance).Returns(instance); - dataAccessor.Setup(da => da.GetDataType(dataType.Id)).Returns(dataType); + dataAccessor.Setup(da => da.DataTypes).Returns([dataType]); List validationIssues = await validationService.ValidateInstanceAtTask( dataAccessor.Object, @@ -114,7 +115,7 @@ public async Task FileScanEnabled_PendingScanNotEnabled_ValidationShouldNotFail( var dataAccessorMock = new Mock(MockBehavior.Strict); dataAccessorMock.SetupGet(da => da.Instance).Returns(instance); - dataAccessorMock.Setup(da => da.GetDataType(dataType.Id)).Returns(dataType); + dataAccessorMock.Setup(da => da.DataTypes).Returns([dataType]); List validationIssues = await validationService.ValidateInstanceAtTask( dataAccessorMock.Object, @@ -145,7 +146,7 @@ public async Task FileScanEnabled_PendingScanEnabled_ValidationShouldNotFail() var instance = new Instance() { Data = [dataElement] }; var dataAccessorMock = new Mock(MockBehavior.Strict); dataAccessorMock.SetupGet(da => da.Instance).Returns(instance); - dataAccessorMock.Setup(da => da.GetDataType(dataType.Id)).Returns(dataType); + dataAccessorMock.Setup(da => da.DataTypes).Returns([dataType]); List validationIssues = await validationService.ValidateInstanceAtTask( dataAccessorMock.Object, @@ -219,7 +220,7 @@ public async Task ValidateAndUpdateProcess_set_canComplete_validationstatus_and_ }; var dataAccessorMock = new Mock(MockBehavior.Strict); dataAccessorMock.SetupGet(da => da.Instance).Returns(instance); - dataAccessorMock.Setup(da => da.GetDataType(dataType.Id)).Returns(dataType); + dataAccessorMock.Setup(da => da.DataTypes).Returns([dataType]); var issues = await validationService.ValidateInstanceAtTask(dataAccessorMock.Object, taskId, null, null, null); issues.Should().BeEmpty(); @@ -268,7 +269,7 @@ public async Task ValidateAndUpdateProcess_set_canComplete_false_validationstatu }; var dataAccessorMock = new Mock(MockBehavior.Strict); dataAccessorMock.SetupGet(da => da.Instance).Returns(instance); - dataAccessorMock.Setup(da => da.GetDataType(dataType.Id)).Returns(dataType); + dataAccessorMock.Setup(da => da.DataTypes).Returns([dataType]); var issues = await validationService.ValidateInstanceAtTask(dataAccessorMock.Object, taskId, null, null, null); issues.Should().HaveCount(1); diff --git a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs index 646548bf25..9a42e12239 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs @@ -142,11 +142,13 @@ public ValidationServiceTests() _dataClientMock.Object, _instanceClientMock.Object, _defaultAppMetadata, + _translationServiceMock.Object, _modelSerialization, null!, null!, DefaultTaskId, - DefaultLanguage + DefaultLanguage, + null ); _serviceCollection.AddAppImplementationFactory(); _serviceCollection.AddSingleton(_loggerMock.Object); @@ -160,6 +162,7 @@ public ValidationServiceTests() _serviceCollection.AddSingleton(); _serviceCollection.AddSingleton(_dataElementAccessCheckerMock.Object); _serviceCollection.AddSingleton(Microsoft.Extensions.Options.Options.Create(new GeneralSettings())); + _serviceCollection.AddSingleton(Microsoft.Extensions.Options.Options.Create(new AppSettings())); // NeverUsedValidators _serviceCollection.AddSingleton(_taskValidatorNeverMock.Object); @@ -431,11 +434,13 @@ public async Task ValidateFormData_WithMyNameValidator_ReturnsErrorsWhenNameIsKa _dataClientMock.Object, _instanceClientMock.Object, _defaultAppMetadata, + _translationServiceMock.Object, _modelSerialization, null!, null!, DefaultTaskId, - DefaultLanguage + DefaultLanguage, + null ); var resultData = await validatorService.ValidateIncrementalFormData( dataAccessor, @@ -513,11 +518,13 @@ List CreateIssues(string code) _dataClientMock.Object, _instanceClientMock.Object, _defaultAppMetadata, + _translationServiceMock.Object, _modelSerialization, null!, null!, DefaultTaskId, - DefaultLanguage + DefaultLanguage, + null ); var taskResult = await validationService.ValidateInstanceAtTask( diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs index ecbef84760..7481c2b58f 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs @@ -279,10 +279,12 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew _dataClient.Object, _instanceClient.Object, appMetadata, + null!, modelSerializationService, null!, null!, TaskId, + null, null ); diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs index 5d94332993..781a261446 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs @@ -11,6 +11,7 @@ using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements; +using Altinn.App.Core.Internal.Texts; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Process; using Altinn.App.Core.Models.UserAction; @@ -1207,6 +1208,7 @@ public static Fixture Create( Mock appModelMock = new(MockBehavior.Strict); Mock appMetadataMock = new(MockBehavior.Strict); Mock appResourcesMock = new(MockBehavior.Strict); + Mock translationServiceMock = new(MockBehavior.Strict); var appMetadata = new ApplicationMetadata("org/app"); appMetadataMock.Setup(x => x.GetApplicationMetadata()).ReturnsAsync(appMetadata); @@ -1270,6 +1272,7 @@ public static Fixture Create( services.TryAddTransient(_ => appModelMock.Object); services.TryAddTransient(_ => appMetadataMock.Object); services.TryAddTransient(_ => appResourcesMock.Object); + services.TryAddTransient(_ => translationServiceMock.Object); services.TryAddTransient(); if (registerProcessEnd) diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs index fab4722b75..96a8a1e413 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs @@ -7,6 +7,7 @@ using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements; +using Altinn.App.Core.Internal.Texts; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Process; using Altinn.App.Core.Tests.Internal.Process.TestUtils; @@ -294,6 +295,7 @@ private static Fixture SetupProcessNavigator(string bpmnfile, IEnumerable(MockBehavior.Strict).Object); services.AddSingleton(new Mock(MockBehavior.Strict).Object); services.AddSingleton(new Mock(MockBehavior.Strict).Object); + services.AddSingleton(new Mock(MockBehavior.Strict).Object); services.AddSingleton(); services.AddTransient(); diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs index 5202b59b75..9902c44332 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs @@ -69,6 +69,7 @@ public async Task Finalize_WithValidInputs_ShouldCallCorrectMethods() new DataElement { Id = Guid.NewGuid().ToString(), + DataType = "dataType", References = [new Reference { ValueType = ReferenceType.Task, Value = "EndEvent" }], }, ], @@ -76,7 +77,7 @@ public async Task Finalize_WithValidInputs_ShouldCallCorrectMethods() var applicationMetadata = new ApplicationMetadata(instance.AppId) { - DataTypes = [new DataType { TaskId = instance.Process.CurrentTask.ElementId }], + DataTypes = [new DataType { Id = "dataType", TaskId = instance.Process.CurrentTask.ElementId }], }; _appMetadataMock.Setup(x => x.GetApplicationMetadata()).ReturnsAsync(applicationMetadata); diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/SigningProcessTaskTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/SigningProcessTaskTests.cs index 4b7f6bf33d..5cf21138c6 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/SigningProcessTaskTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/SigningProcessTaskTests.cs @@ -11,6 +11,7 @@ using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; using Altinn.App.Core.Internal.Process.ProcessTasks; +using Altinn.App.Core.Internal.Texts; using Altinn.App.Core.Models; using Altinn.Platform.Storage.Interface.Models; using Microsoft.Extensions.DependencyInjection; @@ -27,6 +28,7 @@ public class SigningProcessTaskTests private readonly Mock _signingServiceMock = new(MockBehavior.Strict); private readonly Mock _signeeContextsManagerMock = new(MockBehavior.Strict); private readonly Mock _appMetadataMock = new(MockBehavior.Strict); + private readonly Mock _translationServiceMock = new(MockBehavior.Strict); private readonly Mock _hostEnvironmentMock = new(MockBehavior.Strict); private readonly Mock _appModelMock = new(MockBehavior.Strict); private readonly Mock _dataClientMock = new(MockBehavior.Strict); @@ -46,6 +48,7 @@ public SigningProcessTaskTests() _serviceCollection.AddSingleton(_instanceClientMock.Object); _serviceCollection.AddSingleton(_appModelMock.Object); _serviceCollection.AddSingleton(_appMetadataMock.Object); + _serviceCollection.AddSingleton(_translationServiceMock.Object); _serviceCollection.AddSingleton(_hostEnvironmentMock.Object); _serviceCollection.AddSingleton(_dataClientMock.Object); _serviceCollection.AddSingleton(_pdfServiceMock.Object); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs index 2037a503a0..a25b11fae8 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs @@ -1,10 +1,9 @@ -using System.Collections; using System.Text.Json; using System.Text.Json.Serialization; +using Altinn.App.Core.Features; using Altinn.App.Core.Helpers; using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; -using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; using Newtonsoft.Json; using JsonSerializer = System.Text.Json.JsonSerializer; @@ -13,44 +12,42 @@ namespace Altinn.App.Core.Tests.LayoutExpressions; public class TestDataModel { - private readonly DataElement _dataElement = new() { DataType = "default" }; - [Fact] public void TestSimpleGet() { var model = new Model { Name = new() { Value = "myValue" } }; - var modelHelper = new DataModelWrapper(model); - modelHelper.GetModelData("does.not.exist").Should().BeNull(); - modelHelper.GetModelData("name.value").Should().Be(model.Name.Value); - modelHelper.GetModelData("name.value", [1, 2, 3]).Should().Be(model.Name.Value); + var modelHelper = new ReflectionFormDataWrapper(model); + modelHelper.Get("does.not.exist").Should().BeNull(); + modelHelper.Get("name.value").Should().Be(model.Name.Value); + modelHelper.Get(modelHelper.AddIndexToPath("name.value", [1, 2, 3])).Should().Be(model.Name.Value); } [Fact] public void AttributeNoAttriubteCaseSensitive() { var model = new Model { NoAttribute = "asdfsf559" }; - var modelHelper = new DataModelWrapper(model); - modelHelper.GetModelData("NOATTRIBUTE").Should().BeNull("data model lookup is case sensitive"); - modelHelper.GetModelData("noAttribute").Should().BeNull(); - modelHelper.GetModelData("NoAttribute").Should().Be("asdfsf559"); + var modelHelper = new ReflectionFormDataWrapper(model); + modelHelper.Get("NOATTRIBUTE").Should().BeNull("data model lookup is case sensitive"); + modelHelper.Get("noAttribute").Should().BeNull(); + modelHelper.Get("NoAttribute").Should().Be("asdfsf559"); } [Fact] public void NewtonsoftAttributeWorks() { - var modelHelper = new DataModelWrapper(new Model { OnlyNewtonsoft = "asdfsf559" }); - modelHelper.GetModelData("OnlyNewtonsoft").Should().BeNull("Attribute should win over property when set"); - modelHelper.GetModelData("ONlyNewtonsoft").Should().BeNull(); - modelHelper.GetModelData("onlyNewtonsoft").Should().Be("asdfsf559"); + var modelHelper = new ReflectionFormDataWrapper(new Model { OnlyNewtonsoft = "asdfsf559" }); + modelHelper.Get("OnlyNewtonsoft").Should().BeNull("Attribute should win over property when set"); + modelHelper.Get("ONlyNewtonsoft").Should().BeNull(); + modelHelper.Get("onlyNewtonsoft").Should().Be("asdfsf559"); } [Fact] public void SystemTextJsonAttributeWorks() { - var modelHelper = new DataModelWrapper(new Model { OnlySystemTextJson = "asdfsf559" }); - modelHelper.GetModelData("OnlySystemTextJson").Should().BeNull("Attribute should win over property when set"); - modelHelper.GetModelData("onlysystemtextjson").Should().BeNull(); - modelHelper.GetModelData("onlySystemTextJson").Should().Be("asdfsf559"); + var modelHelper = new ReflectionFormDataWrapper(new Model { OnlySystemTextJson = "asdfsf559" }); + modelHelper.Get("OnlySystemTextJson").Should().BeNull("Attribute should win over property when set"); + modelHelper.Get("onlysystemtextjson").Should().BeNull(); + modelHelper.Get("onlySystemTextJson").Should().Be("asdfsf559"); } [Fact] @@ -68,25 +65,27 @@ public void RecursiveLookup() new() { Name = new() { Value = "Dolly Duck" } }, }, }; - var modelHelper = new DataModelWrapper(model); - modelHelper.GetModelData("friends.name.value").Should().BeNull(); - modelHelper.GetModelData("friends[0].name.value").Should().Be("Donald Duck"); - modelHelper.GetModelData("friends.name.value", [0]).Should().Be("Donald Duck"); - modelHelper.GetModelData("friends[0].age").Should().Be(123); - modelHelper.GetModelData("friends.age", [0]).Should().Be(123); - modelHelper.GetModelData("friends[1].name.value").Should().Be("Dolly Duck"); - modelHelper.GetModelData("friends.name.value", [1]).Should().Be("Dolly Duck"); + var modelHelper = new ReflectionFormDataWrapper(model); + modelHelper.Get("friends.name.value").Should().BeNull(); + modelHelper.Get("friends[0].name.value").Should().Be("Donald Duck"); + modelHelper.Get("friends.name.value", [0]).Should().Be("Donald Duck"); + modelHelper.Get("friends[0].age").Should().Be(123); + modelHelper.Get("friends.age", [0]).Should().Be(123); + modelHelper.Get("friends[1].name.value").Should().Be("Dolly Duck"); + modelHelper.Get("friends.name.value", [1]).Should().Be("Dolly Duck"); // Run the same tests with JsonDataModel using var doc = JsonDocument.Parse(JsonSerializer.Serialize(model)); - var jsonModelHelper = new DataModelWrapper(DynamicClassBuilder.DataObjectFromJsonDocument(doc.RootElement)); - jsonModelHelper.GetModelData("friends.name.value").Should().BeNull(); - jsonModelHelper.GetModelData("friends[0].name.value").Should().Be("Donald Duck"); - jsonModelHelper.GetModelData("friends.name.value", [0]).Should().Be("Donald Duck"); - jsonModelHelper.GetModelData("friends[0].age").Should().Be(123); - jsonModelHelper.GetModelData("friends.age", [0]).Should().Be(123); - jsonModelHelper.GetModelData("friends[1].name.value").Should().Be("Dolly Duck"); - jsonModelHelper.GetModelData("friends.name.value", [1]).Should().Be("Dolly Duck"); + var jsonModelHelper = new ReflectionFormDataWrapper( + DynamicClassBuilder.DataObjectFromJsonDocument(doc.RootElement) + ); + jsonModelHelper.Get("friends.name.value").Should().BeNull(); + jsonModelHelper.Get("friends[0].name.value").Should().Be("Donald Duck"); + jsonModelHelper.Get("friends.name.value", [0]).Should().Be("Donald Duck"); + jsonModelHelper.Get("friends[0].age").Should().Be(123); + jsonModelHelper.Get("friends.age", [0]).Should().Be(123); + jsonModelHelper.Get("friends[1].name.value").Should().Be("Dolly Duck"); + jsonModelHelper.Get("friends.name.value", [1]).Should().Be("Dolly Duck"); } [Fact] @@ -150,39 +149,41 @@ public void DoubleRecursiveLookup() }, }; - var modelHelper = new DataModelWrapper(model); - modelHelper.GetModelData("friends[1].friends[0].name.value").Should().Be("Onkel Skrue"); - modelHelper.GetModelData("friends[1].friends.name.value", [0, 0]).Should().BeNull(); + var modelHelper = new ReflectionFormDataWrapper(model); + modelHelper.Get("friends[1].friends[0].name.value").Should().Be("Onkel Skrue"); + modelHelper.Get("friends[1].friends.name.value", [0, 0]).Should().BeNull(); modelHelper - .GetModelData("friends[1].friends.name.value", [1, 0]) + .Get("friends[1].friends.name.value", [1, 0]) .Should() .BeNull("context indexes should not be used after literal index is used"); - modelHelper.GetModelData("friends[1].friends.name.value", [1]).Should().BeNull(); - modelHelper.GetModelData("friends.friends[0].name.value", [1, 4, 5, 7]).Should().Be("Onkel Skrue"); - modelHelper.GetModelDataCount("friends[1].friends", Array.Empty()).Should().Be(1); - modelHelper.GetModelDataCount("friends.friends", [1]).Should().Be(1); - modelHelper.GetModelDataCount("friends[1].friends.friends", [1, 0, 0]).Should().BeNull(); - modelHelper.GetModelDataCount("friends[1].friends[0].friends", [1, 0, 0]).Should().Be(2); - modelHelper.GetModelDataCount("friends.friends.friends", [1, 0, 0]).Should().Be(2); - modelHelper.GetModelDataCount("friends.friends", [1]).Should().Be(1); + modelHelper.Get("friends[1].friends.name.value", [1]).Should().BeNull(); + modelHelper.Get("friends.friends[0].name.value", [1, 4, 5, 7]).Should().Be("Onkel Skrue"); + modelHelper.GetRowCount("friends[1].friends", Array.Empty()).Should().Be(1); + modelHelper.GetRowCount("friends.friends", [1]).Should().Be(1); + modelHelper.GetRowCount("friends[1].friends.friends", [1, 0, 0]).Should().BeNull(); + modelHelper.GetRowCount("friends[1].friends[0].friends", [1, 0, 0]).Should().Be(2); + modelHelper.GetRowCount("friends.friends.friends", [1, 0]).Should().Be(2); + modelHelper.GetRowCount("friends.friends", [1]).Should().Be(1); // Run the same tests with JsonDataModel using var doc = JsonDocument.Parse(JsonSerializer.Serialize(model)); - var jsonModelHelper = new DataModelWrapper(DynamicClassBuilder.DataObjectFromJsonDocument(doc.RootElement)); - jsonModelHelper.GetModelData("friends[1].friends[0].name.value").Should().Be("Onkel Skrue"); - jsonModelHelper.GetModelData("friends[1].friends.name.value", [0, 0]).Should().BeNull(); + var jsonModelHelper = new ReflectionFormDataWrapper( + DynamicClassBuilder.DataObjectFromJsonDocument(doc.RootElement) + ); + jsonModelHelper.Get("friends[1].friends[0].name.value").Should().Be("Onkel Skrue"); + jsonModelHelper.Get("friends[1].friends.name.value", [0, 0]).Should().BeNull(); jsonModelHelper - .GetModelData("friends[1].friends.name.value", [1, 0]) + .Get("friends[1].friends.name.value", [1, 0]) .Should() .BeNull("context indexes should not be used after literal index is used"); - jsonModelHelper.GetModelData("friends[1].friends.name.value", [1]).Should().BeNull(); - jsonModelHelper.GetModelData("friends.friends[0].name.value", [1, 4, 5, 7]).Should().Be("Onkel Skrue"); - jsonModelHelper.GetModelDataCount("friends[1].friends", Array.Empty()).Should().Be(1); - jsonModelHelper.GetModelDataCount("friends.friends", [1]).Should().Be(1); - jsonModelHelper.GetModelDataCount("friends[1].friends.friends", [1, 0, 0]).Should().BeNull(); - jsonModelHelper.GetModelDataCount("friends[1].friends[0].friends", [1, 0, 0]).Should().Be(2); - jsonModelHelper.GetModelDataCount("friends.friends.friends", [1, 0, 0]).Should().Be(2); - jsonModelHelper.GetModelDataCount("friends.friends", [1]).Should().Be(1); + jsonModelHelper.Get("friends[1].friends.name.value", [1]).Should().BeNull(); + jsonModelHelper.Get("friends.friends[0].name.value", [1, 4, 5, 7]).Should().Be("Onkel Skrue"); + jsonModelHelper.GetRowCount("friends[1].friends", Array.Empty()).Should().Be(1); + jsonModelHelper.GetRowCount("friends.friends", [1]).Should().Be(1); + jsonModelHelper.GetRowCount("friends[1].friends.friends", [1, 0, 0]).Should().BeNull(); + jsonModelHelper.GetRowCount("friends[1].friends[0].friends", [1, 0, 0]).Should().Be(2); + jsonModelHelper.GetRowCount("friends.friends.friends", [1, 0]).Should().Be(2); + jsonModelHelper.GetRowCount("friends.friends", [1]).Should().Be(1); } [Fact] @@ -209,7 +210,7 @@ public void TestRemoveFields() }, }, }; - var modelHelper = new DataModelWrapper(model); + var modelHelper = new ReflectionFormDataWrapper(model); model.Id.Should().Be(2); modelHelper.RemoveField("id", RowRemovalOption.SetToNull); model.Id.Should().Be(default); @@ -294,7 +295,7 @@ public void TestRemoveRows() // deleteRows = false var model1 = JsonSerializer.Deserialize(serializedModel)!; - var modelHelper1 = new DataModelWrapper(model1); + var modelHelper1 = new ReflectionFormDataWrapper(model1); modelHelper1.RemoveField("friends[0].friends[0]", RowRemovalOption.SetToNull); model1.Friends![0].Friends![0].Should().BeNull(); @@ -308,7 +309,7 @@ public void TestRemoveRows() // deleteRows = true var model2 = JsonSerializer.Deserialize(serializedModel)!; - var modelHelper2 = new DataModelWrapper(model2); + var modelHelper2 = new ReflectionFormDataWrapper(model2); modelHelper2.RemoveField("friends[0].friends[0]", RowRemovalOption.DeleteRow); model2.Friends![0].Friends!.Count.Should().Be(2); @@ -322,52 +323,30 @@ public void TestRemoveRows() [Fact] public void TestErrorCases() { - var modelHelper = new DataModelWrapper( + var modelHelper = new ReflectionFormDataWrapper( new Model() { Id = 3, Friends = new List() { new() { Name = new() { Value = "Ole" } } }, } ); - modelHelper.Invoking(m => m.GetModelData(".")).Should().Throw().WithMessage("*empty part*"); - modelHelper.GetModelData("friends[0]").Should().BeOfType().Which.Name?.Value.Should().Be("Ole"); - modelHelper.GetModelData("friends[3]").Should().BeNull(); + modelHelper.Invoking(m => m.Get(".")).Should().Throw().WithMessage("*empty part*"); + modelHelper.Get("friends[0]").Should().BeOfType().Which.Name?.Value.Should().Be("Ole"); + modelHelper.Get("friends[3]").Should().BeNull(); - modelHelper - .Invoking(m => m.AddIndicies("tull.sd", [2])) - .Should() - .Throw() - .WithMessage("Unknown model property tull in*"); + Assert.Null(modelHelper.AddIndexToPath("tull.sd", [2])); modelHelper - .Invoking(m => m.AddIndicies("id[4]", [6])) + .Invoking(m => m.AddIndexToPath("id[4]", [6])) .Should() .Throw() .WithMessage("Index on non indexable property"); } - [Fact] - public void TestEdgeCaseWithNonGenericEnumerableForCoverage() - { - // Test with erroneous model with non-generic IEnumerable (special error for code coverage) - var modelHelper = new DataModelWrapper( - new - { - // ArrayList is not supported as a data model - friends = new ArrayList { 1, 2, 3 }, - } - ); - modelHelper - .Invoking(m => m.AddIndicies("friends", [0])) - .Should() - .Throw() - .WithMessage("DataModels must have generic IEnumerable<> implementation for list"); - } - [Fact] public void TestAddIndicies() { - var modelHelper = new DataModelWrapper( + var modelHelper = new ReflectionFormDataWrapper( new Model { Id = 3, @@ -376,26 +355,32 @@ public void TestAddIndicies() ); // Plain add indicies - modelHelper.AddIndicies("friends.friends", [0, 1]).Should().Be("friends[0].friends[1]"); + modelHelper.AddIndexToPath("friends.friends.name", [0, 1]).Should().Be("friends[0].friends[1].name"); // Ignore extra indicies - modelHelper.AddIndicies("friends.friends", [0, 1, 4, 6]).Should().Be("friends[0].friends[1]"); + modelHelper.AddIndexToPath("friends.friends", [0, 1, 4, 6]).Should().Be("friends[0].friends[1]"); + + // Add empty when too few indexes + modelHelper.AddIndexToPath("friends.friends.friends", [0]).Should().Be("friends[0].friends[].friends[]"); // Don't add indicies if they are specified in input - modelHelper.AddIndicies("friends[3]", [0]).Should().Be("friends[3]"); + modelHelper.AddIndexToPath("friends[3]", [0]).Should().Be("friends[3]"); + + // First index (and remaining) is ignored if first is explicit + modelHelper.AddIndexToPath("friends[0].friends.friends", [2, 3]).Should().Be("friends[0].friends[].friends[]"); - // First index is ignored if it is explicit - modelHelper.AddIndicies("friends[0].friends", [2, 3]).Should().Be("friends[0].friends[3]"); + // After we have used one index from context, we still respect explicit indexes + modelHelper.AddIndexToPath("friends.friends[4].name", [10, 10]).Should().Be("friends[10].friends[4].name"); } [Fact] public void AddIndicies_WhenGivenIndexOnNonIndexableProperty_ThrowsError() { - var modelHelper = new DataModelWrapper(new Model { Id = 3 }); + var modelHelper = new ReflectionFormDataWrapper(new Model { Id = 3 }); // Throws because id is not indexable modelHelper - .Invoking(m => m.AddIndicies("id[0]", [1, 2, 3])) + .Invoking(m => m.AddIndexToPath("id[0]", [1, 2, 3])) .Should() .Throw() .WithMessage("Index on non indexable property"); @@ -404,7 +389,7 @@ public void AddIndicies_WhenGivenIndexOnNonIndexableProperty_ThrowsError() [Fact] public void RemoveField_WhenValueDoesNotExist_DoNothing() { - var modelHelper = new DataModelWrapper(new Model()); + var modelHelper = new ReflectionFormDataWrapper(new Model()); // real fields works, no error modelHelper.RemoveField("id", RowRemovalOption.SetToNull); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs index 59591502cf..218001a949 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs @@ -1,5 +1,7 @@ using System.Collections; using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Models; using Altinn.Platform.Storage.Interface.Models; @@ -66,11 +68,18 @@ public void Add(DataElement? dataElement, object data, int maxCount = 1) public Instance Instance { get; } + public IReadOnlyCollection DataTypes => _applicationMetadata.DataTypes; + public Task GetFormData(DataElementIdentifier dataElementIdentifier) { return Task.FromResult(_dataById[dataElementIdentifier]); } + public Task GetFormDataWrapper(DataElementIdentifier dataElementIdentifier) + { + return Task.FromResult(FormDataWrapperFactory.Create(_dataById[dataElementIdentifier])); + } + public Task> GetBinaryData(DataElementIdentifier dataElementIdentifier) { throw new NotImplementedException(); @@ -84,17 +93,6 @@ public DataElement GetDataElement(DataElementIdentifier dataElementIdentifier) ); } - public DataType? GetDataType(string dataTypeId) - { - if (_applicationMetadata is null) - { - throw new InvalidOperationException("Application metadata not set for InstanceDataAccessorFake"); - } - var dataType = _applicationMetadata.DataTypes.Find(d => d.Id == dataTypeId); - - return dataType; - } - public void AddFormDataElement(string dataType, object model) { throw new NotImplementedException(); diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index 898382c0d5..abd5c335cf 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -1274,14 +1274,19 @@ namespace Altinn.App.Core.Features } public interface IInstanceDataAccessor { + System.Collections.Generic.IReadOnlyCollection DataTypes { get; } Altinn.Platform.Storage.Interface.Models.Instance Instance { get; } System.Threading.Tasks.Task> GetBinaryData(Altinn.App.Core.Models.DataElementIdentifier dataElementIdentifier); Altinn.Platform.Storage.Interface.Models.DataElement GetDataElement(Altinn.App.Core.Models.DataElementIdentifier dataElementIdentifier); - Altinn.Platform.Storage.Interface.Models.DataType? GetDataType(string dataTypeId); System.Threading.Tasks.Task GetFormData(Altinn.App.Core.Models.DataElementIdentifier dataElementIdentifier); + System.Threading.Tasks.Task GetFormDataWrapper(Altinn.App.Core.Models.DataElementIdentifier dataElementIdentifier); } public static class IInstanceDataAccessorExtensions { + public static System.Threading.Tasks.Task GetAllFormData(this Altinn.App.Core.Features.IInstanceDataAccessor accessor) + where T : class { } + public static System.Threading.Tasks.Task GetAllFormData(this Altinn.App.Core.Features.IInstanceDataAccessor accessor, Altinn.Platform.Storage.Interface.Models.DataType dataType) + where T : class { } [return: System.Runtime.CompilerServices.TupleElementNames(new string[] { "dataType", "dataElement"})] @@ -1301,8 +1306,14 @@ namespace Altinn.App.Core.Features "dataElement"})] public static System.Collections.Generic.IEnumerable> GetDataElementsWithFormDataForTask(this Altinn.App.Core.Features.IInstanceDataAccessor accessor, string taskId) { } public static Altinn.Platform.Storage.Interface.Models.DataType GetDataType(this Altinn.App.Core.Features.IInstanceDataAccessor dataAccessor, Altinn.App.Core.Models.DataElementIdentifier dataElementIdentifier) { } + public static Altinn.Platform.Storage.Interface.Models.DataType GetDataType(this Altinn.App.Core.Features.IInstanceDataAccessor dataAccessor, string dataTypeId) { } + public static Altinn.Platform.Storage.Interface.Models.DataType GetDataType(this Altinn.App.Core.Features.IInstanceDataAccessor accessor) { } + public static System.Threading.Tasks.Task GetFormData(this Altinn.App.Core.Features.IInstanceDataAccessor accessor) + where T : class { } public static System.Threading.Tasks.Task GetFormData(this Altinn.App.Core.Features.IInstanceDataAccessor accessor, Altinn.App.Core.Models.DataElementIdentifier dataElementIdentifier) where T : class { } + public static System.Threading.Tasks.Task GetFormData(this Altinn.App.Core.Features.IInstanceDataAccessor accessor, Altinn.Platform.Storage.Interface.Models.DataType dataType) + where T : class { } } public interface IInstanceDataListProvider { @@ -2192,6 +2203,8 @@ namespace Altinn.App.Core.Helpers } namespace Altinn.App.Core.Helpers.DataModel { + [System.Obsolete("This class is deprecated and will be removed in a future version. Use the new Lay" + + "outEvaluatorState instead.")] public class DataModel { public DataModel(Altinn.App.Core.Features.IInstanceDataAccessor dataAccessor) { } @@ -2207,6 +2220,8 @@ namespace Altinn.App.Core.Helpers.DataModel { public DataModelException(string msg) { } } + [System.Obsolete("Will be removed in v9 use Altinn.App.Core.Helpers.DataModel.FormDataWrapperFactor" + + "y instead")] public class DataModelWrapper { public DataModelWrapper(object dataModel) { } From 6df1896e5ae237441c9f18853fef0c23a435e42f Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Tue, 16 Sep 2025 22:37:26 +0200 Subject: [PATCH 2/3] Fix usage of IFormDataWrapper --- .../Features/IInstanceDataAccessor.cs | 9 ++--- .../Helpers/DataModel/DataModel.cs | 10 ++--- .../Helpers/DataModel/DataModelWrapper.cs | 6 +-- .../DataModel/FormDataWrapperFactory.cs | 25 +++++++++--- .../Internal/Data/InstanceDataUnitOfWork.cs | 2 +- .../Expressions/LayoutEvaluatorState.cs | 40 ++++++++++--------- .../Controllers/SigningControllerTests.cs | 2 +- .../ValidateControllerValidateDataTests.cs | 8 ++-- .../LayoutExpressions/TestDataModel.cs | 18 ++++----- ...ouldNotChange_Unintentionally.verified.txt | 1 + .../UnitTest/TestFormDataWrapperFactory.cs | 39 ++++++++++++++++++ 11 files changed, 107 insertions(+), 53 deletions(-) create mode 100644 test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestFormDataWrapperFactory.cs diff --git a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs index ab5f20e217..42f2fa5c05 100644 --- a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs @@ -74,8 +74,8 @@ public static DataType GetDataType( DataElementIdentifier dataElementIdentifier ) { - var dataElement = dataAccessor.GetDataElement(dataElementIdentifier); - return dataAccessor.GetDataType(dataElement.DataType); + var dataType = dataElementIdentifier.DataTypeId ?? dataAccessor.GetDataElement(dataElementIdentifier).DataType; + return dataAccessor.GetDataType(dataType); } /// @@ -263,10 +263,7 @@ this IInstanceDataAccessor accessor foreach (var dataElement in accessor.Instance.Data) { var dataType = accessor.GetDataType(dataElement.DataType); - if (dataType is not null) - { - yield return (dataType, dataElement); - } + yield return (dataType, dataElement); } } } diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index 9151edd987..4ac78195c3 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -82,9 +82,9 @@ DataElementIdentifier defaultDataElementIdentifier /// Get model data based on key and optionally indexes /// /// - /// Inline indicies in the key "Bedrifter[1].Ansatte[1].Alder" will override - /// normal indicies, and if both "Bedrifter" and "Ansatte" is lists, - /// "Bedrifter[1].Ansatte.Alder", will fail, because the indicies will be reset + /// Inline indexes in the key "Bedrifter[1].Ansatte[1].Alder" will override + /// normal indexes, and if both "Bedrifter" and "Ansatte" is lists, + /// "Bedrifter[1].Ansatte.Alder", will fail, because the indexes will be reset /// after an inline index is used /// public async Task GetModelData( @@ -149,11 +149,11 @@ public async Task GetResolvedKeys(DataReference reference) } /// - /// Return a full dataModelBiding from a context aware binding by adding indicies + /// Return a full dataModelBiding from a context aware binding by adding indexes /// /// /// key = "bedrift.ansatte.navn" - /// indicies = [1,2] + /// indexes = [1,2] /// => "bedrift[1].ansatte[2].navn" /// public async Task AddIndexes( diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs index d9742be8bd..0f3752c223 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs @@ -13,7 +13,7 @@ public class DataModelWrapper private readonly object _dataModel; /// - /// Constructor that wraps a PCOC data model, and gives extra tool for working with the data in an object using json like keys and reflection + /// Constructor that wraps a POCO data model, and gives extra tools for working with the data in an object using json like keys and reflection /// public DataModelWrapper(object dataModel) { @@ -297,11 +297,11 @@ ReadOnlySpan indexes } /// - /// Return a full dataModelBiding from a context aware binding by adding indexes + /// Return a full dataModelBinding from a context aware binding by adding indexes /// /// /// key = "bedrift.ansatte.navn" - /// indicies = [1,2] + /// indexes = [1,2] /// => "bedrift[1].ansatte[2].navn" /// public string AddIndicies(string field, ReadOnlySpan rowIndexes = default) diff --git a/src/Altinn.App.Core/Helpers/DataModel/FormDataWrapperFactory.cs b/src/Altinn.App.Core/Helpers/DataModel/FormDataWrapperFactory.cs index a58612e914..7ec34201bd 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/FormDataWrapperFactory.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/FormDataWrapperFactory.cs @@ -1,6 +1,5 @@ using System.Collections.Frozen; using System.Diagnostics; -using System.Reflection; using Altinn.App.Core.Features; namespace Altinn.App.Core.Helpers.DataModel; @@ -11,11 +10,25 @@ internal static class FormDataWrapperFactory private static FrozenDictionary InitializePathAccessorLookup() { - return Assembly - .GetExecutingAssembly() - .GetTypes() - .Where(t => t.IsClass && t.IsAssignableTo(typeof(IFormDataWrapper<>))) - .ToFrozenDictionary(k => k.GenericTypeArguments[0], v => v); + var interfaceType = typeof(IFormDataWrapper<>); + return AppDomain + .CurrentDomain.GetAssemblies() + .SelectMany(a => + { + List> pathAccessors = new(); + var assemblyTypes = a.GetExportedTypes(); + foreach (var type in assemblyTypes) + { + var formDataWrapperInterface = type.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == interfaceType); + if (formDataWrapperInterface is not null) + { + pathAccessors.Add(KeyValuePair.Create(formDataWrapperInterface.GenericTypeArguments[0], type)); + } + } + return pathAccessors; + }) + .ToFrozenDictionary(); } public static IFormDataWrapper Create(object dataModel) diff --git a/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs b/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs index bd5a0e845b..4368111cab 100644 --- a/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs +++ b/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs @@ -267,7 +267,7 @@ public void RemoveDataElement(DataElementIdentifier dataElementIdentifier) DataType = dataType, ContentType = dataElement.ContentType, CurrentFormData = _formDataCache.TryGetCachedValue(dataElementIdentifier, out var cfd) - ? cfd + ? cfd.BackingData() : _modelSerializationService.GetEmpty(dataType), PreviousFormData = _modelSerializationService.GetEmpty(dataType), CurrentBinaryData = ReadOnlyMemory.Empty, diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index 9290736589..46e4402696 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -247,11 +247,11 @@ public int CountDataElements(string dataTypeId) } /// - /// Return a full dataModelBiding from a context aware binding by adding indicies + /// Return a full dataModelBiding from a context aware binding by adding indexes /// /// /// key = "bedrift.ansatte.navn" - /// indicies = [1,2] + /// indexes = [1,2] /// => "bedrift[1].ansatte[2].navn" /// public async Task AddInidicies(ModelBinding binding, ComponentContext context) @@ -268,40 +268,44 @@ private DataElementIdentifier ResolveDataElementIdentifier( DataElementIdentifier defaultDataElementIdentifier ) { - if ( - key.DataType == null - || defaultDataElementIdentifier.DataTypeId == key.DataType - || _dataAccessor.GetDataType(defaultDataElementIdentifier).Id == key.DataType - ) + // If the binding don't have a specific data type, use the default + if (key.DataType == null) + { + return defaultDataElementIdentifier; + } + // If the data element has the same type as default, return it + var dataType = _dataAccessor.GetDataType(defaultDataElementIdentifier); + if (dataType.Id == key.DataType) { return defaultDataElementIdentifier; } + // Return correct element if the data type has a single element on the instance and MaxCount == 1 if (_dataIdsByType.TryGetValue(key.DataType, out var dataElementId)) { return dataElementId; } - if (_dataAccessor.GetDataType(key.DataType) is { } dataType) + + // Raise the correct error + if (dataType.AppLogic?.ClassRef is null) { - if (dataType.MaxCount != 1) - { - throw new InvalidOperationException( - $"{key.DataType} has maxCount different from 1 in applicationmetadata.json or don't have a classRef in appLogic" - ); - } throw new InvalidOperationException( $"{key.DataType} has no classRef in applicationmetadata.json and can't be used as a data model in layouts" ); } - - throw new InvalidOperationException( - $"Data model with type {key.DataType} not found in applicationmetadata.json" - ); + if (dataType.MaxCount != 1) + { + throw new InvalidOperationException( + $"{key.DataType} has maxCount different from 1 in applicationmetadata.json and must be part of a subform when used in layouts" + ); + } + throw new InvalidOperationException($"Data element with type {key.DataType} not found on instance"); } /// /// Return a full dataModelBiding from a context aware binding by adding indexes /// + [Obsolete("This method is deprecated and will be removed in a future version.")] public async Task AddInidicies( ModelBinding binding, DataElementIdentifier dataElementIdentifier, diff --git a/test/Altinn.App.Api.Tests/Controllers/SigningControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/SigningControllerTests.cs index 09285b19f8..6a68021983 100644 --- a/test/Altinn.App.Api.Tests/Controllers/SigningControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/SigningControllerTests.cs @@ -622,7 +622,7 @@ public async Task GetAuthorizedOrganizations_TaskTypeIsNotSigning_Returns_BadReq } [Fact] - public async Task GetAuthorizedOrganizations_UserIdIsNull_Returns_Unathorized() + public async Task GetAuthorizedOrganizations_UserIdIsNull_Returns_Unauthorized() { // Arrange SetupAuthenticationContextMock(authenticated: CreateAuthenticatedNone()); diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs index 493cd9ef24..2189c72552 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs @@ -32,17 +32,17 @@ public class TestScenariosData : IEnumerable ReceivedInstance = null, ExpectedResult = typeof(NotFoundResult), }, - new("thows_ValidationException_when_instance_process_is_null") + new("throws_ValidationException_when_instance_process_is_null") { ReceivedInstance = new Instance { Process = null }, ExpectedExceptionMessage = "Unable to validate instance without a started process.", }, - new("thows_ValidationException_when_Instance_Process_CurrentTask_is_null") + new("throws_ValidationException_when_Instance_Process_CurrentTask_is_null") { ReceivedInstance = new Instance { Process = new ProcessState { CurrentTask = null } }, ExpectedExceptionMessage = "Unable to validate instance without a started process.", }, - new("thows_ValidationException_when_Instance_Data_is_empty") + new("throws_ValidationException_when_Instance_Data_is_empty") { ReceivedInstance = new Instance { @@ -51,7 +51,7 @@ public class TestScenariosData : IEnumerable }, ExpectedExceptionMessage = "Unable to validate data element.", }, - new("thows_ValidationException_when_Application_DataTypes_is_empty") + new("throws_ValidationException_when_Application_DataTypes_is_empty") { DataGuid = _dataGuid, ReceivedInstance = new Instance diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs index a25b11fae8..e0584d1f75 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs @@ -23,7 +23,7 @@ public void TestSimpleGet() } [Fact] - public void AttributeNoAttriubteCaseSensitive() + public void AttributeNoAttributeCaseSensitive() { var model = new Model { NoAttribute = "asdfsf559" }; var modelHelper = new ReflectionFormDataWrapper(model); @@ -330,7 +330,7 @@ public void TestErrorCases() Friends = new List() { new() { Name = new() { Value = "Ole" } } }, } ); - modelHelper.Invoking(m => m.Get(".")).Should().Throw().WithMessage("*empty part*"); + Assert.Null(modelHelper.Get(".")); modelHelper.Get("friends[0]").Should().BeOfType().Which.Name?.Value.Should().Be("Ole"); modelHelper.Get("friends[3]").Should().BeNull(); @@ -344,7 +344,7 @@ public void TestErrorCases() } [Fact] - public void TestAddIndicies() + public void TestAddIndexes() { var modelHelper = new ReflectionFormDataWrapper( new Model @@ -354,27 +354,27 @@ public void TestAddIndicies() } ); - // Plain add indicies + // Plain add indexes modelHelper.AddIndexToPath("friends.friends.name", [0, 1]).Should().Be("friends[0].friends[1].name"); - // Ignore extra indicies + // Ignore extra indexes modelHelper.AddIndexToPath("friends.friends", [0, 1, 4, 6]).Should().Be("friends[0].friends[1]"); // Add empty when too few indexes - modelHelper.AddIndexToPath("friends.friends.friends", [0]).Should().Be("friends[0].friends[].friends[]"); + Assert.Null(modelHelper.AddIndexToPath("friends.friends.friends", [0])); - // Don't add indicies if they are specified in input + // Don't add indexes if they are specified in input modelHelper.AddIndexToPath("friends[3]", [0]).Should().Be("friends[3]"); // First index (and remaining) is ignored if first is explicit - modelHelper.AddIndexToPath("friends[0].friends.friends", [2, 3]).Should().Be("friends[0].friends[].friends[]"); + Assert.Null(modelHelper.AddIndexToPath("friends[0].friends.friends", [2, 3])); // After we have used one index from context, we still respect explicit indexes modelHelper.AddIndexToPath("friends.friends[4].name", [10, 10]).Should().Be("friends[10].friends[4].name"); } [Fact] - public void AddIndicies_WhenGivenIndexOnNonIndexableProperty_ThrowsError() + public void AddIndexes_WhenGivenIndexOnNonIndexableProperty_ThrowsError() { var modelHelper = new ReflectionFormDataWrapper(new Model { Id = 3 }); diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index abd5c335cf..13fe675ccc 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -3064,6 +3064,7 @@ namespace Altinn.App.Core.Internal.Expressions public LayoutEvaluatorState(Altinn.App.Core.Features.IInstanceDataAccessor dataAccessor, Altinn.App.Core.Models.Layout.LayoutModel? componentModel, Altinn.App.Core.Internal.Texts.ITranslationService translationService, Altinn.App.Core.Configuration.FrontEndSettings frontEndSettings, string? gatewayAction = null, string? language = null, System.TimeZoneInfo? timeZone = null) { } public Altinn.Platform.Storage.Interface.Models.Instance Instance { get; } public System.Threading.Tasks.Task AddInidicies(Altinn.App.Core.Models.Layout.ModelBinding binding, Altinn.App.Core.Models.Expressions.ComponentContext context) { } + [System.Obsolete("This method is deprecated and will be removed in a future version.")] public System.Threading.Tasks.Task AddInidicies(Altinn.App.Core.Models.Layout.ModelBinding binding, Altinn.App.Core.Models.DataElementIdentifier dataElementIdentifier, int[]? indexes) { } public int CountDataElements(string dataTypeId) { } [System.Obsolete("You need to get a context, not a component", true)] diff --git a/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestFormDataWrapperFactory.cs b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestFormDataWrapperFactory.cs new file mode 100644 index 0000000000..fbf7c198cc --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestFormDataWrapperFactory.cs @@ -0,0 +1,39 @@ +using System; +using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.SourceGenerator.Integration.Tests.Models; +using Xunit.Abstractions; + +public class TestFormDataWrapperFactory(ITestOutputHelper testOutputHelper) +{ + [Fact] + public void Create_ShouldReturnValidWrapper() + { + // Arrange + var model = new Skjema(); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var wrapper = FormDataWrapperFactory.Create(model); + testOutputHelper.WriteLine($"First execution {stopwatch.Elapsed.TotalMicroseconds} microseconds"); + Assert.IsType(wrapper); + Assert.IsNotType(wrapper); + + // Act + var result = wrapper.BackingData(); + + // Assert + Assert.NotNull(result); + Assert.Equal(model, result); + + // Measure second execution time + stopwatch.Restart(); + var wrapper2 = FormDataWrapperFactory.Create(model); + stopwatch.Stop(); + testOutputHelper.WriteLine($"Second execution {stopwatch.Elapsed.TotalMicroseconds} microseconds"); + Assert.IsType(wrapper2); + Assert.IsNotType(wrapper2); + Assert.NotSame(wrapper, wrapper2); + + // Assert that the second execution is very fast (due to caching) + // Would like to assert lower, but lets be conservative to avoid flaky tests + Assert.InRange(stopwatch.Elapsed, TimeSpan.Zero, TimeSpan.FromMilliseconds(2)); + } +} From e09fc81b78f797f0694e6571d8fd799d599cf7da Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Thu, 18 Sep 2025 10:41:20 +0200 Subject: [PATCH 3/3] More coderabit fixes --- .../Controllers/DataController.cs | 4 +- .../Helpers/Patch/InternalPatchService.cs | 8 +- .../Features/IInstanceDataAccessor.cs | 9 +-- .../Validation/Default/ExpressionValidator.cs | 2 +- .../Helpers/DataModel/DataModel.cs | 2 +- .../Helpers/DataModel/DataModelWrapper.cs | 2 +- .../DataModel/FormDataWrapperFactory.cs | 23 ++++-- .../Internal/Data/InstanceDataUnitOfWork.cs | 77 +++++++++---------- .../Expressions/LayoutEvaluatorState.cs | 26 +++++-- .../Models/DataElementChanges.cs | 19 ++++- .../ValidationServiceTests.cs | 9 ++- .../Validators/ValidationServiceTests.cs | 9 ++- .../ExpressionsExclusiveGatewayTests.cs | 28 +++---- ...ouldNotChange_Unintentionally.verified.txt | 6 +- .../UnitTest/TestFormDataWrapperFactory.cs | 3 + 15 files changed, 126 insertions(+), 101 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 38f99d9834..92c37578f8 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -1127,8 +1127,8 @@ private async Task PutFormData( DataElement = dataElement, ContentType = dataElement.ContentType, DataType = dataType, - PreviousFormData = oldServiceModel, - CurrentFormData = serviceModel, + PreviousFormDataWrapper = FormDataWrapperFactory.Create(oldServiceModel), + CurrentFormDataWrapper = FormDataWrapperFactory.Create(serviceModel), PreviousBinaryData = await dataMutator.GetBinaryData(dataElement), CurrentBinaryData = null, // We don't serialize to xml before running data processors }; diff --git a/src/Altinn.App.Api/Helpers/Patch/InternalPatchService.cs b/src/Altinn.App.Api/Helpers/Patch/InternalPatchService.cs index 7b426366ee..25361c2eba 100644 --- a/src/Altinn.App.Api/Helpers/Patch/InternalPatchService.cs +++ b/src/Altinn.App.Api/Helpers/Patch/InternalPatchService.cs @@ -127,8 +127,8 @@ public async Task> ApplyPatches( DataElement = dataElement, ContentType = dataElement.ContentType, DataType = dataAccessor.GetDataType(dataElementIdentifier), - PreviousFormData = oldModel, - CurrentFormData = newModel, + PreviousFormDataWrapper = FormDataWrapperFactory.Create(oldModel), + CurrentFormDataWrapper = FormDataWrapperFactory.Create(newModel), PreviousBinaryData = await dataAccessor.GetBinaryData(dataElementIdentifier), CurrentBinaryData = null, // Set this after DataProcessors have run } @@ -192,8 +192,8 @@ await RunDataProcessors( DataElement = dataElement, ContentType = dataElement.ContentType, DataType = dataAccessor.GetDataType(dataElement), - PreviousFormData = await dataAccessor.GetFormData(dataElement), - CurrentFormData = await dataAccessor.GetFormData(dataElement), + PreviousFormDataWrapper = await dataAccessor.GetFormDataWrapper(dataElement), + CurrentFormDataWrapper = await dataAccessor.GetFormDataWrapper(dataElement), PreviousBinaryData = await dataAccessor.GetBinaryData(dataElement), CurrentBinaryData = await dataAccessor.GetBinaryData(dataElement), } diff --git a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs index 42f2fa5c05..37435d5798 100644 --- a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs @@ -173,14 +173,7 @@ public static async Task GetAllFormData(this IInstanceDataAccessor acces public static async Task GetAllFormData(this IInstanceDataAccessor accessor, DataType dataType) where T : class { - var dataElements = accessor.GetDataElementsForType(dataType).ToArray(); - var result = new T[dataElements.Length]; - for (int i = 0; i < dataElements.Length; i++) - { - result[i] = await accessor.GetFormData(dataElements[i]); - } - - return result; + return await Task.WhenAll(accessor.GetDataElementsForType(dataType).Select(e => accessor.GetFormData(e))); } /// diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index b4862faa8e..3e69d5b659 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -167,7 +167,7 @@ await RunValidation( private static int[]? GetRowIndices(string field) { - Span rowIndicesSpan = stackalloc int[20]; // Assuming max 20 indices for simplicity + 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++) { diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index 4ac78195c3..fe7684e74f 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -149,7 +149,7 @@ public async Task GetResolvedKeys(DataReference reference) } /// - /// Return a full dataModelBiding from a context aware binding by adding indexes + /// Return a full dataModelBinding from a context aware binding by adding indexes /// /// /// key = "bedrift.ansatte.navn" diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs index 0f3752c223..6c2fa1b89a 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs @@ -21,7 +21,7 @@ public DataModelWrapper(object dataModel) } /// - /// Get model data based on key and optionally indicies + /// Get model data based on key and optionally indexes /// /// /// Inline indexes in the key "Bedrifter[1].Ansatte[1].Alder" will override diff --git a/src/Altinn.App.Core/Helpers/DataModel/FormDataWrapperFactory.cs b/src/Altinn.App.Core/Helpers/DataModel/FormDataWrapperFactory.cs index 7ec34201bd..42d0ddb6fc 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/FormDataWrapperFactory.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/FormDataWrapperFactory.cs @@ -16,16 +16,27 @@ private static FrozenDictionary InitializePathAccessorLookup() .SelectMany(a => { List> pathAccessors = new(); - var assemblyTypes = a.GetExportedTypes(); - foreach (var type in assemblyTypes) + try { - var formDataWrapperInterface = type.GetInterfaces() - .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == interfaceType); - if (formDataWrapperInterface is not null) + var assemblyTypes = a.GetExportedTypes(); + foreach (var type in assemblyTypes) { - pathAccessors.Add(KeyValuePair.Create(formDataWrapperInterface.GenericTypeArguments[0], type)); + var formDataWrapperInterface = type.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == interfaceType); + if (formDataWrapperInterface is not null) + { + pathAccessors.Add( + KeyValuePair.Create(formDataWrapperInterface.GenericTypeArguments[0], type) + ); + } } } + catch (Exception) + { + // Just ignore it if we can't load the assembly types for some reason + // It is likely not the relevant assembly anyway + } + return pathAccessors; }) .ToFrozenDictionary(); diff --git a/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs b/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs index 4368111cab..375923e616 100644 --- a/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs +++ b/src/Altinn.App.Core/Internal/Data/InstanceDataUnitOfWork.cs @@ -105,13 +105,17 @@ public async Task GetFormDataWrapper(DataElementIdentifier dat dataElementIdentifier, async () => { + var dataType = this.GetDataType(dataElementIdentifier); + if (dataType.AppLogic?.ClassRef is null) + { + throw new InvalidOperationException( + $"Data element {dataElementIdentifier.Id} is of data type {dataType.Id} which doesn't have app logic in application metadata and cant be used as form data" + ); + } var binaryData = await GetBinaryData(dataElementIdentifier); return FormDataWrapperFactory.Create( - _modelSerializationService.DeserializeFromStorage( - binaryData.Span, - this.GetDataType(dataElementIdentifier) - ) + _modelSerializationService.DeserializeFromStorage(binaryData.Span, dataType) ); } ); @@ -179,8 +183,8 @@ public FormDataChange AddFormDataElement(string dataTypeId, object model) DataElement = null, DataType = dataType, ContentType = contentType, - CurrentFormData = model, - PreviousFormData = _modelSerializationService.GetEmpty(dataType), + CurrentFormDataWrapper = FormDataWrapperFactory.Create(model), + PreviousFormDataWrapper = FormDataWrapperFactory.Create(_modelSerializationService.GetEmpty(dataType)), CurrentBinaryData = bytes, PreviousBinaryData = default, // empty memory reference }; @@ -266,10 +270,12 @@ public void RemoveDataElement(DataElementIdentifier dataElementIdentifier) DataElement = dataElement, DataType = dataType, ContentType = dataElement.ContentType, - CurrentFormData = _formDataCache.TryGetCachedValue(dataElementIdentifier, out var cfd) - ? cfd.BackingData() - : _modelSerializationService.GetEmpty(dataType), - PreviousFormData = _modelSerializationService.GetEmpty(dataType), + CurrentFormDataWrapper = _formDataCache.TryGetCachedValue(dataElementIdentifier, out var cfd) + ? cfd + : FormDataWrapperFactory.Create(_modelSerializationService.GetEmpty(dataType)), + PreviousFormDataWrapper = FormDataWrapperFactory.Create( + _modelSerializationService.GetEmpty(dataType) + ), CurrentBinaryData = ReadOnlyMemory.Empty, PreviousBinaryData = _binaryCache.TryGetCachedValue(dataElementIdentifier, out var value) ? value @@ -352,12 +358,11 @@ out ReadOnlyMemory cachedBinary DataElement = dataElement, ContentType = dataElement.ContentType, DataType = dataType, - CurrentFormData = dataWrapper.BackingData(), + CurrentFormDataWrapper = dataWrapper, // For patch requests we could get the previous data from the patch, but it's not available here // and deserializing twice is not a big deal - PreviousFormData = _modelSerializationService.DeserializeFromStorage( - cachedBinary.Span, - dataType + PreviousFormDataWrapper = FormDataWrapperFactory.Create( + _modelSerializationService.DeserializeFromStorage(cachedBinary.Span, dataType) ), CurrentBinaryData = currentBinary, PreviousBinaryData = cachedBinary, @@ -401,18 +406,22 @@ DataElementChange change createdDataElements.TryAdd(change, dataElement); } - private async Task UpdateDataElement( - DataElementIdentifier dataElementIdentifier, - string contentType, - string? filename, - ReadOnlyMemory bytes - ) + private async Task UpdateDataElement(FormDataChange change) { + if (change.CurrentBinaryData is null) + { + throw new InvalidOperationException( + "ChangeType.Updated sent to SaveChanges must have a CurrentBinaryData value" + ); + } + if (change.DataElement is null) + throw new InvalidOperationException("ChangeType.Updated sent to SaveChanges must have a DataElement value"); + ReadOnlyMemory bytes = change.CurrentBinaryData.Value; await _dataClient.UpdateBinaryData( new InstanceIdentifier(Instance), - contentType, - filename, - dataElementIdentifier.Guid, + change.DataElement.ContentType, + change.DataElement.Filename, + Guid.Parse(change.DataElement.Id), new MemoryAsStream(bytes) ); } @@ -499,26 +508,9 @@ internal async Task SaveChanges(DataElementChanges changes) foreach (var change in changes.FormDataChanges) { if (change.Type != ChangeType.Updated) - continue; - if (change.CurrentBinaryData is null) - { - throw new InvalidOperationException( - "ChangeType.Updated sent to SaveChanges must have a CurrentBinaryData value" - ); - } - if (change.DataElement is null) - throw new InvalidOperationException( - "ChangeType.Updated sent to SaveChanges must have a DataElement value" - ); + continue; // New and deleted form data is handled separately - tasks.Add( - UpdateDataElement( - change.DataElement, - change.DataElement.ContentType, - change.DataElement.Filename, - change.CurrentBinaryData.Value - ) - ); + tasks.Add(UpdateDataElement(change)); } await Task.WhenAll(tasks); @@ -529,6 +521,7 @@ internal async Task SaveChanges(DataElementChanges changes) /// internal void SetFormData(DataElementIdentifier dataElementIdentifier, IFormDataWrapper formDataWrapper) { + ArgumentNullException.ThrowIfNull(formDataWrapper); var dataType = this.GetDataType(dataElementIdentifier); if (dataType.AppLogic?.ClassRef is not { } classRef) { diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index 46e4402696..0f87d35b97 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -44,10 +44,13 @@ public LayoutEvaluatorState( TimeZoneInfo? timeZone = null ) { + // Precompute a map of data types to data element ids for all single-instance data types + // This is used to resolve data element ids when a specific data type is requested in a binding foreach (var (dataType, dataElement) in dataAccessor.GetDataElements()) { if (dataType is { MaxCount: 1, AppLogic.ClassRef: not null }) { + // There should never be duplicates because of MaxCount == 1, but just in case, we only want the first one _dataIdsByType.TryAdd(dataElement.DataType, dataElement); } } @@ -247,7 +250,7 @@ public int CountDataElements(string dataTypeId) } /// - /// Return a full dataModelBiding from a context aware binding by adding indexes + /// Return a full dataModelBinding from a context-aware binding by adding indexes /// /// /// key = "bedrift.ansatte.navn" @@ -259,7 +262,13 @@ public async Task AddInidicies(ModelBinding binding, ComponentCon var dataElementId = ResolveDataElementIdentifier(binding, context.DataElementIdentifier); var formDataWrapper = await _dataAccessor.GetFormDataWrapper(dataElementId); - var field = formDataWrapper.AddIndexToPath(binding.Field, context.RowIndices) ?? ""; + var field = + formDataWrapper.AddIndexToPath(binding.Field, context.RowIndices) + ?? throw new InvalidOperationException( + $"Failed to add indexes to path {binding.Field} with indexes " + + $"{(context.RowIndices is null ? "null" : string.Join(", ", context.RowIndices))} on {dataElementId}" + ); + ; return new DataReference() { Field = field, DataElementIdentifier = dataElementId }; } @@ -274,26 +283,27 @@ DataElementIdentifier defaultDataElementIdentifier return defaultDataElementIdentifier; } // If the data element has the same type as default, return it - var dataType = _dataAccessor.GetDataType(defaultDataElementIdentifier); - if (dataType.Id == key.DataType) + var defaultDataType = _dataAccessor.GetDataType(defaultDataElementIdentifier); + if (defaultDataType.Id == key.DataType) { return defaultDataElementIdentifier; } - // Return correct element if the data type has a single element on the instance and MaxCount == 1 + // Return the correct element if the data type has a single element on the instance and MaxCount == 1 if (_dataIdsByType.TryGetValue(key.DataType, out var dataElementId)) { return dataElementId; } // Raise the correct error - if (dataType.AppLogic?.ClassRef is null) + var requestedDataType = _dataAccessor.GetDataType(key.DataType); + if (requestedDataType.AppLogic?.ClassRef is null) { throw new InvalidOperationException( $"{key.DataType} has no classRef in applicationmetadata.json and can't be used as a data model in layouts" ); } - if (dataType.MaxCount != 1) + if (requestedDataType.MaxCount != 1) { throw new InvalidOperationException( $"{key.DataType} has maxCount different from 1 in applicationmetadata.json and must be part of a subform when used in layouts" @@ -303,7 +313,7 @@ DataElementIdentifier defaultDataElementIdentifier } /// - /// Return a full dataModelBiding from a context aware binding by adding indexes + /// Return a full dataModelBinding from a context aware binding by adding indexes /// [Obsolete("This method is deprecated and will be removed in a future version.")] public async Task AddInidicies( diff --git a/src/Altinn.App.Core/Models/DataElementChanges.cs b/src/Altinn.App.Core/Models/DataElementChanges.cs index 376923f52f..6bb06bf018 100644 --- a/src/Altinn.App.Core/Models/DataElementChanges.cs +++ b/src/Altinn.App.Core/Models/DataElementChanges.cs @@ -1,3 +1,4 @@ +using Altinn.App.Core.Features; using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Models; @@ -103,14 +104,24 @@ public sealed class BinaryDataChange : DataElementChange public sealed class FormDataChange : DataElementChange { /// - /// The state of the data element before the change + /// A POCO object representing the state of the data element before the change /// - public required object PreviousFormData { get; init; } + public object PreviousFormData => PreviousFormDataWrapper.BackingData(); /// - /// The state of the data element after the change + /// The previous form data wrapped in a /// - public required object CurrentFormData { get; init; } + public required IFormDataWrapper PreviousFormDataWrapper { get; init; } + + /// + /// A POCO object representing the state of the data element after the change + /// + public object CurrentFormData => CurrentFormDataWrapper.BackingData(); + + /// + /// The data after the change wrapped in a + /// + public required IFormDataWrapper CurrentFormDataWrapper { get; init; } /// /// The binary representation (for storage) of the data element before changes diff --git a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs index 9a42e12239..dc2503eb2b 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; @@ -372,8 +373,8 @@ public async Task ValidateFormData_WithSpecificValidator() DataElement = _defaultDataElement, DataType = _defaultDataType, ContentType = "application/xml", - PreviousFormData = previousData, - CurrentFormData = data, + PreviousFormDataWrapper = FormDataWrapperFactory.Create(previousData), + CurrentFormDataWrapper = FormDataWrapperFactory.Create(data), PreviousBinaryData = null, CurrentBinaryData = null, }, @@ -421,8 +422,8 @@ public async Task ValidateFormData_WithMyNameValidator_ReturnsErrorsWhenNameIsKa DataElement = _defaultDataElement, DataType = _defaultDataType, ContentType = "application/xml", - CurrentFormData = data, - PreviousFormData = data, + CurrentFormDataWrapper = FormDataWrapperFactory.Create(data), + PreviousFormDataWrapper = FormDataWrapperFactory.Create(data), PreviousBinaryData = null, CurrentBinaryData = null, }, diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs index 20d2c3597a..590e5e8940 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs @@ -1,5 +1,6 @@ using Altinn.App.Core.Features; using Altinn.App.Core.Features.Validation; +using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Texts; @@ -441,8 +442,8 @@ public async Task GenericFormDataValidator_serviceModelIsString_CallsValidatorFu DataElement = dataElement, DataType = _instanceDataAccessor.GetDataType(dataElement), ContentType = "text/plain", - CurrentFormData = "currentValue", - PreviousFormData = "previousValue", + CurrentFormDataWrapper = FormDataWrapperFactory.Create("currentValue"), + PreviousFormDataWrapper = FormDataWrapperFactory.Create("previousValue"), CurrentBinaryData = default, PreviousBinaryData = default, }, @@ -452,8 +453,8 @@ public async Task GenericFormDataValidator_serviceModelIsString_CallsValidatorFu DataElement = dataElementNoValidation, DataType = _instanceDataAccessor.GetDataType(dataElement), ContentType = "text/plain", - CurrentFormData = "currentValue", - PreviousFormData = "previousValue", + CurrentFormDataWrapper = FormDataWrapperFactory.Create("currentValue"), + PreviousFormDataWrapper = FormDataWrapperFactory.Create("previousValue"), CurrentBinaryData = null, PreviousBinaryData = null, }, diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs index 7481c2b58f..c81f765fb9 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs @@ -223,7 +223,7 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew { Id = "500000/60226acd-b821-4aae-82cd-97a342071bd3", InstanceOwner = new() { PartyId = "500000" }, - AppId = "ttd/test", + AppId = AppId, Process = new() { CurrentTask = new() { ElementId = "Task_1" } }, Data = new() { @@ -249,8 +249,8 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew object? formData = null ) { - _resources.Setup(r => r.GetLayoutSetForTask("Task_1")).Returns(layoutSet); - var appMetadata = new ApplicationMetadata("ttd/test-app") { DataTypes = dataTypes }; + _resources.Setup(r => r.GetLayoutSetForTask(TaskId)).Returns(layoutSet); + var appMetadata = new ApplicationMetadata(AppId) { DataTypes = dataTypes }; var modelSerializationService = new ModelSerializationService(_appModel.Object); _appMetadata.Setup(m => m.GetApplicationMetadata()).ReturnsAsync(appMetadata).Verifiable(Times.AtLeastOnce); if (formData != null) @@ -275,17 +275,17 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew var frontendSettings = Options.Create(new FrontEndSettings()); var dataAccessor = new InstanceDataUnitOfWork( - instance, - _dataClient.Object, - _instanceClient.Object, - appMetadata, - null!, - modelSerializationService, - null!, - null!, - TaskId, - null, - null + instance: instance, + dataClient: _dataClient.Object, + instanceClient: _instanceClient.Object, + appMetadata: appMetadata, + translationService: null!, + modelSerializationService: modelSerializationService, + appResources: null!, + frontEndSettings: null!, + taskId: TaskId, + language: null, + telemetry: null ); var layoutStateInit = new LayoutEvaluatorStateInitializer( diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index 13fe675ccc..fe462cae8c 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -4001,9 +4001,11 @@ namespace Altinn.App.Core.Models { public FormDataChange() { } public required System.ReadOnlyMemory? CurrentBinaryData { get; init; } - public required object CurrentFormData { get; init; } + public object CurrentFormData { get; } + public required Altinn.App.Core.Features.IFormDataWrapper CurrentFormDataWrapper { get; init; } public required System.ReadOnlyMemory PreviousBinaryData { get; init; } - public required object PreviousFormData { get; init; } + public object PreviousFormData { get; } + public required Altinn.App.Core.Features.IFormDataWrapper PreviousFormDataWrapper { get; init; } } public interface ILanguageCodeStandard { diff --git a/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestFormDataWrapperFactory.cs b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestFormDataWrapperFactory.cs index fbf7c198cc..0f7865e536 100644 --- a/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestFormDataWrapperFactory.cs +++ b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestFormDataWrapperFactory.cs @@ -1,8 +1,11 @@ using System; using Altinn.App.Core.Helpers.DataModel; using Altinn.App.SourceGenerator.Integration.Tests.Models; +using Xunit; using Xunit.Abstractions; +namespace Altinn.App.SourceGenerator.Integration.Tests.UnitTest; + public class TestFormDataWrapperFactory(ITestOutputHelper testOutputHelper) { [Fact]