Repository: Altinn/app-lib-dotnet
Pull request #1310: Make expression engine work with explicit dataType in subform
Author: login: ivarne
Labels: name: bugfix
description: Label Pull requests with bugfix. Used when generation releasenotes
Created: 2025-05-13T19:45:00Z | Merged: 2025-05-14T20:37:05Z

Studio seems to prefer explicit dataType in "dataModelBindings", but backend only supported explicit reference when the target has MaxCount > 1

* Add nullable dataType to DataElementIdentifier (for efficiency)
* Check if defaultDataElement from context is of the correct type before complaining about MaxCount



Diff:
diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs
index c231af7aa..e6d01f8df 100644
--- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs
+++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs
@@ -12,7 +12,7 @@ namespace Altinn.App.Core.Helpers.DataModel;
 public class DataModel
 {
     private readonly IInstanceDataAccessor _dataAccessor;
-    private readonly Dictionary<string, DataElementIdentifier?> _dataIdsByType = [];
+    private readonly Dictionary<string, DataElementIdentifier> _dataIdsByType = [];
 
     /// <summary>
     /// Constructor that wraps a POCO data model, and gives extra tool for working with the data
@@ -20,9 +20,9 @@ public class DataModel
     public DataModel(IInstanceDataAccessor dataAccessor)
     {
         _dataAccessor = dataAccessor;
-        foreach (var (dataType, dataElement) in dataAccessor.GetDataElementsWithFormData())
+        foreach (var (dataType, dataElement) in dataAccessor.GetDataElements())
         {
-            if (dataType is { MaxCount: 1 })
+            if (dataType is { MaxCount: 1, AppLogic.ClassRef: not null })
             {
                 _dataIdsByType.TryAdd(dataElement.DataType, dataElement);
             }
@@ -44,20 +44,30 @@ private async Task<object> ServiceModel(ModelBinding key, DataElementIdentifier
         DataElementIdentifier defaultDataElementIdentifier
     )
     {
-        if (key.DataType == null)
+        if (
+            key.DataType == null
+            || defaultDataElementIdentifier.DataTypeId == key.DataType
+            || _dataAccessor.GetDataType(defaultDataElementIdentifier).Id == key.DataType
+        )
         {
             return (defaultDataElementIdentifier, await _dataAccessor.GetFormData(defaultDataElementIdentifier));
         }
 
         if (_dataIdsByType.TryGetValue(key.DataType, out var dataElementId))
         {
-            if (dataElementId is null)
+            return (dataElementId, await _dataAccessor.GetFormData(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"
                 );
             }
-            return (dataElementId.Value, await _dataAccessor.GetFormData(dataElementId.Value));
+            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(
diff --git a/src/Altinn.App.Core/Models/DataElementIdentifier.cs b/src/Altinn.App.Core/Models/DataElementIdentifier.cs
index 447017b94..35cee3f18 100644
--- a/src/Altinn.App.Core/Models/DataElementIdentifier.cs
+++ b/src/Altinn.App.Core/Models/DataElementIdentifier.cs
@@ -8,7 +8,7 @@ namespace Altinn.App.Core.Models;
 public readonly struct DataElementIdentifier : IEquatable<DataElementIdentifier>
 {
     /// <summary>
-    /// The backing field for the parsed guid that identifies a <see cref="DataElement"/>
+    /// The parsed guid that identifies a <see cref="DataElement"/>
     /// </summary>
     public Guid Guid { get; }
 
@@ -17,6 +17,11 @@ namespace Altinn.App.Core.Models;
     /// </summary>
     public string Id { get; }
 
+    /// <summary>
+    /// The backing field for the data type of the <see cref="DataElement"/>
+    /// </summary>
+    public string? DataTypeId { get; }
+
     /// <summary>
     /// Constructor that takes a string representation of a guid
     /// </summary>
@@ -37,17 +42,27 @@ public DataElementIdentifier(Guid guid)
         Id = guid.ToString();
     }
 
+    /// <summary>
+    /// Constructor that initializes from a <see cref="DataElement"/>
+    /// </summary>
+    public DataElementIdentifier(DataElement dataElement)
+    {
+        Guid = Guid.Parse(dataElement.Id);
+        Id = dataElement.Id;
+        DataTypeId = dataElement.DataType;
+    }
+
     /// <summary>
     /// Implicit conversion to allow DataElements to be used as DataElementIds
     /// </summary>
-    public static implicit operator DataElementIdentifier(DataElement dataElement) => new(dataElement.Id);
+    public static implicit operator DataElementIdentifier(DataElement dataElement) => new(dataElement);
 
     /// <summary>
     /// Implicit conversion to allow DataElements to be used as DataElementIds,
     /// but accept and return null values
     /// </summary>
     public static implicit operator DataElementIdentifier?(DataElement? dataElement) =>
-        dataElement is null ? default : new(dataElement.Id);
+        dataElement is null ? default : new(dataElement);
 
     /// <summary>
     /// Make the ToString method return the ID
diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RemoveHiddenData/RemoveHiddenDataTests.TestRemoveHiddenData.verified.txt b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RemoveHiddenData/RemoveHiddenDataTests.TestRemoveHiddenData.verified.txt
index f63bbab28..e424938c0 100644
--- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RemoveHiddenData/RemoveHiddenDataTests.TestRemoveHiddenData.verified.txt
+++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RemoveHiddenData/RemoveHiddenDataTests.TestRemoveHiddenData.verified.txt
@@ -3,77 +3,88 @@
     Field: root.fornavn,
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   },
   {
     Field: root.etternavn,
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   },
   {
     Field: root.alder,
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   },
   {
     Field: root.kjonn,
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   },
   {
     Field: root.epost,
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   },
   {
     Field: root.telefonnummer,
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   },
   {
     Field: root.bosted,
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   },
   {
     Field: root.arbeidserfaring[2],
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   },
   {
     Field: root.arbeidserfaring[1],
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   },
   {
     Field: root.arbeidserfaring[0].prosjekter[3],
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   },
   {
     Field: root.arbeidserfaring[0].prosjekter[1],
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   }
 ]
\ No newline at end of file
diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=False_hidePage2=False.verified.txt b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=False_hidePage2=False.verified.txt
index e1bda8d0a..94f54ab25 100644
--- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=False_hidePage2=False.verified.txt
+++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=False_hidePage2=False.verified.txt
@@ -3,14 +3,16 @@
     Field: some.data[1].prodMengde,
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   },
   {
     Field: some.data[0],
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   }
 ]
\ No newline at end of file
diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=False_hidePage2=True.verified.txt b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=False_hidePage2=True.verified.txt
index 0003839d8..674514ebb 100644
--- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=False_hidePage2=True.verified.txt
+++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=False_hidePage2=True.verified.txt
@@ -3,7 +3,8 @@
     Field: some.data,
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   }
 ]
\ No newline at end of file
diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=True_hidePage2=False.verified.txt b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=True_hidePage2=False.verified.txt
index e1bda8d0a..94f54ab25 100644
--- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=True_hidePage2=False.verified.txt
+++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=True_hidePage2=False.verified.txt
@@ -3,14 +3,16 @@
     Field: some.data[1].prodMengde,
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   },
   {
     Field: some.data[0],
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   }
 ]
\ No newline at end of file
diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=True_hidePage2=True.verified.txt b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=True_hidePage2=True.verified.txt
index 0003839d8..674514ebb 100644
--- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=True_hidePage2=True.verified.txt
+++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=True_hidePage2=True.verified.txt
@@ -3,7 +3,8 @@
     Field: some.data,
     DataElementIdentifier: {
       Guid: Guid_1,
-      Id: Guid_1
+      Id: Guid_1,
+      DataTypeId: default
     }
   }
 ]
\ No newline at end of file
diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs
index 6d5554a52..6af65f2f4 100644
--- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs
+++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs
@@ -93,7 +93,7 @@ private record SubFormModel(
             {
                 Id = SubformDataType,
                 TaskId = TaskId,
-                AppLogic = new ApplicationLogic() { ClassRef = _classRefSub, AllowInSubform = true },
+                AppLogic = new ApplicationLogic() { ClassRef = _classRefSub },
             },
         ],
     };
@@ -163,7 +163,10 @@ private record SubFormModel(
                       "id": "Name",
                       "type": "Input",
                       "dataModelBindings": {
-                        "simpleBinding": "Name"
+                        "simpleBinding": {
+                          "field": "Name",
+                          "dataType": "subform"
+                        }
                       },
                       "required": true
                     },
diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs
index 2b750244f..59591502c 100644
--- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs
+++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs
@@ -91,10 +91,6 @@ public DataElement GetDataElement(DataElementIdentifier dataElementIdentifier)
             throw new InvalidOperationException("Application metadata not set for InstanceDataAccessorFake");
         }
         var dataType = _applicationMetadata.DataTypes.Find(d => d.Id == dataTypeId);
-        if (dataType is null)
-        {
-            throw new InvalidOperationException($"Data type {dataTypeId} not found in applicationmetadata");
-        }
 
         return dataType;
     }


Reviews:
user: login: martinothamar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T06:35:45Z
user: login: martinothamar
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T06:35:50Z
user: login: ivarne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T06:38:19Z
user: login: martinothamar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T06:41:12Z

Review comments:
diff_hunk: @@ -37,17 +42,27 @@ public DataElementIdentifier(Guid guid)
         Id = guid.ToString();
     }
 
+    /// <summary>
+    /// Constructor that initializes from a <see cref="DataElement"/>
+    /// </summary>
+    public DataElementIdentifier(DataElement dataElement)
+    {
+        Guid = Guid.Parse(dataElement.Id);
+        Id = dataElement.Id;
+        DataTypeId = dataElement.DataType;
path: src/Altinn.App.Core/Models/DataElementIdentifier.cs
user: login: martinothamar
body: Why would we name it `DataTypeId` here as opposed to `DataType`?
created_at: 2025-05-14T06:35:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,17 +42,27 @@ public DataElementIdentifier(Guid guid)
         Id = guid.ToString();
     }
 
+    /// <summary>
+    /// Constructor that initializes from a <see cref="DataElement"/>
+    /// </summary>
+    public DataElementIdentifier(DataElement dataElement)
+    {
+        Guid = Guid.Parse(dataElement.Id);
+        Id = dataElement.Id;
+        DataTypeId = dataElement.DataType;
path: src/Altinn.App.Core/Models/DataElementIdentifier.cs
user: login: ivarne
body: Because I get confused whether something is the dataType object or the ID. I can't change `dataElement.DataType` to `dataElement.DataTypeId`. Do you think I should follow the naming in storage?
created_at: 2025-05-14T06:38:19Z
author_association: MEMBER
diff_hunk: @@ -37,17 +42,27 @@ public DataElementIdentifier(Guid guid)
         Id = guid.ToString();
     }
 
+    /// <summary>
+    /// Constructor that initializes from a <see cref="DataElement"/>
+    /// </summary>
+    public DataElementIdentifier(DataElement dataElement)
+    {
+        Guid = Guid.Parse(dataElement.Id);
+        Id = dataElement.Id;
+        DataTypeId = dataElement.DataType;
path: src/Altinn.App.Core/Models/DataElementIdentifier.cs
user: login: martinothamar
body: I see that we use `DataTypeId` a couple of more places (signing and process stuff), so I'm fine with it
created_at: 2025-05-14T06:41:12Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-14T20:39:39Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Altinn_app-lib-dotnet&pullRequest=1310) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&pullRequest=1310&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Altinn_app-lib-dotnet&pullRequest=1310&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Altinn_app-lib-dotnet&pullRequest=1310&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [80.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=Altinn_app-lib-dotnet&pullRequest=1310&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Altinn_app-lib-dotnet&pullRequest=1310&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Altinn_app-lib-dotnet&pullRequest=1310)


author_association: NONE