diff --git a/.gitignore b/.gitignore index a12dd4bdee..c9f07d7652 100644 --- a/.gitignore +++ b/.gitignore @@ -381,6 +381,7 @@ src/Altinn.Apps/AppTemplates/AspNet/App/Properties/launchSettings.json *.received.txt *.received.json +*.received.cs # Benchmark Results BenchmarkDotNet.Artifacts/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 4be7845793..c2f185ffbd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,6 +26,7 @@ + @@ -57,6 +58,7 @@ + diff --git a/solutions/All.sln b/solutions/All.sln index d1de282a41..c23d7448dd 100644 --- a/solutions/All.sln +++ b/solutions/All.sln @@ -23,6 +23,19 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Altinn.App.Api.Tests", "..\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Altinn.App.Integration.Tests", "..\test\Altinn.App.Integration.Tests\Altinn.App.Integration.Tests.csproj", "{0DE9F775-FE1A-4604-B239-4CFE4E105B1D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Altinn.App.SourceGenerator.Tests", "..\test\Altinn.App.SourceGenerator.Tests\Altinn.App.SourceGenerator.Tests.csproj", "{976E3CF9-26CC-4825-92F7-5EEBD99A8B6A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C5968C89-1529-42F3-B082-DC1184C18395}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{B96EA64C-2F26-42FE-A260-F6A4C6AA48E7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{D8D6ED6A-EF63-48FC-B267-17FB76C5DD07}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Altinn.App.SourceGenerator.Integration.Tests", "..\test\Altinn.App.SourceGenerator.Integration.Tests\Altinn.App.SourceGenerator.Integration.Tests.csproj", "{836CECBF-5518-4276-BE2C-B0F255BF19F2}" + ProjectSection(ProjectDependencies) = postProject + {E8F29FE8-6B62-41F1-A08C-2A318DD08BB4} = {E8F29FE8-6B62-41F1-A08C-2A318DD08BB4} + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -72,5 +85,27 @@ Global {0DE9F775-FE1A-4604-B239-4CFE4E105B1D}.Debug|Any CPU.Build.0 = Debug|Any CPU {0DE9F775-FE1A-4604-B239-4CFE4E105B1D}.Release|Any CPU.ActiveCfg = Release|Any CPU {0DE9F775-FE1A-4604-B239-4CFE4E105B1D}.Release|Any CPU.Build.0 = Release|Any CPU + {976E3CF9-26CC-4825-92F7-5EEBD99A8B6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {976E3CF9-26CC-4825-92F7-5EEBD99A8B6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {976E3CF9-26CC-4825-92F7-5EEBD99A8B6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {976E3CF9-26CC-4825-92F7-5EEBD99A8B6A}.Release|Any CPU.Build.0 = Release|Any CPU + {836CECBF-5518-4276-BE2C-B0F255BF19F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {836CECBF-5518-4276-BE2C-B0F255BF19F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {836CECBF-5518-4276-BE2C-B0F255BF19F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {836CECBF-5518-4276-BE2C-B0F255BF19F2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D6C1901B-BED6-44AC-A061-5981A2DEB937} = {C5968C89-1529-42F3-B082-DC1184C18395} + {E8F29FE8-6B62-41F1-A08C-2A318DD08BB4} = {C5968C89-1529-42F3-B082-DC1184C18395} + {8006DE72-9E93-47A0-AD15-C4B5244B08DA} = {C5968C89-1529-42F3-B082-DC1184C18395} + {3C1E1C25-9072-42C5-8058-162ADF136B3F} = {B96EA64C-2F26-42FE-A260-F6A4C6AA48E7} + {6F762771-DC24-4139-B1B2-DD0F6A4E2B5B} = {B96EA64C-2F26-42FE-A260-F6A4C6AA48E7} + {F5732D5E-FBF2-4905-AE49-9D0171AE0698} = {B96EA64C-2F26-42FE-A260-F6A4C6AA48E7} + {976E3CF9-26CC-4825-92F7-5EEBD99A8B6A} = {B96EA64C-2F26-42FE-A260-F6A4C6AA48E7} + {C69F2010-B4CF-42FE-9DEC-81F3B1C06294} = {B96EA64C-2F26-42FE-A260-F6A4C6AA48E7} + {A1CD82E1-D0B7-4C04-9DF4-8E7277706D3F} = {C5968C89-1529-42F3-B082-DC1184C18395} + {626CC2E1-1D1D-45A4-B07C-A120BB4EE973} = {D8D6ED6A-EF63-48FC-B267-17FB76C5DD07} + {836CECBF-5518-4276-BE2C-B0F255BF19F2} = {B96EA64C-2F26-42FE-A260-F6A4C6AA48E7} + {0DE9F775-FE1A-4604-B239-4CFE4E105B1D} = {B96EA64C-2F26-42FE-A260-F6A4C6AA48E7} EndGlobalSection EndGlobal diff --git a/src/Altinn.App.Analyzers/Altinn.App.Analyzers.csproj b/src/Altinn.App.Analyzers/Altinn.App.Analyzers.csproj index a2af323c0f..92dd53e1aa 100644 --- a/src/Altinn.App.Analyzers/Altinn.App.Analyzers.csproj +++ b/src/Altinn.App.Analyzers/Altinn.App.Analyzers.csproj @@ -5,6 +5,7 @@ enable enable false + 12 false true diff --git a/src/Altinn.App.Analyzers/AnalyzerReleases.Unshipped.md b/src/Altinn.App.Analyzers/AnalyzerReleases.Unshipped.md index edbc9fdbd7..4a8e0101ff 100644 --- a/src/Altinn.App.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Altinn.App.Analyzers/AnalyzerReleases.Unshipped.md @@ -6,5 +6,6 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- ALTINNAPP0001 | General | Warning | Project not found +ALTINNAPP0002 | Metadata | Warning | Error in applicationmetadata.json ALTINNAPP9999 | General | Warning | Unknown error ALTINNAPP0500 | CodeSmells | Warning | CodeSmells diff --git a/src/Altinn.App.Analyzers/Diagnostics.cs b/src/Altinn.App.Analyzers/Diagnostics.cs index 7549205f70..4e2855512c 100644 --- a/src/Altinn.App.Analyzers/Diagnostics.cs +++ b/src/Altinn.App.Analyzers/Diagnostics.cs @@ -26,12 +26,25 @@ public static class CodeSmells ); } + public static class FormDataWrapperGenerator + { + public static readonly DiagnosticDescriptor AppMetadataError = Warning( + "ALTINNAPP0002", + Category.Metadata, + "Application metadata error", + "Error in applicationmetadata.json: {0}" + ); + } + private const string DocsRoot = "https://docs.altinn.studio/nb/altinn-studio/reference/analysis/"; private const string RulesRoot = DocsRoot + "rules/"; private static DiagnosticDescriptor Warning(string id, string category, string title, string messageFormat) => Create(id, title, messageFormat, category, DiagnosticSeverity.Warning); + private static DiagnosticDescriptor Error(string id, string category, string title, string messageFormat) => + Create(id, title, messageFormat, category, DiagnosticSeverity.Error); + private static DiagnosticDescriptor Create( string id, string title, diff --git a/src/Altinn.App.Analyzers/IncrementalGenerator/FormDataWrapperGenerator.cs b/src/Altinn.App.Analyzers/IncrementalGenerator/FormDataWrapperGenerator.cs new file mode 100644 index 0000000000..4fc5b01228 --- /dev/null +++ b/src/Altinn.App.Analyzers/IncrementalGenerator/FormDataWrapperGenerator.cs @@ -0,0 +1,254 @@ +using Altinn.App.Analyzers.SourceTextGenerator; +using Altinn.App.Analyzers.Utils; +using NanoJsonReader; + +namespace Altinn.App.Analyzers.IncrementalGenerator; + +/// +/// Generate IFormDataWrapper implementations for classes in models/*.cs in the app. +/// +[Generator] +public class FormDataWrapperGenerator : IIncrementalGenerator +{ + private sealed record Result(T? Value, EquatableArray Diagnostics) + where T : class + { + public Result(EquatableDiagnostic diagnostics) + : this(null, new EquatableArray([diagnostics])) { } + + public Result(T value) + : this(value, EquatableArray.Empty) { } + }; + + private sealed record ModelClassOrDiagnostic( + string? ClassName, + Location? Location, + EquatableArray Diagnostics + ) + { + public ModelClassOrDiagnostic(EquatableDiagnostic diagnostic) + : this(null, null, new([diagnostic])) { } + + public ModelClassOrDiagnostic(string className, Location? location) + : this(className, location, EquatableArray.Empty) { } + }; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var rootClasses = context + .AdditionalTextsProvider.Where(text => + text.Path.Replace('\\', '/').EndsWith("config/applicationmetadata.json") + ) + .SelectMany(ParseModelClassOrDiagnostic); + + var modelPathNodesProvider = rootClasses.Combine(context.CompilationProvider).Select(CreateNodeTree); + + context.RegisterSourceOutput(modelPathNodesProvider, GenerateFromNode); + } + + private static ImmutableArray ParseModelClassOrDiagnostic( + AdditionalText text, + CancellationToken token + ) + { + try + { + var textContent = text.GetText(token)?.ToString(); + if (textContent is null) + { + return + [ + new( + new EquatableDiagnostic( + Diagnostics.FormDataWrapperGenerator.AppMetadataError, + FileLocationHelper.GetLocation(text, 0, null), + ["Failed to read"] + ) + ), + ]; + } + + var appMetadata = JsonValue.Parse(textContent); + if (appMetadata.Type != JsonType.Object) + { + return + [ + new( + new EquatableDiagnostic( + Diagnostics.FormDataWrapperGenerator.AppMetadataError, + FileLocationHelper.GetLocation(text, appMetadata.Start, appMetadata.End), + ["not a valid JSON object"] + ) + ), + ]; + } + + var dataTypes = appMetadata.GetProperty("dataTypes"); + if (dataTypes?.Type != JsonType.Array) + { + return + [ + new( + new( + Diagnostics.FormDataWrapperGenerator.AppMetadataError, + FileLocationHelper.GetLocation(text, appMetadata.Start, appMetadata.End), + ["the property dataTypes is not a valid JSON array"] + ) + ), + ]; + } + + List rootClasses = []; + foreach (var dataType in dataTypes.GetArrayValues()) + { + if (dataType.Type != JsonType.Object) + { + continue; + } + + var appLogic = dataType.GetProperty("appLogic"); + if (appLogic?.Type != JsonType.Object) + { + continue; + } + + var classRef = appLogic.GetProperty("classRef"); + if (classRef?.Type != JsonType.String) + { + continue; + } + + rootClasses.Add( + new(classRef.GetString(), FileLocationHelper.GetLocation(text, classRef.Start, classRef.End)) + ); + } + + return [.. rootClasses]; + } + catch (NanoJsonException e) + { + return + [ + new( + new EquatableDiagnostic( + Diagnostics.FormDataWrapperGenerator.AppMetadataError, + FileLocationHelper.GetLocation(text, e.StartIndex, e.EndIndex), + [e.Message] + ) + ), + ]; + } + } + + private static Result CreateNodeTree( + (ModelClassOrDiagnostic, Compilation) tuple, + CancellationToken _ + ) + { + var (rootSymbolFullName, compilation) = tuple; + if (rootSymbolFullName.ClassName is null) + { + return new Result(null, rootSymbolFullName.Diagnostics); + } + var rootSymbol = compilation.GetBestTypeByMetadataName(rootSymbolFullName.ClassName); + if (rootSymbol == null) + { + return new Result( + new EquatableDiagnostic( + Diagnostics.FormDataWrapperGenerator.AppMetadataError, + rootSymbolFullName.Location, + [$"Could not find class {rootSymbolFullName.ClassName} in the compilation"] + ) + ); + } + + return new Result( + new ModelPathNode("", "", SourceReaderUtils.TypeSymbolToString(rootSymbol), GetNodeProperties(rootSymbol)) + ); + } + + private static EquatableArray? GetNodeProperties(INamedTypeSymbol namedTypeSymbol) + { + var nodeProperties = new List(); + foreach (var property in namedTypeSymbol.GetMembers().OfType()) + { + if (PropertyShouldBeSkipped(property)) + { + // Skip static, readonly, writeonly, implicitly declared, private and indexer properties + continue; + } + var (propertyTypeSymbol, propertyCollectionTypeSymbol) = SourceReaderUtils.GetTypeFromProperty( + property.Type + ); + + var cSharpName = property.Name; + var jsonName = SourceReaderUtils.GetJsonName(property) ?? cSharpName; + var typeString = SourceReaderUtils.TypeSymbolToString(propertyTypeSymbol); + var collectionTypeString = propertyCollectionTypeSymbol is null + ? null + : SourceReaderUtils.TypeSymbolToString(propertyCollectionTypeSymbol); + + if ( + propertyTypeSymbol is INamedTypeSymbol propertyNamedTypeSymbol + && !propertyNamedTypeSymbol.ContainingNamespace.ToString().StartsWith("System") + ) + { + nodeProperties.Add( + new ModelPathNode( + cSharpName, + jsonName, + typeString, + GetNodeProperties(propertyNamedTypeSymbol), + collectionTypeString + ) + ); + } + else + { + nodeProperties.Add(new ModelPathNode(cSharpName, jsonName, typeString, null, collectionTypeString)); + } + } + return nodeProperties; + } + + private static bool PropertyShouldBeSkipped(IPropertySymbol property) + { + // Skip static, readonly, writeonly, implicitly declared, private and indexer properties + if ( + property.IsStatic + || property.IsReadOnly + || property.IsWriteOnly + || property.IsImplicitlyDeclared + || property.IsIndexer + || ( + property.DeclaredAccessibility is not Accessibility.Public + && property.DeclaredAccessibility is not Accessibility.Internal + ) + ) + { + return true; + } + + // Skip properties of abstract types (cannot be instantiated) + if (SourceReaderUtils.UnwrapNullable(property.Type) is INamedTypeSymbol { IsAbstract: true }) + { + return true; + } + + return false; + } + + private static void GenerateFromNode(SourceProductionContext context, Result result) + { + foreach (var diagnostic in result.Diagnostics) + { + context.ReportDiagnostic(diagnostic.CreateDiagnostic()); + } + if (result is not { Value: { } node }) + return; + + var sourceText = SourceTextGenerator.SourceTextGenerator.GenerateSourceText(node); + context.AddSource(node.Name + "FormDataWrapper.g.cs", sourceText); + } +} diff --git a/src/Altinn.App.Analyzers/ModelPathNode.cs b/src/Altinn.App.Analyzers/ModelPathNode.cs new file mode 100644 index 0000000000..9f770eaea4 --- /dev/null +++ b/src/Altinn.App.Analyzers/ModelPathNode.cs @@ -0,0 +1,87 @@ +using System.Diagnostics; +using Altinn.App.Analyzers.Utils; + +namespace Altinn.App.Analyzers; + +/// +/// Node used to represent the shape of a model for the purpose of generating code. +/// Somewhat similar to a JSON schema. +/// +[DebuggerDisplay("{_debugDisplayString}")] +public record ModelPathNode +{ + /// + /// Initializes a new instance of the class. + /// + public ModelPathNode( + string cSharpName, + string jsonName, + string typeName, + EquatableArray? properties = null, + string? listType = null + ) + { + CSharpName = cSharpName; + JsonName = jsonName; + ListType = listType; + TypeName = typeName; + Properties = properties ?? EquatableArray.Empty; + } + + /// + /// The full type name with safe characters for C# identifier name + /// + public string Name => + TypeName + .Replace('+', '_') + .Replace('.', '_') + .Replace('<', '_') + .Replace('>', '_') + .Replace("?", "") + .Replace("global::", ""); + + /// + /// The fully qualified type name, excluding nullable annotations and the "global::" prefix. + /// + public string FullName => TypeName.Replace("?", "").Replace("global::", ""); + + /// + /// The name used in json to access this property. The [JsonPropertyName("")] value. + /// + public string JsonName { get; init; } + + /// + /// The name used in C# to access this property. Used for direct access in source generated code. + /// + public string CSharpName { get; init; } + + /// + /// The FullName for the type of the property or element of list including global::. + /// + public string TypeName { get; init; } + + /// + /// If this is a list property, this is the type of the list. (eg System.Collections.Generic.List) + /// + /// + /// We assume this is a subtype of + /// + public string? ListType { get; init; } + + /// + /// The sub properties of this node. + /// + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public EquatableArray Properties { get; init; } + + private string _debugDisplayString => + $"{JsonName}{(ListType is null ? "" : "[]")} with {Properties.Count} children"; + + /// + /// Determines whether the current node represents an AltinnRowId. + /// + public bool IsAltinnRowId() + { + return this is { JsonName: "altinnRowId", CSharpName: "AltinnRowId", TypeName: "global::System.Guid" }; + } +}; diff --git a/src/Altinn.App.Analyzers/PolyFills.cs b/src/Altinn.App.Analyzers/PolyFills.cs new file mode 100644 index 0000000000..6985e11c67 --- /dev/null +++ b/src/Altinn.App.Analyzers/PolyFills.cs @@ -0,0 +1,11 @@ +#pragma warning disable IDE0161 +// ReSharper disable file ArrangeNamespaceBody +// ReSharper disable file CheckNamespace +#if NETSTANDARD +namespace System.Runtime.CompilerServices +{ + // Allow the use of init-only properties when targeting dotnet stadard. + internal static class IsExternalInit { } +} +#endif +#pragma warning restore IDE0161 diff --git a/src/Altinn.App.Analyzers/SourceTextGenerator/AddIndexToPathGenerator.cs b/src/Altinn.App.Analyzers/SourceTextGenerator/AddIndexToPathGenerator.cs new file mode 100644 index 0000000000..1ce204d491 --- /dev/null +++ b/src/Altinn.App.Analyzers/SourceTextGenerator/AddIndexToPathGenerator.cs @@ -0,0 +1,177 @@ +using System.Text; + +namespace Altinn.App.Analyzers.SourceTextGenerator; + +internal static class AddIndexToPathGenerator +{ + public static void Generate(StringBuilder builder, ModelPathNode rootNode) + { + builder.Append( + $$""" + + /// + public global::System.ReadOnlySpan AddIndexToPath(global::System.ReadOnlySpan path, global::System.ReadOnlySpan rowIndexes, global::System.Span buffer) + { + if(path.IsEmpty) + { + return global::System.ReadOnlySpan.Empty; + } + + var bufferOffset = 0; + var pathOffset = 0; + + AddIndexToPathRecursive_{{rootNode.Name}}( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + + return buffer[..bufferOffset]; + } + + """ + ); + GenerateRecursiveMethod(builder, rootNode, new HashSet(StringComparer.Ordinal)); + } + + private static void GenerateRecursiveMethod( + StringBuilder builder, + ModelPathNode node, + HashSet generatedTypeNames + ) + { + if (!generatedTypeNames.Add(node.TypeName)) + { + // Already generated this method + return; + } + builder.Append( + $$""" + + private void AddIndexToPathRecursive_{{node.Name}}( + global::System.ReadOnlySpan path, + int pathOffset, + global::System.ReadOnlySpan rowIndexes, + global::System.Span buffer, + ref int bufferOffset + ) + { + if (bufferOffset > 0) + { + buffer[bufferOffset++] = '.'; + } + var segment = ParseSegment(path, pathOffset, out pathOffset, out int literalIndex); + switch (segment) + { + + """ + ); + + foreach (var child in node.Properties.Where(IsRelevantForRecursion)) + { + builder.Append( + $$""" + case "{{child.JsonName}}": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += {{child.JsonName.Length}}; + + """ + ); + if (child.ListType is not null) + { + builder.Append( + """ + + if (literalIndex != -1) + { + // Copy index from path to buffer + buffer[bufferOffset++] = '['; + if (!literalIndex.TryFormat(buffer[bufferOffset..], out int charsWritten)) + { + throw new global::System.FormatException($"Invalid index in {path}."); + } + + bufferOffset += charsWritten; + buffer[bufferOffset++] = ']'; + rowIndexes = default; + } + else if (rowIndexes.Length >= 1) + { + // Write index from rowIndexes to buffer + buffer[bufferOffset++] = '['; + rowIndexes[0].TryFormat(buffer.Slice(bufferOffset), out int charsWritten); + bufferOffset += charsWritten; + buffer[bufferOffset++] = ']'; + rowIndexes = rowIndexes.Slice(1); + } + else if (pathOffset == -1) + { + // No more segments in the path, and the last part is valid in a list + // without index (e.g. "model.listProperty" is valid, but "model.listProperty.val" needs an index) + return; + } + else + { + // No index to write, but there are more segments in the path + // thus the path is not valid + bufferOffset = 0; + return; + } + + """ + ); + } + builder.Append( + $$""" + if (pathOffset != -1) + { + AddIndexToPathRecursive_{{child.Name}}( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + } + return; + + """ + ); + } + + foreach (var child in node.Properties.Where(c => !IsRelevantForRecursion(c))) + { + builder.Append( + $$""" + case "{{child.JsonName}}": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += {{child.JsonName.Length}}; + return; + + """ + ); + } + + builder.Append( + """ + default: + bufferOffset = 0; + return; + } + } + + """ + ); + foreach (var child in node.Properties.Where(c => IsRelevantForRecursion(c))) + { + GenerateRecursiveMethod(builder, child, generatedTypeNames); + } + } + + private static bool IsRelevantForRecursion(ModelPathNode node) + { + return node.Properties.Count != 0; + } +} diff --git a/src/Altinn.App.Analyzers/SourceTextGenerator/AltinnRowIdsGenerator.cs b/src/Altinn.App.Analyzers/SourceTextGenerator/AltinnRowIdsGenerator.cs new file mode 100644 index 0000000000..44663de585 --- /dev/null +++ b/src/Altinn.App.Analyzers/SourceTextGenerator/AltinnRowIdsGenerator.cs @@ -0,0 +1,146 @@ +using System.Text; + +namespace Altinn.App.Analyzers.SourceTextGenerator; + +internal static class AltinnRowIdsGenerator +{ + public static void Generate(StringBuilder builder, ModelPathNode rootNode) + { + var property = GetListProperties(rootNode); + if (property?.Children is not { Count: > 0 }) + { + builder.Append( + """ + + /// + public void RemoveAltinnRowIds() { } + + /// + public void InitializeAltinnRowIds() { } + + """ + ); + + return; + } + builder.Append( + """ + + /// + public void RemoveAltinnRowIds() + { + SetAltinnRowIds(_dataModel, initialize: false); + } + + /// + public void InitializeAltinnRowIds() + { + SetAltinnRowIds(_dataModel, initialize: true); + } + + """ + ); + + GenerateSetAltinnRowIds(builder, property, []); + } + + private static void GenerateSetAltinnRowIds( + StringBuilder builder, + PropertyWithListChildren property, + HashSet classes + ) + { + if (!classes.Add(property.Node.TypeName)) + { + return; + } + builder.Append( + $$""" + + private static void SetAltinnRowIds({{property.Node.TypeName}} dataModel, bool initialize) + { + + """ + ); + + foreach (var child in property.Children) + { + if (child.IsRowId) + { + builder.Append( + $$""" + if (!initialize) + { + dataModel.{{child.Node.CSharpName}} = global::System.Guid.Empty; + } + else if (dataModel.{{child.Node.CSharpName}} == global::System.Guid.Empty) + { + dataModel.{{child.Node.CSharpName}} = global::System.Guid.NewGuid(); + } + + """ + ); + } + else if (child.Node.ListType is null) + { + builder.Append( + $$""" + if(dataModel.{{child.Node.CSharpName}} is not null) + { + SetAltinnRowIds(dataModel.{{child.Node.CSharpName}}, initialize); + } + + """ + ); + } + else + { + builder.Append( + $$""" + if(dataModel.{{child.Node.CSharpName}} is not null) + { + foreach (var item in dataModel.{{child.Node.CSharpName}}) + { + if (item is not null) + { + SetAltinnRowIds(item, initialize); + } + } + } + + """ + ); + } + } + builder.Append( + """ + } + + """ + ); + + foreach (var child in property.Children) + { + if (!child.IsRowId) + GenerateSetAltinnRowIds(builder, child, classes); + } + } + + private sealed record PropertyWithListChildren( + ModelPathNode Node, + List Children, + bool IsRowId + ); + + private static PropertyWithListChildren? GetListProperties(ModelPathNode node) + { + var children = node.Properties.Select(GetListProperties).OfType().ToList(); + var isAltinnRowId = node.IsAltinnRowId(); + if (children.Count == 0 && !isAltinnRowId) + { + return null; + } + + return new PropertyWithListChildren(node, children, isAltinnRowId); + } +} diff --git a/src/Altinn.App.Analyzers/SourceTextGenerator/CopyGenerator.cs b/src/Altinn.App.Analyzers/SourceTextGenerator/CopyGenerator.cs new file mode 100644 index 0000000000..0ef531926c --- /dev/null +++ b/src/Altinn.App.Analyzers/SourceTextGenerator/CopyGenerator.cs @@ -0,0 +1,103 @@ +using System.Text; + +namespace Altinn.App.Analyzers.SourceTextGenerator; + +internal static class CopyGenerator +{ + public static void Generate(StringBuilder builder, ModelPathNode rootNode, string generatedWrapperClassName) + { + builder.Append( + $$""" + + /// + public global::Altinn.App.Core.Features.IFormDataWrapper Copy() + { + return new {{generatedWrapperClassName}}(CopyRecursive(_dataModel)); + } + + """ + ); + + GenerateCopyRecursive(builder, rootNode, []); + } + + private static void GenerateCopyRecursive(StringBuilder builder, ModelPathNode node, HashSet classNames) + { + if (node.ListType is not null && classNames.Add(node.ListType)) + { + GenerateCopyList(builder, node); + } + if (!classNames.Add(node.TypeName)) + { + // Ignore repeated types + return; + } + builder.Append( + $$""" + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("data")] + private static {{node.TypeName}}? CopyRecursive( + {{node.TypeName}}? data + ) + { + if (data is null) + { + return null; + } + + + """ + ); + if (node.Properties.Count == 0) + { + builder.Append(" return new();\r\n }\r\n"); + } + else + { + builder.Append(" return new()\r\n {\r\n"); + foreach (var property in node.Properties) + { + builder.Append( + property.Properties.Count == 0 + ? $" {property.CSharpName} = data.{property.CSharpName},\r\n" + : $" {property.CSharpName} = CopyRecursive(data.{property.CSharpName}),\r\n" + ); + } + + builder.Append(" };\r\n }\r\n"); + } + + foreach (var recursiveChild in node.Properties.Where(c => c.Properties.Count != 0)) + { + GenerateCopyRecursive(builder, recursiveChild, classNames); + } + } + + private static void GenerateCopyList(StringBuilder builder, ModelPathNode node) + { + builder.Append( + $$""" + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("list")] + private static {{node.ListType}}? CopyRecursive( + {{node.ListType}}? list + ) + { + if (list is null) + { + return null; + } + + {{node.ListType}} result = new(list.Count); + foreach (var item in list) + { + result.Add(CopyRecursive(item)); + } + + return result; + } + + """ + ); + } +} diff --git a/src/Altinn.App.Analyzers/SourceTextGenerator/GenerateJsonComment.cs b/src/Altinn.App.Analyzers/SourceTextGenerator/GenerateJsonComment.cs new file mode 100644 index 0000000000..0ad572b314 --- /dev/null +++ b/src/Altinn.App.Analyzers/SourceTextGenerator/GenerateJsonComment.cs @@ -0,0 +1,69 @@ +using System.Text; + +namespace Altinn.App.Analyzers.SourceTextGenerator; + +public static class GenerateJsonComment +{ + public static void Generate(StringBuilder sb, ModelPathNode rootNode) + { + sb.Append("\r\n"); + sb.Append("// --------------------------------------------------\r\n"); + sb.Append("// ModelPathNode as json (for debugging)\r\n"); + sb.Append("// --------------------------------------------------\r\n"); + sb.Append("//"); + var indent = "\r\n// "; + sb.Append(indent); + sb.Append('{'); + WriteAsJson(sb, rootNode, indent + " "); + sb.Append(indent); + sb.Append("}\r\n"); + } + + private static void WriteAsJson(StringBuilder sb, ModelPathNode node, string linePrefix) + { + // Custom json serializer because it is problematic to use System.Text.Json in a source generator + sb.Append(linePrefix); + sb.Append("\"JsonName\": \""); + sb.Append(node.JsonName); + sb.Append("\","); + sb.Append(linePrefix); + sb.Append("\"CSharpName\": \""); + sb.Append(node.CSharpName); + sb.Append("\","); + sb.Append(linePrefix); + sb.Append("\"TypeName\": \""); + sb.Append(node.TypeName); + sb.Append("\","); + if (node.ListType != null) + { + sb.Append(linePrefix); + sb.Append("\"ListType\": \""); + sb.Append(node.ListType); + sb.Append("\","); + } + + if (node.Properties.Count != 0) + { + sb.Append(linePrefix); + var subIndent = linePrefix + " "; + var childIndent = subIndent + " "; + sb.Append("\"Properties\": ["); + bool first = true; + foreach (var property in node.Properties) + { + if (!first) + { + sb.Append(','); + } + first = false; + sb.Append(subIndent); + sb.Append('{'); + WriteAsJson(sb, property, childIndent); + sb.Append(subIndent); + sb.Append('}'); + } + sb.Append(linePrefix); + sb.Append(']'); + } + } +} diff --git a/src/Altinn.App.Analyzers/SourceTextGenerator/GetterGenerator.cs b/src/Altinn.App.Analyzers/SourceTextGenerator/GetterGenerator.cs new file mode 100644 index 0000000000..c9603c7b37 --- /dev/null +++ b/src/Altinn.App.Analyzers/SourceTextGenerator/GetterGenerator.cs @@ -0,0 +1,133 @@ +using System.Text; + +namespace Altinn.App.Analyzers.SourceTextGenerator; + +internal static class GetterGenerator +{ + public static void Generate(StringBuilder builder, ModelPathNode rootNode) + { + if (rootNode.Properties.Count == 0) + { + builder.Append( + """ + + /// + public object? Get(global::System.ReadOnlySpan path) => null; + + """ + ); + return; + } + builder.Append( + """ + + /// + public object? Get(global::System.ReadOnlySpan path) + { + if (path.IsEmpty) + { + return null; + } + + return GetRecursive(_dataModel, path, 0); + } + + """ + ); + + GenerateRecursive(builder, rootNode, new HashSet(StringComparer.Ordinal)); + } + + private static void GenerateRecursive( + StringBuilder builder, + ModelPathNode modelPathNode, + HashSet generatedTypes + ) + { + if (modelPathNode.Properties.Count == 0) + { + // Do not generate for primitive types + return; + } + + if (modelPathNode.ListType != null && generatedTypes.Add(modelPathNode.ListType)) + { + builder.Append( + $$""" + + private static object? GetRecursive( + {{modelPathNode.ListType}}? model, + global::System.ReadOnlySpan path, + int literalIndex, + int offset + ) + { + if (model is null || literalIndex < 0 || literalIndex >= model.Count) + { + return null; + } + + return GetRecursive(model[literalIndex], path, offset); + } + + """ + ); + } + + if (!generatedTypes.Add(modelPathNode.TypeName)) + { + // Do not generate the same type twice + return; + } + + builder.Append( + $$""" + + private static object? GetRecursive( + {{modelPathNode.TypeName}}? model, + global::System.ReadOnlySpan path, + int offset + ) + { + if(model is null) + { + return null; + } + + return ParseSegment(path, offset, out int nextOffset, out int literalIndex) switch + { + + """ + ); + foreach (var child in modelPathNode.Properties) + { + builder.Append( + child switch + { + { Properties.Count: 0 } => + $" \"{child.JsonName}\" when nextOffset is -1 && literalIndex is -1 => model.{child.CSharpName},\r\n", + { ListType: not null } => + $" \"{child.JsonName}\" when nextOffset > -1 && literalIndex > -1 => GetRecursive(model.{child.CSharpName}, path, literalIndex, nextOffset),\r\n", + _ => + $" \"{child.JsonName}\" when nextOffset > -1 && literalIndex is -1 => GetRecursive(model.{child.CSharpName}, path, nextOffset),\r\n", + } + ); + } + + builder.Append( + """ + "" => model, + // _ => throw new global::System.ArgumentException("{path} is not a valid path."), + _ => null, + }; + } + + """ + ); + + foreach (var child in modelPathNode.Properties) + { + GenerateRecursive(builder, child, generatedTypes); + } + } +} diff --git a/src/Altinn.App.Analyzers/SourceTextGenerator/PrepareModelForXmlStorageGenerator.cs b/src/Altinn.App.Analyzers/SourceTextGenerator/PrepareModelForXmlStorageGenerator.cs new file mode 100644 index 0000000000..4e5d8080d6 --- /dev/null +++ b/src/Altinn.App.Analyzers/SourceTextGenerator/PrepareModelForXmlStorageGenerator.cs @@ -0,0 +1,21 @@ +using System.Text; + +namespace Altinn.App.Analyzers.SourceTextGenerator; + +internal static class PrepareModelForXmlStorageGenerator +{ + public static void Generate(StringBuilder builder, ModelPathNode _) + { + builder.Append( + """ + + /// + public void PrepareModelForXmlStorage() + { + global::Altinn.App.Core.Helpers.ObjectUtils.PrepareModelForXmlStorage(_dataModel); + } + + """ + ); + } +} diff --git a/src/Altinn.App.Analyzers/SourceTextGenerator/RemoveGenerator.cs b/src/Altinn.App.Analyzers/SourceTextGenerator/RemoveGenerator.cs new file mode 100644 index 0000000000..2079604028 --- /dev/null +++ b/src/Altinn.App.Analyzers/SourceTextGenerator/RemoveGenerator.cs @@ -0,0 +1,176 @@ +using System.Text; + +namespace Altinn.App.Analyzers.SourceTextGenerator; + +internal static class RemoveGenerator +{ + public static void Generate(StringBuilder builder, ModelPathNode rootNode) + { + if (rootNode.Properties.Count == 0) + { + builder.Append( + """ + + /// + public void RemoveField(global::System.ReadOnlySpan path, global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption) { } + + """ + ); + return; + } + builder.Append( + """ + + /// + public void RemoveField(global::System.ReadOnlySpan path, global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption) + { + if (path.IsEmpty) + { + return; + } + + RemoveRecursive(_dataModel, path, 0, rowRemovalOption); + } + + """ + ); + + GenerateRecursive(builder, rootNode, new HashSet(StringComparer.Ordinal)); + } + + private static void GenerateRecursive( + StringBuilder builder, + ModelPathNode modelPathNode, + HashSet generatedTypes + ) + { + if (modelPathNode.Properties.Count == 0) + { + // Do not generate for primitive types + return; + } + + if (modelPathNode.ListType != null && generatedTypes.Add($"{modelPathNode.ListType}<{modelPathNode.TypeName}>")) + { + builder.Append( + $$""" + + private static void RemoveRecursive( + {{modelPathNode.ListType}}? model, + global::System.ReadOnlySpan path, + int offset, + int index, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + if (index < 0 || index >= model.Count) + { + return; + } + if (offset == -1) + { + switch (rowRemovalOption) + { + case global::Altinn.App.Core.Helpers.RowRemovalOption.DeleteRow: + model.RemoveAt(index); + break; + case global::Altinn.App.Core.Helpers.RowRemovalOption.SetToNull: + model[index] = default!; + break; + } + } + else + { + RemoveRecursive(model?[index], path, offset, rowRemovalOption); + } + } + + """ + ); + } + + if (!generatedTypes.Add(modelPathNode.TypeName)) + { + // Do not generate the same type twice + return; + } + builder.Append( + $$""" + + private static void RemoveRecursive( + {{modelPathNode.TypeName}}? model, + global::System.ReadOnlySpan path, + int offset, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + switch (ParseSegment(path, offset, out int nextOffset, out int literalIndex)) + { + + """ + ); + foreach (var child in modelPathNode.Properties) + { + if (child.IsAltinnRowId()) + { + // altinnRowId isn't nullable, and it is set on its own schedule. + continue; + } + + builder.Append( + $""" + case "{child.JsonName}" when (nextOffset is -1) && (literalIndex is -1): + model.{child.CSharpName} = default; + break; + + """ + ); + if (child.ListType is not null) + { + builder.Append( + $""" + case "{child.JsonName}": + RemoveRecursive(model.{child.CSharpName}, path, nextOffset, literalIndex, rowRemovalOption); + break; + + """ + ); + } + else if (child.Properties.Count != 0) + { + builder.Append( + $""" + case "{child.JsonName}": + RemoveRecursive(model.{child.CSharpName}, path, nextOffset, rowRemovalOption); + break; + + """ + ); + } + } + + builder.Append( + """ + default: + // throw new ArgumentException("{path} is not a valid path."); + return; + } + } + + """ + ); + + foreach (var child in modelPathNode.Properties) + { + GenerateRecursive(builder, child, generatedTypes); + } + } +} diff --git a/src/Altinn.App.Analyzers/SourceTextGenerator/SourceReaderUtils.cs b/src/Altinn.App.Analyzers/SourceTextGenerator/SourceReaderUtils.cs new file mode 100644 index 0000000000..48e43b4cba --- /dev/null +++ b/src/Altinn.App.Analyzers/SourceTextGenerator/SourceReaderUtils.cs @@ -0,0 +1,91 @@ +namespace Altinn.App.Analyzers.SourceTextGenerator; + +internal static class SourceReaderUtils +{ + public static ITypeSymbol UnwrapNullable(ITypeSymbol symbol) + { + if ( + symbol is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } namedTypeSymbol + ) + { + return namedTypeSymbol.TypeArguments[0]; + } + if (symbol.NullableAnnotation == NullableAnnotation.Annotated) + { + return symbol.WithNullableAnnotation(NullableAnnotation.NotAnnotated); + } + + return symbol; + } + + public static string? GetJsonName(IPropertySymbol symbol) + { + foreach (var attributeData in symbol.GetAttributes()) + { + if ( + attributeData.AttributeClass is { Name: "JsonPropertyNameAttribute" } attr + && attr.ContainingNamespace?.ToDisplayString() == "System.Text.Json.Serialization" + ) + { + var args = attributeData.ConstructorArguments; + if (args.Length == 1 && args[0].Value is string jsonName) + { + return jsonName; + } + } + } + + return null; + } + + private static readonly SymbolDisplayFormat _format = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes + | SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers + | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier + ); + + public static string TypeSymbolToString(ITypeSymbol typeSymbol) + { + return typeSymbol.ToDisplayString(_format); + } + + public static (ITypeSymbol, ITypeSymbol?) GetTypeFromProperty(ITypeSymbol propertyTypeSymbol) + { + var unwrappedTypeSymbol = UnwrapNullable(propertyTypeSymbol); + + switch (unwrappedTypeSymbol) + { + case INamedTypeSymbol { Arity: 1 } namedTypeSymbol: + var collectionTypeSymbol = namedTypeSymbol.AllInterfaces.FirstOrDefault(i => + i.MetadataName == "ICollection`1" + && i.ContainingNamespace.Name == "Generic" + && i.ContainingNamespace.ContainingNamespace.Name == "Collections" + && i.ContainingNamespace.ContainingNamespace.ContainingNamespace.Name == "System" + && i.ContainingNamespace + .ContainingNamespace + .ContainingNamespace + .ContainingNamespace + .IsGlobalNamespace + ); + + if (collectionTypeSymbol is not null) + { + return (UnwrapNullable(collectionTypeSymbol.TypeArguments[0]), namedTypeSymbol); + } + + break; + case IArrayTypeSymbol arrayTypeSymbol: + // Arrays have never been supported in data models. + // Just ignore them for now. + // return (UnwrapNullable(arrayTypeSymbol.ElementType), arrayTypeSymbol); + throw new NotSupportedException( + $"Array types are not supported. Type: {arrayTypeSymbol.ToDisplayString(_format)}" + ); + } + + return (unwrappedTypeSymbol, null); + } +} diff --git a/src/Altinn.App.Analyzers/SourceTextGenerator/SourceTextGenerator.cs b/src/Altinn.App.Analyzers/SourceTextGenerator/SourceTextGenerator.cs new file mode 100644 index 0000000000..311877f3a7 --- /dev/null +++ b/src/Altinn.App.Analyzers/SourceTextGenerator/SourceTextGenerator.cs @@ -0,0 +1,137 @@ +using System.Text; + +namespace Altinn.App.Analyzers.SourceTextGenerator; + +/// +/// Generates source text for the IFormDataWrapper implementations. +/// +public static class SourceTextGenerator +{ + private static readonly string _sourceGeneratorVersion = "1.0.0.0"; + + /// + /// Generates the source text for the IFormDataWrapper implementation for the given root node. + /// + public static string GenerateSourceText(ModelPathNode rootNode) + { + var builder = new StringBuilder(); + builder.Append("// \r\n"); + var className = $"{rootNode.Name}FormDataWrapper"; + builder.Append( + $$""" + #nullable enable + + [global::System.CodeDom.Compiler.GeneratedCode("Altinn.App.Analyzers", "{{_sourceGeneratorVersion}}")] + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public sealed class {{className}} + : global::Altinn.App.Core.Features.IFormDataWrapper<{{rootNode.TypeName}}> + { + private readonly {{rootNode.TypeName}} _dataModel; + + public global::System.Type BackingDataType => typeof({{rootNode.TypeName}}); + + public T BackingData() + where T : class + { + return _dataModel as T + ?? throw new global::System.InvalidCastException( + $"Attempted to cast data model of type {{rootNode.FullName}} to {typeof(T).FullName}" + ); + } + + public {{className}}(object dataModel) + { + _dataModel = + dataModel as {{rootNode.TypeName}} + ?? throw new global::System.ArgumentException( + $"Data model must be of type {{rootNode.FullName}}, (was {dataModel?.GetType().FullName ?? "null"})" + ); + } + + """ + ); + builder.Append("\r\n #region Getters\r\n"); + GetterGenerator.Generate(builder, rootNode); + builder.Append("\r\n #endregion Getters\r\n"); + builder.Append(" #region AddIndexToPath\r\n"); + AddIndexToPathGenerator.Generate(builder, rootNode); + builder.Append("\r\n #endregion AddIndexToPath\r\n"); + builder.Append(" #region Copy\r\n"); + CopyGenerator.Generate(builder, rootNode, className); + builder.Append("\r\n #endregion Copy\r\n"); + builder.Append(" #region Remove\r\n"); + RemoveGenerator.Generate(builder, rootNode); + builder.Append("\r\n #endregion Remove\r\n"); + builder.Append(" #region AltinnRowIds\r\n"); + AltinnRowIdsGenerator.Generate(builder, rootNode); + builder.Append("\r\n #endregion AltinnRowIds\r\n"); + builder.Append(" #region XmlStorage\r\n"); + PrepareModelForXmlStorageGenerator.Generate(builder, rootNode); + builder.Append("\r\n #endregion XmlStorage\r\n"); + + builder.Append( + $$""" + public static global::System.ReadOnlySpan ParseSegment(global::System.ReadOnlySpan path, int offset, out int nextOffset, out int literalIndex) + { + if (offset < 0 || offset >= path.Length) + { + throw new global::System.ArgumentOutOfRangeException(nameof(offset)); + } + + for (int i = offset; i < path.Length; i++) + { + switch (path[i]) + { + case '.': + nextOffset = i + 1; + literalIndex = -1; + return path[offset..i]; + case '[': + literalIndex = GetIndex(path, i + 1, out nextOffset); + return path[offset..i]; + } + } + + // No markers found. Just return the rest of the path. + nextOffset = -1; + literalIndex = -1; + return path[offset..]; + } + + private static int GetIndex(global::System.ReadOnlySpan path, int offset, out int nextOffset) + { + var segment = path[offset..]; + var bracketOffset = global::System.MemoryExtensions.IndexOf(segment, ']'); + if (bracketOffset < 0) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Missing closing bracket ']' in {path}."); + } + + if (!int.TryParse(segment[..bracketOffset], out var index)) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Invalid index in {path}."); + } + + if (index < 0) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Invalid negative index in {path}."); + } + + nextOffset = offset + bracketOffset + 2; + if (nextOffset >= path.Length) + { + nextOffset = -1; + } + + return index; + } + + """ + ); + builder.Append("}\r\n"); + + GenerateJsonComment.Generate(builder, rootNode); + + return builder.ToString(); + } +} diff --git a/src/Altinn.App.Analyzers/Utils/EquatableArray.cs b/src/Altinn.App.Analyzers/Utils/EquatableArray.cs new file mode 100644 index 0000000000..7c8cef0ecd --- /dev/null +++ b/src/Altinn.App.Analyzers/Utils/EquatableArray.cs @@ -0,0 +1,124 @@ +using System.Collections; + +namespace Altinn.App.Analyzers.Utils; + +/// +/// An immutable, equatable array. This is equivalent to Array{T} but with value equality support. +/// Copied from https://github.com/andrewlock/StronglyTypedId/blob/master/src/StronglyTypedIds/EquatableArray.cs +/// +/// The type of values in the array. +public readonly struct EquatableArray : IEquatable>, IEnumerable + where T : IEquatable +{ + /// + /// Get an empty . + /// + public static readonly EquatableArray Empty = new(null); + + /// + /// The underlying array. + /// + private readonly T[]? _array; + + /// + /// Creates a new instance. + /// + /// The input to wrap. + public EquatableArray(T[]? array) + { + _array = array; + } + + /// + public bool Equals(EquatableArray array) + { + return AsSpan().SequenceEqual(array.AsSpan()); + } + + /// + public override bool Equals(object? obj) + { + return obj is EquatableArray array && this.Equals(array); + } + + /// + public override int GetHashCode() + { + if (_array is null) + { + return 0; + } + + HashCode hashCode = default; + + foreach (T item in _array) + { + hashCode.Add(item); + } + + return hashCode.ToHashCode(); + } + + /// + /// Returns a wrapping the current items. + /// + /// A wrapping the current items. + public ReadOnlySpan AsSpan() + { + return _array.AsSpan(); + } + + /// + /// Gets the underlying array if there is one + /// + public T[]? GetArray() => _array; + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)(_array ?? [])).GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)(_array ?? [])).GetEnumerator(); + } + + /// + /// Get the length of the array. + /// + public int Count => _array?.Length ?? 0; + + /// + /// Checks whether two values are the same. + /// + /// The first value. + /// The second value. + /// Whether and are equal. + public static bool operator ==(EquatableArray left, EquatableArray right) + { + return left.Equals(right); + } + + /// + /// Checks whether two values are not the same. + /// + /// The first value. + /// The second value. + /// Whether and are not equal. + public static bool operator !=(EquatableArray left, EquatableArray right) + { + return !left.Equals(right); + } + + /// + /// Implicit operator from list for ease of use. + /// + public static implicit operator EquatableArray(List list) => new([.. list]); + + /// + /// Implicit operator from array for ease of use. + /// + public static implicit operator EquatableArray(T[] array) => new(array); +} diff --git a/src/Altinn.App.Analyzers/Utils/EquatableDiagnostic.cs b/src/Altinn.App.Analyzers/Utils/EquatableDiagnostic.cs new file mode 100644 index 0000000000..8807d00dd7 --- /dev/null +++ b/src/Altinn.App.Analyzers/Utils/EquatableDiagnostic.cs @@ -0,0 +1,110 @@ +// Copied from PolyType with some inlined helper methods + +// Original license +// MIT License +// +// Copyright (c) 2023 Eirik Tsarpalis +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +namespace Altinn.App.Analyzers.Utils; + +/// +/// Descriptor for diagnostic instances using structural equality comparison. +/// Provides a work-around for https://github.com/dotnet/roslyn/issues/68291. +/// +public readonly struct EquatableDiagnostic(DiagnosticDescriptor descriptor, Location? location, object?[] messageArgs) + : IEquatable +{ + /// + /// The for the diagnostic. + /// + public DiagnosticDescriptor Descriptor { get; } = descriptor; + + /// + /// The message arguments for the diagnostic. + /// + public object?[] MessageArgs { get; } = messageArgs; + + /// + /// The location of the diagnostic. + /// + public Location? Location { get; } = GetLocationTrimmed(location); + + // Inline utility method from PolyType Helpers.RoslynHelpers + private static Location? GetLocationTrimmed(Location? location) + { + if (location is null) + { + return null; + } + var lineSpan = location.GetLineSpan(); + var mappedSpan = location.GetMappedLineSpan(); + + return Location.Create(lineSpan.Path, location.SourceSpan, lineSpan.Span, mappedSpan.Path, mappedSpan.Span); + } + + /// + /// Creates a new instance from the current instance. + /// + public Diagnostic CreateDiagnostic() => Diagnostic.Create(Descriptor, Location, MessageArgs); + + /// + public readonly override bool Equals(object? obj) => obj is EquatableDiagnostic info && Equals(info); + + /// + public readonly bool Equals(EquatableDiagnostic other) + { + return Descriptor.Equals(other.Descriptor) + && MessageArgs.SequenceEqual(other.MessageArgs) + && Location == other.Location; + } + + /// + public readonly override int GetHashCode() + { + int hashCode = Descriptor.GetHashCode(); + foreach (object? messageArg in MessageArgs) + { + hashCode = CombineHashCodes(hashCode, messageArg?.GetHashCode() ?? 0); + } + + hashCode = CombineHashCodes(hashCode, Location?.GetHashCode() ?? 0); + return hashCode; + } + + // inline utility method from PolyType Helpers.CommonHelpers + private static int CombineHashCodes(int h1, int h2) + { + // RyuJIT optimizes this to use the ROL instruction + // Related GitHub pull request: https://github.com/dotnet/coreclr/pull/1830 + uint rol5 = ((uint)h1 << 5) | ((uint)h1 >> 27); + return ((int)rol5 + h1) ^ h2; + } + + public static bool operator ==(EquatableDiagnostic left, EquatableDiagnostic right) + { + return left.Equals(right); + } + + public static bool operator !=(EquatableDiagnostic left, EquatableDiagnostic right) + { + return !left.Equals(right); + } +} diff --git a/src/Altinn.App.Analyzers/Utils/FileLocationHelper.cs b/src/Altinn.App.Analyzers/Utils/FileLocationHelper.cs new file mode 100644 index 0000000000..96f5edb103 --- /dev/null +++ b/src/Altinn.App.Analyzers/Utils/FileLocationHelper.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Altinn.App.Analyzers.Utils; + +/// +/// Helper functions for creating instances from start and end char indexes. +/// +public static class FileLocationHelper +{ + public static Location GetLocation(AdditionalText file, int startIndex, int? endIndex) + { + var fileContent = file.GetText()?.ToString() ?? string.Empty; + return GetLocation(fileContent.AsSpan(), file.Path, startIndex, endIndex); + } + + public static Location GetLocation(ReadOnlySpan fileContent, string filePath, int startIndex, int? endIndex) + { + // normalize and clamp + var start = Math.Max(0, Math.Min(startIndex, fileContent.Length)); + var endRaw = endIndex ?? start; + var end = Math.Max(start, Math.Min(endRaw, fileContent.Length)); + return Location.Create( + filePath, + new TextSpan(start, end - start), + new LinePositionSpan(GetLinePosition(start, fileContent), GetLinePosition(end, fileContent)) + ); + } + + private static LinePosition GetLinePosition(int position, ReadOnlySpan fileContent) + { + Debug.Assert(position >= 0 && position <= fileContent.Length); + var line = 0; + var character = 0; + for (var i = 0; i < position; i++) + { + if (fileContent[i] == '\n') + { + line++; + character = -1; + } + character++; + } + return new LinePosition(line, character); + } +} diff --git a/src/Altinn.App.Analyzers/Utils/HashCode.cs b/src/Altinn.App.Analyzers/Utils/HashCode.cs new file mode 100644 index 0000000000..4654d0b653 --- /dev/null +++ b/src/Altinn.App.Analyzers/Utils/HashCode.cs @@ -0,0 +1,430 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +// Original license +// The MIT License (MIT) + +// Copyright (c) 2019 andrewlock + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +namespace Altinn.App.Analyzers.Utils; + +/// +/// Polyfill for .NET 6 HashCode +/// Copied from https://github.com/andrewlock/StronglyTypedId/blob/6bd17db4a4b700eaad9e209baf41478cc3f0bbe9/src/StronglyTypedIds/HashCode.cs +/// +internal struct HashCode +{ + private static readonly uint _sSeed = GenerateGlobalSeed(); + + private const uint Prime1 = 2654435761U; + private const uint Prime2 = 2246822519U; + private const uint Prime3 = 3266489917U; + private const uint Prime4 = 668265263U; + private const uint Prime5 = 374761393U; + + private uint _v1, + _v2, + _v3, + _v4; + private uint _queue1, + _queue2, + _queue3; + private uint _length; + + private static uint GenerateGlobalSeed() + { + return 2455434; + // var buffer = new byte[sizeof(uint)]; + // new Random().NextBytes(buffer); + // return BitConverter.ToUInt32(buffer, 0); + } + + public static int Combine(T1 value1) + { + // Provide a way of diffusing bits from something with a limited + // input hash space. For example, many enums only have a few + // possible hashes, only using the bottom few bits of the code. Some + // collections are built on the assumption that hashes are spread + // over a larger space, so diffusing the bits may help the + // collection work more efficiently. + + uint hc1 = (uint)(value1?.GetHashCode() ?? 0); + + uint hash = MixEmptyState(); + hash += 4; + + hash = QueueRound(hash, hc1); + + hash = MixFinal(hash); + return (int)hash; + } + + public static int Combine(T1 value1, T2 value2) + { + uint hc1 = (uint)(value1?.GetHashCode() ?? 0); + uint hc2 = (uint)(value2?.GetHashCode() ?? 0); + + uint hash = MixEmptyState(); + hash += 8; + + hash = QueueRound(hash, hc1); + hash = QueueRound(hash, hc2); + + hash = MixFinal(hash); + return (int)hash; + } + + public static int Combine(T1 value1, T2 value2, T3 value3) + { + uint hc1 = (uint)(value1?.GetHashCode() ?? 0); + uint hc2 = (uint)(value2?.GetHashCode() ?? 0); + uint hc3 = (uint)(value3?.GetHashCode() ?? 0); + + uint hash = MixEmptyState(); + hash += 12; + + hash = QueueRound(hash, hc1); + hash = QueueRound(hash, hc2); + hash = QueueRound(hash, hc3); + + hash = MixFinal(hash); + return (int)hash; + } + + public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4) + { + uint hc1 = (uint)(value1?.GetHashCode() ?? 0); + uint hc2 = (uint)(value2?.GetHashCode() ?? 0); + uint hc3 = (uint)(value3?.GetHashCode() ?? 0); + uint hc4 = (uint)(value4?.GetHashCode() ?? 0); + + Initialize(out uint v1, out uint v2, out uint v3, out uint v4); + + v1 = Round(v1, hc1); + v2 = Round(v2, hc2); + v3 = Round(v3, hc3); + v4 = Round(v4, hc4); + + uint hash = MixState(v1, v2, v3, v4); + hash += 16; + + hash = MixFinal(hash); + return (int)hash; + } + + public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5) + { + uint hc1 = (uint)(value1?.GetHashCode() ?? 0); + uint hc2 = (uint)(value2?.GetHashCode() ?? 0); + uint hc3 = (uint)(value3?.GetHashCode() ?? 0); + uint hc4 = (uint)(value4?.GetHashCode() ?? 0); + uint hc5 = (uint)(value5?.GetHashCode() ?? 0); + + Initialize(out uint v1, out uint v2, out uint v3, out uint v4); + + v1 = Round(v1, hc1); + v2 = Round(v2, hc2); + v3 = Round(v3, hc3); + v4 = Round(v4, hc4); + + uint hash = MixState(v1, v2, v3, v4); + hash += 20; + + hash = QueueRound(hash, hc5); + + hash = MixFinal(hash); + return (int)hash; + } + + public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5, T6 value6) + { + uint hc1 = (uint)(value1?.GetHashCode() ?? 0); + uint hc2 = (uint)(value2?.GetHashCode() ?? 0); + uint hc3 = (uint)(value3?.GetHashCode() ?? 0); + uint hc4 = (uint)(value4?.GetHashCode() ?? 0); + uint hc5 = (uint)(value5?.GetHashCode() ?? 0); + uint hc6 = (uint)(value6?.GetHashCode() ?? 0); + + Initialize(out uint v1, out uint v2, out uint v3, out uint v4); + + v1 = Round(v1, hc1); + v2 = Round(v2, hc2); + v3 = Round(v3, hc3); + v4 = Round(v4, hc4); + + uint hash = MixState(v1, v2, v3, v4); + hash += 24; + + hash = QueueRound(hash, hc5); + hash = QueueRound(hash, hc6); + + hash = MixFinal(hash); + return (int)hash; + } + + public static int Combine( + T1 value1, + T2 value2, + T3 value3, + T4 value4, + T5 value5, + T6 value6, + T7 value7 + ) + { + uint hc1 = (uint)(value1?.GetHashCode() ?? 0); + uint hc2 = (uint)(value2?.GetHashCode() ?? 0); + uint hc3 = (uint)(value3?.GetHashCode() ?? 0); + uint hc4 = (uint)(value4?.GetHashCode() ?? 0); + uint hc5 = (uint)(value5?.GetHashCode() ?? 0); + uint hc6 = (uint)(value6?.GetHashCode() ?? 0); + uint hc7 = (uint)(value7?.GetHashCode() ?? 0); + + Initialize(out uint v1, out uint v2, out uint v3, out uint v4); + + v1 = Round(v1, hc1); + v2 = Round(v2, hc2); + v3 = Round(v3, hc3); + v4 = Round(v4, hc4); + + uint hash = MixState(v1, v2, v3, v4); + hash += 28; + + hash = QueueRound(hash, hc5); + hash = QueueRound(hash, hc6); + hash = QueueRound(hash, hc7); + + hash = MixFinal(hash); + return (int)hash; + } + + public static int Combine( + T1 value1, + T2 value2, + T3 value3, + T4 value4, + T5 value5, + T6 value6, + T7 value7, + T8 value8 + ) + { + uint hc1 = (uint)(value1?.GetHashCode() ?? 0); + uint hc2 = (uint)(value2?.GetHashCode() ?? 0); + uint hc3 = (uint)(value3?.GetHashCode() ?? 0); + uint hc4 = (uint)(value4?.GetHashCode() ?? 0); + uint hc5 = (uint)(value5?.GetHashCode() ?? 0); + uint hc6 = (uint)(value6?.GetHashCode() ?? 0); + uint hc7 = (uint)(value7?.GetHashCode() ?? 0); + uint hc8 = (uint)(value8?.GetHashCode() ?? 0); + + Initialize(out uint v1, out uint v2, out uint v3, out uint v4); + + v1 = Round(v1, hc1); + v2 = Round(v2, hc2); + v3 = Round(v3, hc3); + v4 = Round(v4, hc4); + + v1 = Round(v1, hc5); + v2 = Round(v2, hc6); + v3 = Round(v3, hc7); + v4 = Round(v4, hc8); + + uint hash = MixState(v1, v2, v3, v4); + hash += 32; + + hash = MixFinal(hash); + return (int)hash; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Initialize(out uint v1, out uint v2, out uint v3, out uint v4) + { + v1 = _sSeed + Prime1 + Prime2; + v2 = _sSeed + Prime2; + v3 = _sSeed; + v4 = _sSeed - Prime1; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint Round(uint hash, uint input) + { + return RotateLeft(hash + input * Prime2, 13) * Prime1; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint QueueRound(uint hash, uint queuedValue) + { + return RotateLeft(hash + queuedValue * Prime3, 17) * Prime4; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint MixState(uint v1, uint v2, uint v3, uint v4) + { + return RotateLeft(v1, 1) + RotateLeft(v2, 7) + RotateLeft(v3, 12) + RotateLeft(v4, 18); + } + + private static uint MixEmptyState() + { + return _sSeed + Prime5; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint MixFinal(uint hash) + { + hash ^= hash >> 15; + hash *= Prime2; + hash ^= hash >> 13; + hash *= Prime3; + hash ^= hash >> 16; + return hash; + } + + public void Add(T value) + { + Add(value?.GetHashCode() ?? 0); + } + + public void Add(T value, IEqualityComparer? comparer) + { + Add(value is null ? 0 : (comparer?.GetHashCode(value) ?? value.GetHashCode())); + } + + private void Add(int value) + { + // The original xxHash works as follows: + // 0. Initialize immediately. We can't do this in a struct (no + // default ctor). + // 1. Accumulate blocks of length 16 (4 uints) into 4 accumulators. + // 2. Accumulate remaining blocks of length 4 (1 uint) into the + // hash. + // 3. Accumulate remaining blocks of length 1 into the hash. + + // There is no need for #3 as this type only accepts ints. _queue1, + // _queue2 and _queue3 are basically a buffer so that when + // ToHashCode is called we can execute #2 correctly. + + // We need to initialize the xxHash32 state (_v1 to _v4) lazily (see + // #0) nd the last place that can be done if you look at the + // original code is just before the first block of 16 bytes is mixed + // in. The xxHash32 state is never used for streams containing fewer + // than 16 bytes. + + // To see what's really going on here, have a look at the Combine + // methods. + + uint val = (uint)value; + + // Storing the value of _length locally shaves of quite a few bytes + // in the resulting machine code. + uint previousLength = _length++; + uint position = previousLength % 4; + + // Switch can't be inlined. + + if (position == 0) + _queue1 = val; + else if (position == 1) + _queue2 = val; + else if (position == 2) + _queue3 = val; + else // position == 3 + { + if (previousLength == 3) + Initialize(out _v1, out _v2, out _v3, out _v4); + + _v1 = Round(_v1, _queue1); + _v2 = Round(_v2, _queue2); + _v3 = Round(_v3, _queue3); + _v4 = Round(_v4, val); + } + } + + public int ToHashCode() + { + // Storing the value of _length locally shaves of quite a few bytes + // in the resulting machine code. + uint length = _length; + + // position refers to the *next* queue position in this method, so + // position == 1 means that _queue1 is populated; _queue2 would have + // been populated on the next call to Add. + uint position = length % 4; + + // If the length is less than 4, _v1 to _v4 don't contain anything + // yet. xxHash32 treats this differently. + + uint hash = length < 4 ? MixEmptyState() : MixState(_v1, _v2, _v3, _v4); + + // _length is incremented once per Add(Int32) and is therefore 4 + // times too small (xxHash length is in bytes, not ints). + + hash += length * 4; + + // Mix what remains in the queue + + // Switch can't be inlined right now, so use as few branches as + // possible by manually excluding impossible scenarios (position > 1 + // is always false if position is not > 0). + if (position > 0) + { + hash = QueueRound(hash, _queue1); + if (position > 1) + { + hash = QueueRound(hash, _queue2); + if (position > 2) + hash = QueueRound(hash, _queue3); + } + } + + hash = MixFinal(hash); + return (int)hash; + } + +#pragma warning disable 0809 + // Obsolete member 'memberA' overrides non-obsolete member 'memberB'. + // Disallowing GetHashCode and Equals is by design + + // * We decided to not override GetHashCode() to produce the hash code + // as this would be weird, both naming-wise as well as from a + // behavioral standpoint (GetHashCode() should return the object's + // hash code, not the one being computed). + + // * Even though ToHashCode() can be called safely multiple times on + // this implementation, it is not part of the contract. If the + // implementation has to change in the future we don't want to worry + // about people who might have incorrectly used this type. + + [Obsolete( + "HashCode is a mutable struct and should not be compared with other HashCodes. Use ToHashCode to retrieve the computed hash code.", + error: true + )] + [EditorBrowsable(EditorBrowsableState.Never)] + public override int GetHashCode() => throw new NotSupportedException("Hash code not supported"); + + [Obsolete("HashCode is a mutable struct and should not be compared with other HashCodes.", error: true)] + [EditorBrowsable(EditorBrowsableState.Never)] + public override bool Equals(object? obj) => throw new NotSupportedException("Equality not supported"); +#pragma warning restore 0809 + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint RotateLeft(uint value, int offset) => (value << offset) | (value >> (32 - offset)); +} diff --git a/src/Altinn.App.Analyzers/Utils/NanoJsonReader.cs b/src/Altinn.App.Analyzers/Utils/NanoJsonReader.cs new file mode 100644 index 0000000000..311caa2a48 --- /dev/null +++ b/src/Altinn.App.Analyzers/Utils/NanoJsonReader.cs @@ -0,0 +1,556 @@ +using System.Diagnostics; +using System.Text; + +namespace NanoJsonReader; + +public enum JsonType +{ + Error, + Null, + Boolean, + String, + Number, + Object, + Array, +} + +public class NanoJsonException : Exception +{ + public int StartIndex { get; } + public int? EndIndex { get; } + + public string FullJson { get; } + + public NanoJsonException(string message, string fullJson, int startIndex, int? endIndex) + : base(FormatMessage(message, fullJson, startIndex, endIndex)) + { + StartIndex = startIndex; + EndIndex = endIndex; + FullJson = fullJson; + } + + private static string FormatMessage(string message, string fullJson, int startIndex, int? endIndex) + { + var sb = new StringBuilder(); + sb.Append(message); + sb.Append(" at index "); + sb.Append(startIndex); + if (endIndex.HasValue) + { + sb.Append(" to "); + sb.Append(endIndex.Value); + } + + sb.AppendLine(); + + var firstVisibleIndex = Math.Max(0, startIndex - 20); + var displayLength = Math.Min(80, fullJson.Length - firstVisibleIndex); + var snippet = GetMonospaceString(fullJson.Substring(firstVisibleIndex, displayLength)); + sb.AppendLine(snippet); + sb.Append(new string(' ', startIndex - firstVisibleIndex) + "^"); + if (endIndex.HasValue) + { + sb.Append(new string('~', endIndex.Value - startIndex)); + } + + sb.AppendLine(); + + return sb.ToString(); + } + + private static string GetMonospaceString(string input) + { + var sb = new StringBuilder(input.Length); + foreach (char c in input) + { + // Display newlines as ␤ and carriage returns as ␍ + if (c == '\n') + sb.Append('\u2424'); // ␤ + else if (c == '\r') + sb.Append('\u240D'); // ␍ + else if (char.IsLetterOrDigit(c) || char.IsPunctuation(c) || char.IsSymbol(c)) + // Accept letters, digits, punctuation and symbols as is + sb.Append(c); + else if (char.IsWhiteSpace(c)) + // Replace whitespace with space + sb.Append(' '); + else + // Replace other characters with unicode replacement character � + sb.Append('\uFFFD'); + } + return sb.ToString(); + } +} + +public class JsonValue +{ + public readonly string FullJson; + public readonly JsonType Type; + public readonly int Start; + + private int? _end; + + public int End + { + get + { + if (_end.HasValue) + { + return _end.Value; + } + + switch (Type) + { + case JsonType.Array: + foreach (var _ in GetArrayValues()) + { + // Iterate through all array values to find the end + } + return _end ?? throw new InvalidOperationException("End was not set after reading array"); + case JsonType.Object: + foreach (var _ in GetObjectValues()) + { + // Iterate through all object values to find the end + } + return _end ?? throw new InvalidOperationException("End was not set after reading object"); + default: + // End should be set at parse time for other types + throw new NotImplementedException(); + } + } + } + + public ReadOnlySpan InnerJson => _end.HasValue ? FullJson.AsSpan().Slice(Start, _end.Value - Start) : default; + + public JsonValue(JsonType type, string fullJson, int start, int? end) + { + if (type is not (JsonType.Array or JsonType.Object)) + { + Debug.Assert(end is not null); + } + Type = type; + FullJson = fullJson; + Start = start; + _end = end; + } + + public static JsonValue Parse(string json) + { + return JsonReader.ReadNextValue(json, 0); + } + + public override string ToString() => InnerJson.ToString(); + + public string GetString() + { + var stringSpan = GetStringSpan(); + Span buffer = + InnerJson.Length < 1000 ? stackalloc char[stringSpan.Length] : new char[stringSpan.Length].AsSpan(); + if (JsonReader.WriteStringToBuffer(stringSpan, buffer, out int charsWritten)) + { + return buffer.Slice(0, charsWritten).ToString(); + } + + throw new NanoJsonException("Invalid string", FullJson, Start, End); + } + + public ReadOnlySpan GetStringSpan() + { + Debug.Assert(Type == JsonType.String); + Debug.Assert(InnerJson[0] == '"'); + Debug.Assert(InnerJson[InnerJson.Length - 1] == '"'); + return InnerJson.Slice(1, InnerJson.Length - 2); // remove surrounding quotes + } + + public bool StringEquals(ReadOnlySpan s) + { + return JsonReader.CompareStringWithBuffer(s, GetStringSpan()); + } + + public bool GetBool() + { + Debug.Assert(Type == JsonType.Boolean); + // Verification of false done at parse time. + return InnerJson[0] == 't'; + } + + public double GetNumber() + { + Debug.Assert(Type == JsonType.Number); +#if NET8_0_OR_GREATER + if (double.TryParse(InnerJson, out var number)) +#else + // netStandard2.0 does not support TryParse for double with ReadOnlySpan + if (double.TryParse(InnerJson.ToString(), out var number)) +#endif + { + return number; + } + throw new NanoJsonException($"Could not parse number {InnerJson.ToString()}", FullJson, Start, End); + } + + public IEnumerable GetArrayValues() + { + int currentPosition = Start; + Debug.Assert(Type == JsonType.Array); + Debug.Assert(FullJson[currentPosition] == '['); + currentPosition = JsonReader.SkipWhitespace(FullJson, currentPosition + 1); + if (FullJson[currentPosition] == ']') + { + _end = currentPosition + 1; + yield break; + } + + while (true) + { + var item = JsonReader.ReadNextValue(FullJson, currentPosition); + yield return item; + currentPosition = JsonReader.SkipWhitespace(FullJson, item.End); + char sep = FullJson[currentPosition]; + if (sep == ',') + { + // Check for trailing comma + currentPosition = JsonReader.SkipWhitespace(FullJson, currentPosition + 1); + if (FullJson[currentPosition] == ']') + { + _end = currentPosition + 1; + yield break; + } + } + else if (sep == ']') + { + _end = currentPosition + 1; + yield break; + } + else + { + throw new NanoJsonException("Expected comma or end of array", FullJson, currentPosition, null); + } + } + } + + public IEnumerable GetObjectValues() + { + Debug.Assert(Type == JsonType.Object); + int currentPosition = Start; + Debug.Assert(FullJson[currentPosition] == '{'); + + currentPosition = JsonReader.SkipWhitespace(FullJson, currentPosition + 1); + if (FullJson[currentPosition] == '}') + { + _end = currentPosition + 1; + yield break; + } + while (true) + { + var key = JsonReader.ReadNextValue(FullJson, currentPosition); + if (key.Type != JsonType.String) + { + throw new NanoJsonException("Keys in json object must be string", FullJson, currentPosition, key.End); + } + currentPosition = JsonReader.SkipWhitespace(FullJson, key.End); + + if (FullJson[currentPosition] != ':') + { + throw new NanoJsonException("Expected colon after property name", FullJson, currentPosition, null); + } + + var value = JsonReader.ReadNextValue(FullJson, currentPosition + 1); + yield return new JsonProperty(key, value); + currentPosition = JsonReader.SkipWhitespace(FullJson, value.End); + char sep = FullJson[currentPosition]; + if (sep == ',') + { + // Check for trailing comma + currentPosition = JsonReader.SkipWhitespace(FullJson, currentPosition + 1); + if (FullJson[currentPosition] == '}') + { + _end = currentPosition + 1; + yield break; + } + } + else if (sep == '}') + { + _end = currentPosition + 1; + yield break; + } + else + { + throw new NanoJsonException("Expected comma or end of object", FullJson, currentPosition, null); + } + } + } + + public JsonValue? GetProperty(string propertyName) => + GetObjectValues().FirstOrDefault(item => item.IsPropertyName(propertyName.AsSpan()))?.Value; + + public class JsonProperty + { + private readonly JsonValue _key; + + public string Key => _key.GetString(); + public JsonValue Value { get; } + + public JsonProperty(JsonValue key, JsonValue value) + { + Debug.Assert(key.Type == JsonType.String); + _key = key; + Value = value; + } + + public override string ToString() => _key.FullJson.Substring(_key.Start, Value.End - _key.Start); + + public bool IsPropertyName(ReadOnlySpan propertyName) => + JsonReader.CompareStringWithBuffer(propertyName, _key.GetStringSpan()); + } +} + +public static class JsonReader +{ + public static JsonValue ReadNextValue(string fullJson, int start) + { + int pos = SkipWhitespace(fullJson, start); + + return fullJson[pos] switch + { + '"' => ParseString(fullJson, pos), + 'n' => Match(fullJson, pos, "null", JsonType.Null), + 't' => Match(fullJson, pos, "true", JsonType.Boolean), + 'f' => Match(fullJson, pos, "false", JsonType.Boolean), + '-' or >= '0' and <= '9' => ParseNumber(fullJson, pos), + '{' => ReadObject(fullJson, pos), + '[' => ReadArray(fullJson, pos), + // End of array or object is handled by ReadObject and ReadArray, so a stray end is a syntax error. + // ']' or '}' => new JsonValue(JsonType.Error, fullJson, start, pos), + _ => throw new NanoJsonException($"Unexpected character '{fullJson[pos]}'", fullJson, pos, null), + }; + } + + private static JsonValue ReadArray(string fullJson, int pos) + { + Debug.Assert(fullJson[pos] == '['); + + return new JsonValue(JsonType.Array, fullJson, pos, null); + } + + private static JsonValue ReadObject(string fullJson, int pos) + { + Debug.Assert(fullJson[pos] == '{'); + + return new JsonValue(JsonType.Object, fullJson, pos, null); + } + + private static JsonValue Match(string fullJson, int index, string toMatch, JsonType type) + { + if (fullJson.AsSpan().Slice(index).StartsWith(toMatch.AsSpan(), StringComparison.Ordinal)) + { + return new JsonValue(type, fullJson, index, index + toMatch.Length); + } + throw new NanoJsonException("Invalid json literal", fullJson, index, index + toMatch.Length); + } + + private static JsonValue ParseString(string fullJson, int index) + { + int start = index; + index++; // skip opening " + while (fullJson[index] != '"') + { + // naive skip escape to ensure that we don't end the string at \" + // (we use a better algorithm in WriteStringToBuffer that actually translate escape sequences to the proper characters) + if (fullJson[index] == '\\') + index++; + index++; + if (index >= fullJson.Length) + { + throw new NanoJsonException("Unterminated string", fullJson, start, index); + } + } + return new JsonValue(JsonType.String, fullJson, start, index + 1); + } + + private static JsonValue ParseNumber(string fullJson, int index) + { + var nextIndex = index; + while (nextIndex < fullJson.Length && "-+.eE0123456789".Contains(fullJson[nextIndex])) + { + nextIndex++; + } + return new(JsonType.Number, fullJson, index, nextIndex); + } + + public static int SkipWhitespace(string fullJson, int startPosition) + { + int currentPosition = startPosition; + + if (currentPosition >= fullJson.Length) + { + throw new NanoJsonException("End of json encountered", fullJson, startPosition, null); + } + + while (char.IsWhiteSpace(fullJson, currentPosition)) + { + currentPosition++; + if (currentPosition >= fullJson.Length) + { + throw new NanoJsonException("End of json encountered", fullJson, startPosition, currentPosition); + } + } + + return currentPosition; + } + + public static bool CompareStringWithBuffer(ReadOnlySpan nonEscapedString, ReadOnlySpan jsonBuffer) + { + // Fast path: if there are no escape characters, do a direct comparison + int backslashIndex = jsonBuffer.IndexOf('\\'); + if (backslashIndex == -1) + return jsonBuffer.SequenceEqual(nonEscapedString); + + // Slow path: handle escape sequences + int jsonPos = 0; + int rawPos = 0; + + while (jsonPos < jsonBuffer.Length && rawPos < nonEscapedString.Length) + { + char jsonChar = jsonBuffer[jsonPos]; + + if (jsonChar == '\\') + { + if (jsonPos + 1 >= jsonBuffer.Length) + return false; + + jsonPos++; + char escaped = jsonBuffer[jsonPos]; + char unescaped = escaped switch + { + '"' or '\\' or '/' => escaped, + 'b' => '\b', + 'f' => '\f', + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + 'u' => // Handle Unicode escape sequences + jsonPos + 4 < jsonBuffer.Length && TryParseHex16(jsonBuffer.Slice(jsonPos + 1, 4), out char code) + ? code + : '\0', + _ => '\0', + }; + + if (unescaped == '\0') + return false; + + if (unescaped != nonEscapedString[rawPos]) + return false; + + jsonPos += escaped == 'u' ? 4 : 0; + } + else + { + if (jsonChar != nonEscapedString[rawPos]) + return false; + } + + jsonPos++; + rawPos++; + } + + return rawPos == nonEscapedString.Length && jsonPos == jsonBuffer.Length; + } + + public static bool WriteStringToBuffer(ReadOnlySpan jsonString, Span buffer, out int writePos) + { + bool success = true; + int readPos = 0; + writePos = 0; + + while (readPos < jsonString.Length) + { + if (writePos >= buffer.Length) + throw new ArgumentException("Buffer too small"); + + char c = jsonString[readPos++]; + + if (c == '\\') + { + if (readPos >= jsonString.Length) + return false; + + char esc = jsonString[readPos++]; + switch (esc) + { + case '"': + case '\\': + case '/': + buffer[writePos++] = esc; + break; + case 'b': + buffer[writePos++] = '\b'; + break; + case 'f': + buffer[writePos++] = '\f'; + break; + case 'n': + buffer[writePos++] = '\n'; + break; + case 'r': + buffer[writePos++] = '\r'; + break; + case 't': + buffer[writePos++] = '\t'; + break; + case 'u': + if ( + readPos + 4 <= jsonString.Length + && TryParseHex16(jsonString.Slice(readPos, 4), out char code) + ) + { + buffer[writePos++] = code; + readPos += 4; + break; + } + + success = false; + break; + default: + success = false; + break; + } + } + else + { + buffer[writePos++] = c; + } + } + + return success; + } + + private static bool TryParseHex16(ReadOnlySpan hex, out char value) + { + int val = 0; + + for (int i = 0; i < 4; i++) + { + char c = hex[i]; + int digit = c switch + { + >= '0' and <= '9' => c - '0', + >= 'A' and <= 'F' => c - 'A' + 10, + >= 'a' and <= 'f' => c - 'a' + 10, + _ => -1, + }; + + if (digit < 0) + { + value = '\0'; + return false; + } + + val += digit << ((3 - i) * 4); + } + + value = (char)val; + + return true; + } +} diff --git a/src/Altinn.App.Analyzers/Utils/RoslynExtensions.cs b/src/Altinn.App.Analyzers/Utils/RoslynExtensions.cs new file mode 100644 index 0000000000..58d1125bdc --- /dev/null +++ b/src/Altinn.App.Analyzers/Utils/RoslynExtensions.cs @@ -0,0 +1,152 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Altinn.App.Analyzers.Utils; + +internal static class RoslynExtensions +{ + // There is a better version of this code that depends on Roslyn 4.2 which it seems we can't use yet + // Copied into altinn from https://github.com/dotnet/runtime/blob/066077627d669966af19ddf93b6054baf6b14062/src/libraries/Common/src/Roslyn/GetBestTypeByMetadataName.cs + // Copied into system.text.json from: https://github.com/dotnet/roslyn/blob/main/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Extensions/CompilationExtensions.cs + /// + /// Gets a type by its metadata name to use for code analysis within a . This method + /// attempts to find the "best" symbol to use for code analysis, which is the symbol matching the first of the + /// following rules. + /// + /// + /// + /// If only one type with the given name is found within the compilation and its referenced assemblies, that + /// type is returned regardless of accessibility. + /// + /// + /// If the current defines the symbol, that symbol is returned. + /// + /// + /// If exactly one referenced assembly defines the symbol in a manner that makes it visible to the current + /// , that symbol is returned. + /// + /// + /// Otherwise, this method returns . + /// + /// + /// + /// The to consider for analysis. + /// The fully-qualified metadata type name to find. + /// The symbol to use for code analysis; otherwise, . + public static INamedTypeSymbol? GetBestTypeByMetadataName( + this Compilation compilation, + string fullyQualifiedMetadataName + ) + { + // Try to get the unique type with this name, ignoring accessibility + var type = compilation.GetTypeByMetadataName(fullyQualifiedMetadataName); + + // Otherwise, try to get the unique type with this name originally defined in 'compilation' + type ??= compilation.Assembly.GetTypeByMetadataName(fullyQualifiedMetadataName); + + // Otherwise, try to get the unique accessible type with this name from a reference + if (type is null) + { + foreach (var module in compilation.Assembly.Modules) + { + foreach (var referencedAssembly in module.ReferencedAssemblySymbols) + { + var currentType = referencedAssembly.GetTypeByMetadataName(fullyQualifiedMetadataName); + if (currentType is null) + continue; + + switch (currentType.GetResultantVisibility()) + { + case SymbolVisibility.Public: + case SymbolVisibility.Internal when referencedAssembly.GivesAccessTo(compilation.Assembly): + break; + + default: + continue; + } + + if (type is object) + { + // Multiple visible types with the same metadata name are present + return null; + } + + type = currentType; + } + } + } + + return type; + } + + /// + /// A thin wrapper over , + /// but taking the type itself rather than the fully-qualified metadata type name. + /// + /// The to consider for analysis. + /// The type to find. + /// + public static INamedTypeSymbol? GetBestTypeByMetadataName(this Compilation compilation, Type type) => + type.IsArray || type.FullName is null + ? throw new ArgumentException("The input type must correspond to a named type symbol.") + : GetBestTypeByMetadataName(compilation, type.FullName); + + // copied from https://github.com/dotnet/roslyn/blob/main/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Extensions/ISymbolExtensions.cs + private static SymbolVisibility GetResultantVisibility(this ISymbol symbol) + { + // Start by assuming it's visible. + SymbolVisibility visibility = SymbolVisibility.Public; + + switch (symbol.Kind) + { + case SymbolKind.Alias: + // Aliases are uber private. They're only visible in the same file that they + // were declared in. + return SymbolVisibility.Private; + + case SymbolKind.Parameter: + // Parameters are only as visible as their containing symbol + return GetResultantVisibility(symbol.ContainingSymbol); + + case SymbolKind.TypeParameter: + // Type Parameters are private. + return SymbolVisibility.Private; + } + + while (symbol != null && symbol.Kind != SymbolKind.Namespace) + { + switch (symbol.DeclaredAccessibility) + { + // If we see anything private, then the symbol is private. + case Accessibility.NotApplicable: + case Accessibility.Private: + return SymbolVisibility.Private; + + // If we see anything internal, then knock it down from public to + // internal. + case Accessibility.Internal: + case Accessibility.ProtectedAndInternal: + visibility = SymbolVisibility.Internal; + break; + + // For anything else (Public, Protected, ProtectedOrInternal), the + // symbol stays at the level we've gotten so far. + } + + symbol = symbol.ContainingSymbol; + } + + return visibility; + } + + // Copied from: https://github.com/dotnet/roslyn/blob/main/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Utilities/SymbolVisibility.cs +#pragma warning disable CA1027 // Mark enums with FlagsAttribute + private enum SymbolVisibility +#pragma warning restore CA1027 // Mark enums with FlagsAttribute + { + Public = 0, + Internal = 1, + Private = 2, + Friend = Internal, + } +} diff --git a/src/Altinn.App.Api/Altinn.App.Api.csproj b/src/Altinn.App.Api/Altinn.App.Api.csproj index 0f426bff36..53de97665b 100644 --- a/src/Altinn.App.Api/Altinn.App.Api.csproj +++ b/src/Altinn.App.Api/Altinn.App.Api.csproj @@ -35,9 +35,28 @@ + + + + + + + + + + + + + + + + diff --git a/src/Altinn.App.Core/packaging/Altinn.App.Core.props b/src/Altinn.App.Api/packaging/Altinn.App.Api.props similarity index 86% rename from src/Altinn.App.Core/packaging/Altinn.App.Core.props rename to src/Altinn.App.Api/packaging/Altinn.App.Api.props index 86c8946f6a..0b9a8ab7e7 100644 --- a/src/Altinn.App.Core/packaging/Altinn.App.Core.props +++ b/src/Altinn.App.Api/packaging/Altinn.App.Api.props @@ -1,7 +1,7 @@ diff --git a/src/Altinn.App.Core/Altinn.App.Core.csproj b/src/Altinn.App.Core/Altinn.App.Core.csproj index fa4b207e75..1bda0846be 100644 --- a/src/Altinn.App.Core/Altinn.App.Core.csproj +++ b/src/Altinn.App.Core/Altinn.App.Core.csproj @@ -45,32 +45,14 @@ - - - - - - - - - - - - - - - - + diff --git a/src/Altinn.App.Core/Features/IFormDataWrapper.cs b/src/Altinn.App.Core/Features/IFormDataWrapper.cs new file mode 100644 index 0000000000..7594944bc3 --- /dev/null +++ b/src/Altinn.App.Core/Features/IFormDataWrapper.cs @@ -0,0 +1,290 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.Core.Models.Layout; + +namespace Altinn.App.Core.Features; + +/// +/// Interface for a wrapper around a data model, that allows for easy access to fields and rows in the model. +/// +/// Implementations for each data model type will be created by a source generator and retrieved by the +/// +/// +public interface IFormDataWrapper +{ + /// + /// Get the C# class type of this form data + /// + public Type BackingDataType { get; } + + /// + /// Get the backing data model as a T. + /// Use <object> if you don't know the type. + /// + /// + /// // Get the raw data model as (object) + /// var rawData = formDataWrapper.BackingData<object>(); + /// + /// If the wrapped object is not a subtype of T + public T BackingData() + where T : class; + + /// + /// Get the value at the given path from the data model + /// + /// The dotted path to use (including inline indexes) + object? Get(ReadOnlySpan path); + + /// + /// Remove the field at the given path + /// If the path points to a row (last segment is a list with an explicit [index]), the row will be removed or set to null based on rowRemovalOption + /// + void RemoveField(ReadOnlySpan path, RowRemovalOption rowRemovalOption); + + /// + /// Typically you'd never call this low-level method, but rather use one of the extension methods + /// + /// + /// To support relative references, we need to be able to add the indexes from the relative target to the path + /// + /// The current path (possibly with unset indexes on some collections) + /// Extra rowIndexes that should be added (from context) + /// + /// A buffer that the method can work with, that is large enough to hold the full indexed path + /// (Typically we use path.Length + rowIndexes.Length * 12, as int.MaxValue.ToString().Length = 10 + 2 characters for "[]") + /// + /// The indexed path as a span that references buffer, or an empty span if a fully indexed path could not be constructed + ReadOnlySpan AddIndexToPath(ReadOnlySpan path, ReadOnlySpan rowIndexes, Span buffer); + + /// + /// Make a deep copy of the form data + /// + IFormDataWrapper Copy(); + + /// + /// Set all Guid AltinnRowId fields to Guid.Empty (so that they don't get serialized to xml or json) + /// + void RemoveAltinnRowIds(); + + /// + /// Set all Guid AltinnRowId fields that are Guid.Empty to Guid.NewGuid (so that we have an addressable id for the row when diffing for patches) + /// + void InitializeAltinnRowIds(); + + /// + /// Xml serialization-deserialization does not preserve all properties, and we sometimes need + /// to know how it looks when it comes back from storage. + /// + /// + /// * Recursively initialize all properties on the object that are currently null + /// * Ensure that all string properties with `[XmlTextAttribute]` that are empty or whitespace are set to null + /// * If a class has `[XmlTextAttribute]` and no value, set the parent property to null (if the other properties has [BindNever] attribute) + /// * If a property has a `ShouldSerialize{PropertyName}` method that returns false, set the property to default value + /// + void PrepareModelForXmlStorage(); +} + +/// +/// Extension methods for +/// +public static class FormDataWrapperExtensions +{ + /// + /// Get the value at the given path directly as the type T. Same as (T)Get(path, rowIndexes) + /// + public static T? Get( + this IFormDataWrapper formDataWrapper, + ReadOnlySpan path = default, + ReadOnlySpan rowIndexes = default + ) + { + object? data = formDataWrapper.Get(path, rowIndexes); + return data switch + { + null => default, + T t => t, + _ => throw new ArgumentException( + $"Path {path} does not point to a {typeof(T).FullName}, but {data.GetType().FullName}" + ), + }; + } + + /// + /// Simple way to get a value from the form data model adding indexes from context + /// + public static object? Get( + this IFormDataWrapper formDataWrapper, + ReadOnlySpan path, + ReadOnlySpan rowIndexes = default + ) + { + int len = GetMaxBufferLength(path, rowIndexes); + if (len <= 512) + { + Span buffer = stackalloc char[len]; + var indexedPath = formDataWrapper.AddIndexToPath(path, rowIndexes, buffer); + return indexedPath.IsEmpty ? null : formDataWrapper.Get(indexedPath); + } + else + { + char[] pool = System.Buffers.ArrayPool.Shared.Rent(len); + try + { + var indexedPath = formDataWrapper.AddIndexToPath(path, rowIndexes, pool); + return indexedPath.IsEmpty ? null : formDataWrapper.Get(indexedPath); + } + finally + { + System.Buffers.ArrayPool.Shared.Return(pool); + } + } + } + + /// + /// Get a string representation of the path with indexes from context added + /// + /// The path with indexes added or null if a fully indexed path could not be constructed + public static string? AddIndexToPath( + this IFormDataWrapper formDataWrapper, + ReadOnlySpan path, + ReadOnlySpan rowIndexes + ) + { + int len = GetMaxBufferLength(path, rowIndexes); + if (len <= 512) + { + Span buffer = stackalloc char[len]; + var indexedPath = formDataWrapper.AddIndexToPath(path, rowIndexes, buffer); + return indexedPath.IsEmpty ? null : indexedPath.ToString(); + } + else + { + char[] pool = System.Buffers.ArrayPool.Shared.Rent(len); + try + { + var indexedPath = formDataWrapper.AddIndexToPath(path, rowIndexes, pool); + return indexedPath.IsEmpty ? null : indexedPath.ToString(); + } + finally + { + System.Buffers.ArrayPool.Shared.Return(pool); + } + } + } + + /// + /// Get the number of rows in the collection at the given path + /// Returns null if the path does not resolve to an ICollection<T> (or is missing) + /// + public static int? GetRowCount( + this IFormDataWrapper formDataWrapper, + ReadOnlySpan path, + ReadOnlySpan rowIndexes + ) + { + object? data = formDataWrapper.Get(path, rowIndexes); + return data is null ? null : CollectionHelper.GetCount(data); + } + + /// + /// We need some magic to cache the accessor method for the .Count property on ICollection<T> on various subtypes + /// + private static class CollectionHelper + { + private static readonly ConcurrentDictionary?> _countGetters = new(); + + public static int? GetCount(object? data) + { + if (data == null) + { + return null; + } + + var type = data.GetType(); + var getter = _countGetters.GetOrAdd(type, CreateCountGetter); + if (getter == null) + { + // The type does not implement ICollection, so we cannot get the count + //throw new InvalidOperationException($"Type {type.FullName} in path {path} does not implement ICollection, so we can't get the count."); + return null; + } + + return getter.Invoke(data); + } + + private static Func? CreateCountGetter(Type type) + { + // Check if the type implements any ICollection + foreach (var @interface in type.GetInterfaces()) + { + if (@interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(ICollection<>)) + { + // Find the .Count property + var countProperty = @interface.GetProperty(nameof(ICollection.Count)); + if (countProperty != null && countProperty.PropertyType == typeof(int)) + { + var getMethod = countProperty.GetGetMethod(); + if (getMethod != null) + { + // Create a delegate to access .Count + return instance => InvokeReturnIntOrError(getMethod, instance); + } + } + } + } + + // Return null if not found + return null; + } + + private static int InvokeReturnIntOrError(MethodInfo info, object instance) + { + return info.Invoke(instance, null) switch + { + int i => i, + _ => throw new ArgumentException("The .Count property did not return an int"), + }; + } + } + + /// + /// Get a list of all possible keys for the given data model + /// + /// + /// intro.fnr + /// group[0].name + /// group[0].age + /// group[1].name + /// group[1].age + /// + public static DataReference[] GetResolvedKeys(this IFormDataWrapper formDataWrapper, DataReference reference) + { + //TODO: write more efficient code that uses the formDataWrapper to resolve keys instead of reflection in DataModelWrapper + var data = formDataWrapper.BackingData(); +#pragma warning disable CS0618 // Type or member is obsolete + var dataModelWrapper = new DataModelWrapper(data); +#pragma warning restore CS0618 // Type or member is obsolete + return dataModelWrapper + .GetResolvedKeys(reference.Field) + .Select(resolvedField => reference with { Field = resolvedField }) + .ToArray(); + } + + private static int GetMaxBufferLength(ReadOnlySpan path, ReadOnlySpan rowIndexes) + { + // assume adding indexes adds at most 10 characters per index + "[]" + // This is way more than we likely need, but int.MaxValue has 10 digits, + // so there is no reason to accept less. + const int maxIntStringLength = 10 + 2; + return path.Length + rowIndexes.Length * maxIntStringLength; + } +} + +/// +/// Marker interface for to find the correct implementation during assembly scanning +/// +/// +public interface IFormDataWrapper : IFormDataWrapper + where T : class, new() { } diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs index 5750f0860c..bd2a58dd18 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs @@ -221,7 +221,7 @@ private static string[] GetResolvedKeysRecursive( private static readonly Regex _keyPartRegex = new( @"^([^\s\[\]\.]+)\[(\d+)\]?$", RegexOptions.Compiled, - TimeSpan.FromMicroseconds(10) + TimeSpan.FromMilliseconds(2) ); private static (string key, int? index) ParseKeyPart(string keyPart) diff --git a/src/Altinn.App.Core/Helpers/DataModel/ReflectionFormDataWrapper.cs b/src/Altinn.App.Core/Helpers/DataModel/ReflectionFormDataWrapper.cs new file mode 100644 index 0000000000..2a203ea7ad --- /dev/null +++ b/src/Altinn.App.Core/Helpers/DataModel/ReflectionFormDataWrapper.cs @@ -0,0 +1,374 @@ +using System.Globalization; +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; +using Altinn.App.Core.Features; + +namespace Altinn.App.Core.Helpers.DataModel; + +/// +/// Get data fields from a model, using string keys (like "Bedrifter[1].Ansatte[1].Alder") +/// +public class ReflectionFormDataWrapper : IFormDataWrapper +{ + private readonly object _dataModel; + + /// + /// 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 ReflectionFormDataWrapper(object dataModel) + { + _dataModel = dataModel; + } + + /// + public Type BackingDataType => _dataModel.GetType(); + + /// + public T BackingData() + where T : class + { + return (T)_dataModel; + } + + /// + public object? Get(ReadOnlySpan path) + { + if (path.IsEmpty) + { + return null; + } + var field = path.ToString(); + if (string.IsNullOrEmpty(field)) + { + throw new ArgumentException("Field cannot be empty"); + } + return GetModelDataRecursive(field.Split('.'), 0, _dataModel); + } + + /// + public void RemoveField(ReadOnlySpan path, RowRemovalOption rowRemovalOption) + { + RemoveField(path.ToString(), rowRemovalOption); + } + + /// + public ReadOnlySpan AddIndexToPath(ReadOnlySpan path, ReadOnlySpan rowIndexes, Span buffer) + { + string tmp = AddIndexes(path.ToString(), rowIndexes); + if (tmp.Length > buffer.Length) + { + throw new ArgumentException("Buffer too small"); + } + tmp.AsSpan().CopyTo(buffer); + return buffer.Slice(0, tmp.Length); + } + + /// + public IFormDataWrapper Copy() + { + return new ReflectionFormDataWrapper( + JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(_dataModel), _dataModel.GetType()) + ?? throw new InvalidOperationException("Failed to copy data model") + ); + } + + /// + public void RemoveAltinnRowIds() + { + ObjectUtils.RemoveAltinnRowId(_dataModel); + } + + /// + public void InitializeAltinnRowIds() + { + ObjectUtils.InitializeAltinnRowId(_dataModel); + } + + /// + public void PrepareModelForXmlStorage() + { + ObjectUtils.PrepareModelForXmlStorage(_dataModel); + } + + private static object? GetModelDataRecursive(string[] keys, int index, object currentModel) + { + if (index == keys.Length) + { + return currentModel; + } + + var (key, groupIndex) = ParseKeyPart(keys[index]); + var prop = Array.Find(currentModel.GetType().GetProperties(), p => IsPropertyWithJsonName(p, key)); + var childModel = prop?.GetValue(currentModel); + if (childModel is null) + { + return null; + } + + // Strings are enumerable in C# + // Other enumerable types is treated as a collection + if (!(childModel is not string && childModel is System.Collections.IEnumerable childModelList)) + { + if (groupIndex is not null) + { + // Error, trying to index a non-collection + return null; + } + return GetModelDataRecursive(keys, index + 1, childModel); + } + + if (groupIndex is null) + { + if (index == keys.Length - 1) + { + // Return the full list if the last key is missing index + return childModelList; + } + + return null; // Error index for collection not specified + } + + var elementAt = GetElementAt(childModelList, groupIndex.Value); + if (elementAt is null) + { + return null; // Error condition, no value at index + } + + return GetModelDataRecursive(keys, index + 1, elementAt); + } + + private static object? GetElementAt(System.Collections.IEnumerable enumerable, int index) + { + // Return the element with index = groupIndex (could not find another way to get the n'th element in non-generic enumerable) + foreach (var arrayElement in enumerable) + { + if (index-- < 1) + { + return arrayElement; + } + } + + return null; + } + + private static readonly Regex _keyPartRegex = new( + @"^([^\s\[\]\.]+)\[(\d*)\]?$", + RegexOptions.Compiled, + TimeSpan.FromMilliseconds(2) + ); + + private static (string key, int? index) ParseKeyPart(string keyPart) + { + if (keyPart.Length == 0) + { + return (keyPart, null); + } + if (keyPart[^1] != ']') + { + return (keyPart, null); + } + var match = _keyPartRegex.Match(keyPart); + if (!match.Success) + { + throw new DataModelException($"Invalid key part {keyPart}"); + } + + var indexString = match.Groups[2].Value; + + if (indexString.Length == 0) + { + return (match.Groups[1].Value, null); + } + return (match.Groups[1].Value, int.Parse(indexString, CultureInfo.InvariantCulture)); + } + + private static void AddIndexesRecursive( + List ret, + Type currentModelType, + ReadOnlySpan keys, + ReadOnlySpan rowIndexes + ) + { + if (keys.Length == 0) + { + return; + } + var (key, groupIndex) = ParseKeyPart(keys[0]); + var prop = Array.Find(currentModelType.GetProperties(), p => IsPropertyWithJsonName(p, key)); + if (prop is null) + { + // Looking up something that does not exist is currently not an error, but should return null + ret.Clear(); + return; + } + + var childType = prop.PropertyType; + + // Everything that is an System.Collections.ICollection<> can be mapped to a repeating group + // Other types are treated as a single value (no index[]) + var childTypeEnumerableParameter = childType.GetInterface("ICollection`1")?.GenericTypeArguments[0]; + if (childTypeEnumerableParameter is not null) + { + if (groupIndex is null) + { + if (rowIndexes.Length != 0) + { + ret.Add($"{key}[{rowIndexes[0]}]"); + rowIndexes = rowIndexes.Slice(1); + } + else + { + if (keys.Length == 1) + { + ret.Add(key); + return; + } + // We don't have an index, but the path continues, so return empty to indicate failure + ret.Clear(); + return; + } + } + else + { + rowIndexes = default; //when you use a literal index, the context indexes are not to be used later. + ret.Add($"{key}[{groupIndex}]"); + } + + AddIndexesRecursive(ret, childTypeEnumerableParameter, keys[1..], rowIndexes); + } + else + { + if (groupIndex is not null) + { + throw new DataModelException("Index on non indexable property"); + } + + ret.Add(key); + AddIndexesRecursive(ret, childType, keys.Slice(1), rowIndexes); + } + } + + /// + /// Return a full dataModelBinding from a context aware binding by adding indexes + /// + /// + /// key = "bedrift.ansatte.navn" + /// indexes = [1,2] + /// => "bedrift[1].ansatte[2].navn" + /// + private string AddIndexes(string field, ReadOnlySpan rowIndexes = default) + { + var ret = new List(); + AddIndexesRecursive(ret, _dataModel.GetType(), field.Split('.'), rowIndexes); + return string.Join('.', ret); + } + + private static bool IsPropertyWithJsonName(PropertyInfo propertyInfo, string key) + { + var ca = propertyInfo.CustomAttributes; + + // Read [JsonPropertyName("propName")] from System.Text.Json + var system_text_json_attribute = ( + ca.FirstOrDefault(attr => + attr.AttributeType == typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute) + ) + ?.ConstructorArguments.FirstOrDefault() + .Value as string + ); + if (system_text_json_attribute is not null) + { + return system_text_json_attribute == key; + } + + // Read [JsonProperty("propName")] from Newtonsoft.Json + var newtonsoft_json_attribute = ( + ca.FirstOrDefault(attr => attr.AttributeType == typeof(Newtonsoft.Json.JsonPropertyAttribute)) + ?.ConstructorArguments.FirstOrDefault() + .Value as string + ); + // To remove dependency on Newtonsoft, while keeping compatibility + // var newtonsoft_json_attribute = (ca.FirstOrDefault(attr => attr.AttributeType.FullName == "Newtonsoft.Json.JsonPropertyAttribute")?.ConstructorArguments.FirstOrDefault().Value as string); + if (newtonsoft_json_attribute is not null) + { + return newtonsoft_json_attribute == key; + } + + // Fallback to property name if all attributes could not be found + var keyName = propertyInfo.Name; + return keyName == key; + } + + /// + /// Set the value of a field in the model to default (null) + /// + private void RemoveField(string field, RowRemovalOption rowRemovalOption) + { + var fieldSplit = field.Split('.'); + var keys = fieldSplit[0..^1]; + var (lastKey, lastGroupIndex) = ParseKeyPart(fieldSplit[^1]); + + var containingObject = GetModelDataRecursive(keys, 0, _dataModel); + if (containingObject is null) + { + // Already empty field + return; + } + + if (containingObject is System.Collections.IEnumerable) + { + throw new NotImplementedException($"Tried to remove field {field}, ended in an enumerable"); + } + + var property = Array.Find(containingObject.GetType().GetProperties(), p => IsPropertyWithJsonName(p, lastKey)); + if (property is null) + { + return; + } + + if (lastGroupIndex is not null) + { + // Remove row from list + var propertyValue = property.GetValue(containingObject); + if (propertyValue is not System.Collections.IList listValue) + { + throw new ArgumentException( + $"Tried to remove row {field}, ended in a non-list ({propertyValue?.GetType()})" + ); + } + if (listValue.Count < lastGroupIndex) + { + throw new ArgumentException( + $"Tried to remove item at index {lastGroupIndex} but {field} only has {listValue.Count} elements" + ); + } + + switch (rowRemovalOption) + { + case RowRemovalOption.DeleteRow: + if (lastGroupIndex.Value < 0 || lastGroupIndex.Value >= listValue.Count) + { + // Index out of range, nothing to remove + return; + } + listValue.RemoveAt(lastGroupIndex.Value); + break; + case RowRemovalOption.SetToNull: + var genericType = listValue.GetType().GetGenericArguments().FirstOrDefault(); + var nullValue = genericType?.IsValueType == true ? Activator.CreateInstance(genericType) : null; + listValue[lastGroupIndex.Value] = nullValue; + break; + case RowRemovalOption.Ignore: + return; + } + } + else + { + // Set property to null + var nullValue = property.PropertyType.GetTypeInfo().IsValueType + ? Activator.CreateInstance(property.PropertyType) + : null; + property.SetValue(containingObject, nullValue); + } + } +} diff --git a/src/Altinn.App.Core/Helpers/ObjectUtils.cs b/src/Altinn.App.Core/Helpers/ObjectUtils.cs index de84b05773..db5f8d18aa 100644 --- a/src/Altinn.App.Core/Helpers/ObjectUtils.cs +++ b/src/Altinn.App.Core/Helpers/ObjectUtils.cs @@ -33,7 +33,7 @@ public static void InitializeAltinnRowId(object model, int depth = 64) foreach (var prop in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) { - if (PropertyIsAltinRowGuid(prop)) + if (PropertyIsAltinnRowGuid(prop)) { var value = (Guid)(prop.GetValue(model) ?? Guid.Empty); if (value == Guid.Empty) @@ -222,7 +222,7 @@ public static bool RemoveAltinnRowId(object model, int depth = 64) foreach (var prop in type.GetProperties()) { // Handle guid fields named "AltinnRowId" - if (PropertyIsAltinRowGuid(prop)) + if (PropertyIsAltinnRowGuid(prop)) { isModified = true; prop.SetValue(model, Guid.Empty); @@ -259,7 +259,7 @@ public static bool RemoveAltinnRowId(object model, int depth = 64) return isModified; } - private static bool PropertyIsAltinRowGuid(PropertyInfo prop) + private static bool PropertyIsAltinnRowGuid(PropertyInfo prop) { return prop.PropertyType == typeof(Guid) && prop.Name == "AltinnRowId"; } diff --git a/test/Altinn.App.Analyzers.Tests/Altinn.App.Analyzers.Tests.csproj b/test/Altinn.App.Analyzers.Tests/Altinn.App.Analyzers.Tests.csproj index e182adaf33..25eb45a153 100644 --- a/test/Altinn.App.Analyzers.Tests/Altinn.App.Analyzers.Tests.csproj +++ b/test/Altinn.App.Analyzers.Tests/Altinn.App.Analyzers.Tests.csproj @@ -28,10 +28,10 @@ - + diff --git a/test/Altinn.App.Analyzers.Tests/testapp/App/config/applicationmetadata.json b/test/Altinn.App.Analyzers.Tests/testapp/App/config/applicationmetadata.json index 64a57b790a..176011218a 100644 --- a/test/Altinn.App.Analyzers.Tests/testapp/App/config/applicationmetadata.json +++ b/test/Altinn.App.Analyzers.Tests/testapp/App/config/applicationmetadata.json @@ -1,4 +1,5 @@ { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/application/application-metadata.schema.v1.json", "id": "ttd/testapp", "org": "ttd", "title": { @@ -50,4 +51,4 @@ "createdBy": "altinn", "lastChanged": "2024-05-15T12:17:03.65623Z", "lastChangedBy": "altinn" -} \ No newline at end of file +} diff --git a/test/Altinn.App.Api.Tests/Helpers/Patch/PatchServiceTests.cs b/test/Altinn.App.Api.Tests/Helpers/Patch/PatchServiceTests.cs index f4f8672fda..e4af06a440 100644 --- a/test/Altinn.App.Api.Tests/Helpers/Patch/PatchServiceTests.cs +++ b/test/Altinn.App.Api.Tests/Helpers/Patch/PatchServiceTests.cs @@ -43,12 +43,12 @@ public sealed class PatchServiceTests : IDisposable // Service mocks private readonly Mock> _vLoggerMock = new(MockBehavior.Loose); - private readonly Mock _translationService = new(MockBehavior.Strict); private readonly Mock _dataClientMock = new(MockBehavior.Strict); private readonly Mock _instanceClientMock = new(MockBehavior.Strict); private readonly Mock _dataProcessorMock = new(MockBehavior.Strict); private readonly Mock _appModelMock = new(MockBehavior.Strict); private readonly Mock _appMetadataMock = new(MockBehavior.Strict); + private readonly Mock _translationServiceMock = new(MockBehavior.Strict); private readonly TelemetrySink _telemetrySink = new(); private readonly Mock _webHostEnvironment = new(MockBehavior.Strict); private readonly Mock _appResourcesMock = new(MockBehavior.Strict); @@ -122,7 +122,7 @@ public PatchServiceTests() var validatorFactory = _serviceProvider.GetRequiredService(); var validationService = new ValidationService( validatorFactory, - _translationService.Object, + _translationServiceMock.Object, _vLoggerMock.Object ); 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 1706061ba6..898382c0d5 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 @@ -1201,6 +1201,14 @@ namespace Altinn.App.Core.Features { public const string JsonObjectInDataResponse = "JsonObjectInDataResponse"; } + public static class FormDataWrapperExtensions + { + public static string? AddIndexToPath(this Altinn.App.Core.Features.IFormDataWrapper formDataWrapper, System.ReadOnlySpan path, System.ReadOnlySpan rowIndexes) { } + public static object? Get(this Altinn.App.Core.Features.IFormDataWrapper formDataWrapper, System.ReadOnlySpan path, System.ReadOnlySpan rowIndexes = default) { } + public static T? Get(this Altinn.App.Core.Features.IFormDataWrapper formDataWrapper, System.ReadOnlySpan path = default, System.ReadOnlySpan rowIndexes = default) { } + public static Altinn.App.Core.Models.Layout.DataReference[] GetResolvedKeys(this Altinn.App.Core.Features.IFormDataWrapper formDataWrapper, Altinn.App.Core.Models.Layout.DataReference reference) { } + public static int? GetRowCount(this Altinn.App.Core.Features.IFormDataWrapper formDataWrapper, System.ReadOnlySpan path, System.ReadOnlySpan rowIndexes) { } + } public interface IAppOptionsProvider { string Id { get; } @@ -1244,6 +1252,21 @@ namespace Altinn.App.Core.Features bool HasRelevantChanges(object current, object previous); System.Threading.Tasks.Task> ValidateFormData(Altinn.Platform.Storage.Interface.Models.Instance instance, Altinn.Platform.Storage.Interface.Models.DataElement dataElement, object data, string? language); } + public interface IFormDataWrapper + { + System.Type BackingDataType { get; } + System.ReadOnlySpan AddIndexToPath(System.ReadOnlySpan path, System.ReadOnlySpan rowIndexes, System.Span buffer); + T BackingData() + where T : class; + Altinn.App.Core.Features.IFormDataWrapper Copy(); + object? Get(System.ReadOnlySpan path); + void InitializeAltinnRowIds(); + void PrepareModelForXmlStorage(); + void RemoveAltinnRowIds(); + void RemoveField(System.ReadOnlySpan path, Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption); + } + public interface IFormDataWrapper : Altinn.App.Core.Features.IFormDataWrapper + where T : class, new () { } public interface IInstanceAppOptionsProvider { string Id { get; } @@ -2193,6 +2216,20 @@ namespace Altinn.App.Core.Helpers.DataModel public string[] GetResolvedKeys(string field) { } public void RemoveField(string field, Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption) { } } + public class ReflectionFormDataWrapper : Altinn.App.Core.Features.IFormDataWrapper + { + public ReflectionFormDataWrapper(object dataModel) { } + public System.Type BackingDataType { get; } + public System.ReadOnlySpan AddIndexToPath(System.ReadOnlySpan path, System.ReadOnlySpan rowIndexes, System.Span buffer) { } + public T BackingData() + where T : class { } + public Altinn.App.Core.Features.IFormDataWrapper Copy() { } + public object? Get(System.ReadOnlySpan path) { } + public void InitializeAltinnRowIds() { } + public void PrepareModelForXmlStorage() { } + public void RemoveAltinnRowIds() { } + public void RemoveField(System.ReadOnlySpan path, Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption) { } + } } namespace Altinn.App.Core.Helpers.Extensions { diff --git a/test/Altinn.App.SourceGenerator.Integration.Tests/Altinn.App.SourceGenerator.Integration.Tests.csproj b/test/Altinn.App.SourceGenerator.Integration.Tests/Altinn.App.SourceGenerator.Integration.Tests.csproj new file mode 100644 index 0000000000..3796e2b28e --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Integration.Tests/Altinn.App.SourceGenerator.Integration.Tests.csproj @@ -0,0 +1,38 @@ + + + net8.0 + + disable + enable + false + true + true + Generated + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/test/Altinn.App.SourceGenerator.Integration.Tests/Generated/Altinn.App.Analyzers/Altinn.App.Analyzers.IncrementalGenerator.FormDataWrapperGenerator/Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper.g.cs b/test/Altinn.App.SourceGenerator.Integration.Tests/Generated/Altinn.App.Analyzers/Altinn.App.Analyzers.IncrementalGenerator.FormDataWrapperGenerator/Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper.g.cs new file mode 100644 index 0000000000..0056797b8c --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Integration.Tests/Generated/Altinn.App.Analyzers/Altinn.App.Analyzers.IncrementalGenerator.FormDataWrapperGenerator/Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper.g.cs @@ -0,0 +1,961 @@ +// +#nullable enable + +[global::System.CodeDom.Compiler.GeneratedCode("Altinn.App.Analyzers", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +public sealed class Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper + : global::Altinn.App.Core.Features.IFormDataWrapper +{ + private readonly global::Altinn.App.SourceGenerator.Integration.Tests.Models.Skjema _dataModel; + + public global::System.Type BackingDataType => typeof(global::Altinn.App.SourceGenerator.Integration.Tests.Models.Skjema); + + public T BackingData() + where T : class + { + return _dataModel as T + ?? throw new global::System.InvalidCastException( + $"Attempted to cast data model of type Altinn.App.SourceGenerator.Integration.Tests.Models.Skjema to {typeof(T).FullName}" + ); + } + + public Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper(object dataModel) + { + _dataModel = + dataModel as global::Altinn.App.SourceGenerator.Integration.Tests.Models.Skjema + ?? throw new global::System.ArgumentException( + $"Data model must be of type Altinn.App.SourceGenerator.Integration.Tests.Models.Skjema, (was {dataModel?.GetType().FullName ?? "null"})" + ); + } + + #region Getters + + /// + public object? Get(global::System.ReadOnlySpan path) + { + if (path.IsEmpty) + { + return null; + } + + return GetRecursive(_dataModel, path, 0); + } + + private static object? GetRecursive( + global::Altinn.App.SourceGenerator.Integration.Tests.Models.Skjema? model, + global::System.ReadOnlySpan path, + int offset + ) + { + if(model is null) + { + return null; + } + + return ParseSegment(path, offset, out int nextOffset, out int literalIndex) switch + { + "skjemanummer" when nextOffset is -1 && literalIndex is -1 => model.Skjemanummer, + "skjemaversjon" when nextOffset is -1 && literalIndex is -1 => model.Skjemaversjon, + "skjemainnhold" when nextOffset > -1 && literalIndex > -1 => GetRecursive(model.Skjemainnhold, path, literalIndex, nextOffset), + "eierAdresse" when nextOffset > -1 && literalIndex is -1 => GetRecursive(model.EierAdresse, path, nextOffset), + "" => model, + // _ => throw new global::System.ArgumentException("{path} is not a valid path."), + _ => null, + }; + } + + private static object? GetRecursive( + global::System.Collections.Generic.List? model, + global::System.ReadOnlySpan path, + int literalIndex, + int offset + ) + { + if (model is null || literalIndex < 0 || literalIndex >= model.Count) + { + return null; + } + + return GetRecursive(model[literalIndex], path, offset); + } + + private static object? GetRecursive( + global::Altinn.App.SourceGenerator.Integration.Tests.Models.SkjemaInnhold? model, + global::System.ReadOnlySpan path, + int offset + ) + { + if(model is null) + { + return null; + } + + return ParseSegment(path, offset, out int nextOffset, out int literalIndex) switch + { + "altinnRowId" when nextOffset is -1 && literalIndex is -1 => model.AltinnRowId, + "navn" when nextOffset is -1 && literalIndex is -1 => model.Navn, + "alder" when nextOffset is -1 && literalIndex is -1 => model.Alder, + "deltar" when nextOffset is -1 && literalIndex is -1 => model.Deltar, + "adresse" when nextOffset > -1 && literalIndex is -1 => GetRecursive(model.Adresse, path, nextOffset), + "tidligere-adresse" when nextOffset > -1 && literalIndex > -1 => GetRecursive(model.TidligereAdresse, path, literalIndex, nextOffset), + "" => model, + // _ => throw new global::System.ArgumentException("{path} is not a valid path."), + _ => null, + }; + } + + private static object? GetRecursive( + global::Altinn.App.SourceGenerator.Integration.Tests.Models.Adresse? model, + global::System.ReadOnlySpan path, + int offset + ) + { + if(model is null) + { + return null; + } + + return ParseSegment(path, offset, out int nextOffset, out int literalIndex) switch + { + "altinnRowId" when nextOffset is -1 && literalIndex is -1 => model.AltinnRowId, + "gate" when nextOffset is -1 && literalIndex is -1 => model.Gate, + "postnummer" when nextOffset is -1 && literalIndex is -1 => model.Postnummer, + "poststed" when nextOffset is -1 && literalIndex is -1 => model.Poststed, + "" => model, + // _ => throw new global::System.ArgumentException("{path} is not a valid path."), + _ => null, + }; + } + + private static object? GetRecursive( + global::System.Collections.Generic.List? model, + global::System.ReadOnlySpan path, + int literalIndex, + int offset + ) + { + if (model is null || literalIndex < 0 || literalIndex >= model.Count) + { + return null; + } + + return GetRecursive(model[literalIndex], path, offset); + } + + #endregion Getters + #region AddIndexToPath + + /// + public global::System.ReadOnlySpan AddIndexToPath(global::System.ReadOnlySpan path, global::System.ReadOnlySpan rowIndexes, global::System.Span buffer) + { + if(path.IsEmpty) + { + return global::System.ReadOnlySpan.Empty; + } + + var bufferOffset = 0; + var pathOffset = 0; + + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Integration_Tests_Models_Skjema( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + + return buffer[..bufferOffset]; + } + + private void AddIndexToPathRecursive_Altinn_App_SourceGenerator_Integration_Tests_Models_Skjema( + global::System.ReadOnlySpan path, + int pathOffset, + global::System.ReadOnlySpan rowIndexes, + global::System.Span buffer, + ref int bufferOffset + ) + { + if (bufferOffset > 0) + { + buffer[bufferOffset++] = '.'; + } + var segment = ParseSegment(path, pathOffset, out pathOffset, out int literalIndex); + switch (segment) + { + case "skjemainnhold": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 13; + + if (literalIndex != -1) + { + // Copy index from path to buffer + buffer[bufferOffset++] = '['; + if (!literalIndex.TryFormat(buffer[bufferOffset..], out int charsWritten)) + { + throw new global::System.FormatException($"Invalid index in {path}."); + } + + bufferOffset += charsWritten; + buffer[bufferOffset++] = ']'; + rowIndexes = default; + } + else if (rowIndexes.Length >= 1) + { + // Write index from rowIndexes to buffer + buffer[bufferOffset++] = '['; + rowIndexes[0].TryFormat(buffer.Slice(bufferOffset), out int charsWritten); + bufferOffset += charsWritten; + buffer[bufferOffset++] = ']'; + rowIndexes = rowIndexes.Slice(1); + } + else if (pathOffset == -1) + { + // No more segments in the path, and the last part is valid in a list + // without index (e.g. "model.listProperty" is valid, but "model.listProperty.val" needs an index) + return; + } + else + { + // No index to write, but there are more segments in the path + // thus the path is not valid + bufferOffset = 0; + return; + } + if (pathOffset != -1) + { + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaInnhold( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + } + return; + case "eierAdresse": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 11; + if (pathOffset != -1) + { + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Integration_Tests_Models_Adresse( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + } + return; + case "skjemanummer": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 12; + return; + case "skjemaversjon": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 13; + return; + default: + bufferOffset = 0; + return; + } + } + + private void AddIndexToPathRecursive_Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaInnhold( + global::System.ReadOnlySpan path, + int pathOffset, + global::System.ReadOnlySpan rowIndexes, + global::System.Span buffer, + ref int bufferOffset + ) + { + if (bufferOffset > 0) + { + buffer[bufferOffset++] = '.'; + } + var segment = ParseSegment(path, pathOffset, out pathOffset, out int literalIndex); + switch (segment) + { + case "adresse": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 7; + if (pathOffset != -1) + { + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Integration_Tests_Models_Adresse( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + } + return; + case "tidligere-adresse": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 17; + + if (literalIndex != -1) + { + // Copy index from path to buffer + buffer[bufferOffset++] = '['; + if (!literalIndex.TryFormat(buffer[bufferOffset..], out int charsWritten)) + { + throw new global::System.FormatException($"Invalid index in {path}."); + } + + bufferOffset += charsWritten; + buffer[bufferOffset++] = ']'; + rowIndexes = default; + } + else if (rowIndexes.Length >= 1) + { + // Write index from rowIndexes to buffer + buffer[bufferOffset++] = '['; + rowIndexes[0].TryFormat(buffer.Slice(bufferOffset), out int charsWritten); + bufferOffset += charsWritten; + buffer[bufferOffset++] = ']'; + rowIndexes = rowIndexes.Slice(1); + } + else if (pathOffset == -1) + { + // No more segments in the path, and the last part is valid in a list + // without index (e.g. "model.listProperty" is valid, but "model.listProperty.val" needs an index) + return; + } + else + { + // No index to write, but there are more segments in the path + // thus the path is not valid + bufferOffset = 0; + return; + } + if (pathOffset != -1) + { + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Integration_Tests_Models_Adresse( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + } + return; + case "altinnRowId": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 11; + return; + case "navn": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 4; + return; + case "alder": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 5; + return; + case "deltar": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 6; + return; + default: + bufferOffset = 0; + return; + } + } + + private void AddIndexToPathRecursive_Altinn_App_SourceGenerator_Integration_Tests_Models_Adresse( + global::System.ReadOnlySpan path, + int pathOffset, + global::System.ReadOnlySpan rowIndexes, + global::System.Span buffer, + ref int bufferOffset + ) + { + if (bufferOffset > 0) + { + buffer[bufferOffset++] = '.'; + } + var segment = ParseSegment(path, pathOffset, out pathOffset, out int literalIndex); + switch (segment) + { + case "altinnRowId": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 11; + return; + case "gate": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 4; + return; + case "postnummer": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 10; + return; + case "poststed": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 8; + return; + default: + bufferOffset = 0; + return; + } + } + + #endregion AddIndexToPath + #region Copy + + /// + public global::Altinn.App.Core.Features.IFormDataWrapper Copy() + { + return new Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper(CopyRecursive(_dataModel)); + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("data")] + private static global::Altinn.App.SourceGenerator.Integration.Tests.Models.Skjema? CopyRecursive( + global::Altinn.App.SourceGenerator.Integration.Tests.Models.Skjema? data + ) + { + if (data is null) + { + return null; + } + + return new() + { + Skjemanummer = data.Skjemanummer, + Skjemaversjon = data.Skjemaversjon, + Skjemainnhold = CopyRecursive(data.Skjemainnhold), + EierAdresse = CopyRecursive(data.EierAdresse), + }; + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("list")] + private static global::System.Collections.Generic.List? CopyRecursive( + global::System.Collections.Generic.List? list + ) + { + if (list is null) + { + return null; + } + + global::System.Collections.Generic.List result = new(list.Count); + foreach (var item in list) + { + result.Add(CopyRecursive(item)); + } + + return result; + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("data")] + private static global::Altinn.App.SourceGenerator.Integration.Tests.Models.SkjemaInnhold? CopyRecursive( + global::Altinn.App.SourceGenerator.Integration.Tests.Models.SkjemaInnhold? data + ) + { + if (data is null) + { + return null; + } + + return new() + { + AltinnRowId = data.AltinnRowId, + Navn = data.Navn, + Alder = data.Alder, + Deltar = data.Deltar, + Adresse = CopyRecursive(data.Adresse), + TidligereAdresse = CopyRecursive(data.TidligereAdresse), + }; + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("data")] + private static global::Altinn.App.SourceGenerator.Integration.Tests.Models.Adresse? CopyRecursive( + global::Altinn.App.SourceGenerator.Integration.Tests.Models.Adresse? data + ) + { + if (data is null) + { + return null; + } + + return new() + { + AltinnRowId = data.AltinnRowId, + Gate = data.Gate, + Postnummer = data.Postnummer, + Poststed = data.Poststed, + }; + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("list")] + private static global::System.Collections.Generic.List? CopyRecursive( + global::System.Collections.Generic.List? list + ) + { + if (list is null) + { + return null; + } + + global::System.Collections.Generic.List result = new(list.Count); + foreach (var item in list) + { + result.Add(CopyRecursive(item)); + } + + return result; + } + + #endregion Copy + #region Remove + + /// + public void RemoveField(global::System.ReadOnlySpan path, global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption) + { + if (path.IsEmpty) + { + return; + } + + RemoveRecursive(_dataModel, path, 0, rowRemovalOption); + } + + private static void RemoveRecursive( + global::Altinn.App.SourceGenerator.Integration.Tests.Models.Skjema? model, + global::System.ReadOnlySpan path, + int offset, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + switch (ParseSegment(path, offset, out int nextOffset, out int literalIndex)) + { + case "skjemanummer" when (nextOffset is -1) && (literalIndex is -1): + model.Skjemanummer = default; + break; + case "skjemaversjon" when (nextOffset is -1) && (literalIndex is -1): + model.Skjemaversjon = default; + break; + case "skjemainnhold" when (nextOffset is -1) && (literalIndex is -1): + model.Skjemainnhold = default; + break; + case "skjemainnhold": + RemoveRecursive(model.Skjemainnhold, path, nextOffset, literalIndex, rowRemovalOption); + break; + case "eierAdresse" when (nextOffset is -1) && (literalIndex is -1): + model.EierAdresse = default; + break; + case "eierAdresse": + RemoveRecursive(model.EierAdresse, path, nextOffset, rowRemovalOption); + break; + default: + // throw new ArgumentException("{path} is not a valid path."); + return; + } + } + + private static void RemoveRecursive( + global::System.Collections.Generic.List? model, + global::System.ReadOnlySpan path, + int offset, + int index, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + if (index < 0 || index >= model.Count) + { + return; + } + if (offset == -1) + { + switch (rowRemovalOption) + { + case global::Altinn.App.Core.Helpers.RowRemovalOption.DeleteRow: + model.RemoveAt(index); + break; + case global::Altinn.App.Core.Helpers.RowRemovalOption.SetToNull: + model[index] = default!; + break; + } + } + else + { + RemoveRecursive(model?[index], path, offset, rowRemovalOption); + } + } + + private static void RemoveRecursive( + global::Altinn.App.SourceGenerator.Integration.Tests.Models.SkjemaInnhold? model, + global::System.ReadOnlySpan path, + int offset, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + switch (ParseSegment(path, offset, out int nextOffset, out int literalIndex)) + { + case "navn" when (nextOffset is -1) && (literalIndex is -1): + model.Navn = default; + break; + case "alder" when (nextOffset is -1) && (literalIndex is -1): + model.Alder = default; + break; + case "deltar" when (nextOffset is -1) && (literalIndex is -1): + model.Deltar = default; + break; + case "adresse" when (nextOffset is -1) && (literalIndex is -1): + model.Adresse = default; + break; + case "adresse": + RemoveRecursive(model.Adresse, path, nextOffset, rowRemovalOption); + break; + case "tidligere-adresse" when (nextOffset is -1) && (literalIndex is -1): + model.TidligereAdresse = default; + break; + case "tidligere-adresse": + RemoveRecursive(model.TidligereAdresse, path, nextOffset, literalIndex, rowRemovalOption); + break; + default: + // throw new ArgumentException("{path} is not a valid path."); + return; + } + } + + private static void RemoveRecursive( + global::Altinn.App.SourceGenerator.Integration.Tests.Models.Adresse? model, + global::System.ReadOnlySpan path, + int offset, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + switch (ParseSegment(path, offset, out int nextOffset, out int literalIndex)) + { + case "gate" when (nextOffset is -1) && (literalIndex is -1): + model.Gate = default; + break; + case "postnummer" when (nextOffset is -1) && (literalIndex is -1): + model.Postnummer = default; + break; + case "poststed" when (nextOffset is -1) && (literalIndex is -1): + model.Poststed = default; + break; + default: + // throw new ArgumentException("{path} is not a valid path."); + return; + } + } + + private static void RemoveRecursive( + global::System.Collections.Generic.List? model, + global::System.ReadOnlySpan path, + int offset, + int index, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + if (index < 0 || index >= model.Count) + { + return; + } + if (offset == -1) + { + switch (rowRemovalOption) + { + case global::Altinn.App.Core.Helpers.RowRemovalOption.DeleteRow: + model.RemoveAt(index); + break; + case global::Altinn.App.Core.Helpers.RowRemovalOption.SetToNull: + model[index] = default!; + break; + } + } + else + { + RemoveRecursive(model?[index], path, offset, rowRemovalOption); + } + } + + #endregion Remove + #region AltinnRowIds + + /// + public void RemoveAltinnRowIds() + { + SetAltinnRowIds(_dataModel, initialize: false); + } + + /// + public void InitializeAltinnRowIds() + { + SetAltinnRowIds(_dataModel, initialize: true); + } + + private static void SetAltinnRowIds(global::Altinn.App.SourceGenerator.Integration.Tests.Models.Skjema dataModel, bool initialize) + { + if(dataModel.Skjemainnhold is not null) + { + foreach (var item in dataModel.Skjemainnhold) + { + if (item is not null) + { + SetAltinnRowIds(item, initialize); + } + } + } + if(dataModel.EierAdresse is not null) + { + SetAltinnRowIds(dataModel.EierAdresse, initialize); + } + } + + private static void SetAltinnRowIds(global::Altinn.App.SourceGenerator.Integration.Tests.Models.SkjemaInnhold dataModel, bool initialize) + { + if (!initialize) + { + dataModel.AltinnRowId = global::System.Guid.Empty; + } + else if (dataModel.AltinnRowId == global::System.Guid.Empty) + { + dataModel.AltinnRowId = global::System.Guid.NewGuid(); + } + if(dataModel.Adresse is not null) + { + SetAltinnRowIds(dataModel.Adresse, initialize); + } + if(dataModel.TidligereAdresse is not null) + { + foreach (var item in dataModel.TidligereAdresse) + { + if (item is not null) + { + SetAltinnRowIds(item, initialize); + } + } + } + } + + private static void SetAltinnRowIds(global::Altinn.App.SourceGenerator.Integration.Tests.Models.Adresse dataModel, bool initialize) + { + if (!initialize) + { + dataModel.AltinnRowId = global::System.Guid.Empty; + } + else if (dataModel.AltinnRowId == global::System.Guid.Empty) + { + dataModel.AltinnRowId = global::System.Guid.NewGuid(); + } + } + + #endregion AltinnRowIds + #region XmlStorage + + /// + public void PrepareModelForXmlStorage() + { + global::Altinn.App.Core.Helpers.ObjectUtils.PrepareModelForXmlStorage(_dataModel); + } + + #endregion XmlStorage + public static global::System.ReadOnlySpan ParseSegment(global::System.ReadOnlySpan path, int offset, out int nextOffset, out int literalIndex) + { + if (offset < 0 || offset >= path.Length) + { + throw new global::System.ArgumentOutOfRangeException(nameof(offset)); + } + + for (int i = offset; i < path.Length; i++) + { + switch (path[i]) + { + case '.': + nextOffset = i + 1; + literalIndex = -1; + return path[offset..i]; + case '[': + literalIndex = GetIndex(path, i + 1, out nextOffset); + return path[offset..i]; + } + } + + // No markers found. Just return the rest of the path. + nextOffset = -1; + literalIndex = -1; + return path[offset..]; + } + + private static int GetIndex(global::System.ReadOnlySpan path, int offset, out int nextOffset) + { + var segment = path[offset..]; + var bracketOffset = global::System.MemoryExtensions.IndexOf(segment, ']'); + if (bracketOffset < 0) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Missing closing bracket ']' in {path}."); + } + + if (!int.TryParse(segment[..bracketOffset], out var index)) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Invalid index in {path}."); + } + + if (index < 0) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Invalid negative index in {path}."); + } + + nextOffset = offset + bracketOffset + 2; + if (nextOffset >= path.Length) + { + nextOffset = -1; + } + + return index; + } +} + +// -------------------------------------------------- +// ModelPathNode as json (for debugging) +// -------------------------------------------------- +// +// { +// "JsonName": "", +// "CSharpName": "", +// "TypeName": "global::Altinn.App.SourceGenerator.Integration.Tests.Models.Skjema", +// "Properties": [ +// { +// "JsonName": "skjemanummer", +// "CSharpName": "Skjemanummer", +// "TypeName": "string", +// }, +// { +// "JsonName": "skjemaversjon", +// "CSharpName": "Skjemaversjon", +// "TypeName": "string", +// }, +// { +// "JsonName": "skjemainnhold", +// "CSharpName": "Skjemainnhold", +// "TypeName": "global::Altinn.App.SourceGenerator.Integration.Tests.Models.SkjemaInnhold", +// "ListType": "global::System.Collections.Generic.List", +// "Properties": [ +// { +// "JsonName": "altinnRowId", +// "CSharpName": "AltinnRowId", +// "TypeName": "global::System.Guid", +// }, +// { +// "JsonName": "navn", +// "CSharpName": "Navn", +// "TypeName": "string", +// }, +// { +// "JsonName": "alder", +// "CSharpName": "Alder", +// "TypeName": "int", +// }, +// { +// "JsonName": "deltar", +// "CSharpName": "Deltar", +// "TypeName": "bool", +// }, +// { +// "JsonName": "adresse", +// "CSharpName": "Adresse", +// "TypeName": "global::Altinn.App.SourceGenerator.Integration.Tests.Models.Adresse", +// "Properties": [ +// { +// "JsonName": "altinnRowId", +// "CSharpName": "AltinnRowId", +// "TypeName": "global::System.Guid", +// }, +// { +// "JsonName": "gate", +// "CSharpName": "Gate", +// "TypeName": "string", +// }, +// { +// "JsonName": "postnummer", +// "CSharpName": "Postnummer", +// "TypeName": "int", +// }, +// { +// "JsonName": "poststed", +// "CSharpName": "Poststed", +// "TypeName": "string", +// } +// ] +// }, +// { +// "JsonName": "tidligere-adresse", +// "CSharpName": "TidligereAdresse", +// "TypeName": "global::Altinn.App.SourceGenerator.Integration.Tests.Models.Adresse", +// "ListType": "global::System.Collections.Generic.List", +// "Properties": [ +// { +// "JsonName": "altinnRowId", +// "CSharpName": "AltinnRowId", +// "TypeName": "global::System.Guid", +// }, +// { +// "JsonName": "gate", +// "CSharpName": "Gate", +// "TypeName": "string", +// }, +// { +// "JsonName": "postnummer", +// "CSharpName": "Postnummer", +// "TypeName": "int", +// }, +// { +// "JsonName": "poststed", +// "CSharpName": "Poststed", +// "TypeName": "string", +// } +// ] +// } +// ] +// }, +// { +// "JsonName": "eierAdresse", +// "CSharpName": "EierAdresse", +// "TypeName": "global::Altinn.App.SourceGenerator.Integration.Tests.Models.Adresse", +// "Properties": [ +// { +// "JsonName": "altinnRowId", +// "CSharpName": "AltinnRowId", +// "TypeName": "global::System.Guid", +// }, +// { +// "JsonName": "gate", +// "CSharpName": "Gate", +// "TypeName": "string", +// }, +// { +// "JsonName": "postnummer", +// "CSharpName": "Postnummer", +// "TypeName": "int", +// }, +// { +// "JsonName": "poststed", +// "CSharpName": "Poststed", +// "TypeName": "string", +// } +// ] +// } +// ] +// } diff --git a/test/Altinn.App.SourceGenerator.Integration.Tests/Models/Skjema.cs b/test/Altinn.App.SourceGenerator.Integration.Tests/Models/Skjema.cs new file mode 100644 index 0000000000..75d71253dd --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Integration.Tests/Models/Skjema.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Altinn.App.SourceGenerator.Integration.Tests.Models; + +public class Skjema +{ + [JsonPropertyName("skjemanummer")] + public string? Skjemanummer { get; set; } + + [JsonPropertyName("skjemaversjon")] + public string? Skjemaversjon { get; set; } + + [JsonPropertyName("skjemainnhold")] + public List? Skjemainnhold { get; set; } + + [JsonPropertyName("eierAdresse")] + public Adresse? EierAdresse { get; set; } +} + +public class SkjemaInnhold +{ + [JsonPropertyName("altinnRowId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Guid AltinnRowId { get; set; } + + [JsonPropertyName("navn")] + public string? Navn { get; set; } + + [JsonPropertyName("alder")] + public int? Alder { get; set; } + + [JsonPropertyName("deltar")] + public bool? Deltar { get; set; } + + [JsonPropertyName("adresse")] + public Adresse? Adresse { get; set; } + + [JsonPropertyName("tidligere-adresse")] + public List? TidligereAdresse { get; set; } +} + +public class Adresse +{ + [JsonPropertyName("altinnRowId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Guid AltinnRowId { get; set; } + + [JsonPropertyName("gate")] + public string? Gate { get; set; } + + [JsonPropertyName("postnummer")] + public int? Postnummer { get; set; } + + [JsonPropertyName("poststed")] + public string? Poststed { get; set; } +} + +public class Empty { } diff --git a/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestAddIndexToPath.cs b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestAddIndexToPath.cs new file mode 100644 index 0000000000..a8eae8dc98 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestAddIndexToPath.cs @@ -0,0 +1,54 @@ +using System; +using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.SourceGenerator.Integration.Tests.Models; +using Xunit; + +namespace Altinn.App.SourceGenerator.Integration.Tests.UnitTest; + +public class TestAddIndexToPath +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Run(bool reflection) + { + var data = new Skjema(); + + IFormDataWrapper wrapper = reflection + ? new ReflectionFormDataWrapper(data) + : new Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper(data); + Assert.Equal("skjemainnhold[214]", wrapper.AddIndexToPath("skjemainnhold", [214])); + Assert.Equal("skjemainnhold[214].navn", wrapper.AddIndexToPath("skjemainnhold.navn", [214, 33])); + Assert.Equal("skjemainnhold[214].alder", wrapper.AddIndexToPath("skjemainnhold.alder", [214])); + Assert.Null(wrapper.AddIndexToPath("skjemainnhold.finnes-ikke", [214, 1])); + Assert.Equal( + "skjemainnhold[2147483647].adresse.gate", + wrapper.AddIndexToPath("skjemainnhold.adresse.gate", [int.MaxValue]) + ); + Assert.Equal( + "skjemainnhold[0].tidligere-adresse[4].gate", + wrapper.AddIndexToPath("skjemainnhold.tidligere-adresse.gate", [0, 4]) + ); + Assert.Equal( + "skjemainnhold[0].tidligere-adresse[4]", + wrapper.AddIndexToPath("skjemainnhold.tidligere-adresse", [0, 4]) + ); + + Assert.Equal( + "skjemainnhold[0].tidligere-adresse[6]", + wrapper.AddIndexToPath("skjemainnhold.tidligere-adresse[6]", [0, 4]) + ); + + Assert.Equal( + "skjemainnhold[6].tidligere-adresse", + wrapper.AddIndexToPath("skjemainnhold[6].tidligere-adresse", [0, 4]) + ); + Assert.Null(wrapper.AddIndexToPath("skjemainnhold[6].tidligere-adresse.gate", [0, 4])); + + Assert.Equal("skjemainnhold", wrapper.AddIndexToPath("skjemainnhold", [])); + Assert.Null(wrapper.AddIndexToPath("skjemainnhold.navn", [])); + Assert.Null(wrapper.AddIndexToPath("", [])); + Assert.Null(wrapper.AddIndexToPath(null, [])); + } +} diff --git a/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestAltinnRowIds.cs b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestAltinnRowIds.cs new file mode 100644 index 0000000000..ffe52e7aa3 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestAltinnRowIds.cs @@ -0,0 +1,84 @@ +using System; +using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.SourceGenerator.Integration.Tests.Models; +using Xunit; + +namespace Altinn.App.SourceGenerator.Integration.Tests.UnitTest; + +public class TestAltinnRowIds +{ + private readonly Skjema _skjema = new Skjema() + { + Skjemanummer = "1243", + Skjemaversjon = "x4", + Skjemainnhold = + [ + new SkjemaInnhold() + { + Navn = "navn", + Alder = 42, + Deltar = true, + TidligereAdresse = + [ + new Adresse() + { + Gate = "Gata", + Postnummer = 1245, + Poststed = "Sted", + }, + new Adresse() + { + Gate = "Gata", + Postnummer = null, + Poststed = "Sted", + }, + ], + }, + new SkjemaInnhold() + { + Navn = "navn2", + Alder = 43, + Deltar = false, + }, + ], + }; + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void TestAddAndRemoveAltinnRowId(bool reflection) + { + IFormDataWrapper dataWrapper = reflection + ? new ReflectionFormDataWrapper(_skjema) + : new Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper(_skjema); + + // Verify that every rowId is empty + Assert.Equal(Guid.Empty, _skjema.Skjemainnhold?[0]?.AltinnRowId); + Assert.Equal(Guid.Empty, _skjema.Skjemainnhold?[1]?.AltinnRowId); + Assert.Equal(Guid.Empty, _skjema.Skjemainnhold?[0]?.TidligereAdresse?[0].AltinnRowId); + Assert.Equal(Guid.Empty, _skjema.Skjemainnhold?[0]?.TidligereAdresse?[1].AltinnRowId); + // Initialize and see that they get non empty values + dataWrapper.InitializeAltinnRowIds(); + Assert.NotEqual(Guid.Empty, _skjema.Skjemainnhold?[0]?.AltinnRowId); + Assert.NotEqual(Guid.Empty, _skjema.Skjemainnhold?[1]?.AltinnRowId); + Assert.NotEqual(Guid.Empty, _skjema.Skjemainnhold?[0]?.TidligereAdresse?[0].AltinnRowId); + Assert.NotEqual(Guid.Empty, _skjema.Skjemainnhold?[0]?.TidligereAdresse?[1].AltinnRowId); + // And that guids are different + Assert.NotEqual( + _skjema.Skjemainnhold?[0]?.TidligereAdresse?[0].AltinnRowId, + _skjema.Skjemainnhold?[0]?.TidligereAdresse?[1].AltinnRowId + ); + // Initialize again to ensure that values don't change on multiple initializations + var generatedRowId = _skjema.Skjemainnhold?[1]?.AltinnRowId; + dataWrapper.InitializeAltinnRowIds(); + Assert.Equal(generatedRowId, _skjema.Skjemainnhold?[1]?.AltinnRowId); + // Check that removal works + dataWrapper.RemoveAltinnRowIds(); + Assert.Equal(Guid.Empty, _skjema.Skjemainnhold?[0]?.AltinnRowId); + Assert.Equal(Guid.Empty, _skjema.Skjemainnhold?[1]?.AltinnRowId); + Assert.Equal(Guid.Empty, _skjema.Skjemainnhold?[0]?.TidligereAdresse?[0].AltinnRowId); + Assert.Equal("navn", _skjema.Skjemainnhold?[0]?.Navn); + Assert.Equal("navn2", _skjema.Skjemainnhold?[1]?.Navn); + } +} diff --git a/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestGeneratedCopy.cs b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestGeneratedCopy.cs new file mode 100644 index 0000000000..467829d208 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestGeneratedCopy.cs @@ -0,0 +1,99 @@ +using System; +using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.SourceGenerator.Integration.Tests.Models; +using Xunit; + +namespace Altinn.App.SourceGenerator.Integration.Tests.UnitTest; + +public class TestGeneratedCopy +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CopyCreatesNewObject(bool reflection) + { + var rowIdOla = Guid.NewGuid(); + var rowIdKari = Guid.NewGuid(); + var data = new Skjema() + { + Skjemanummer = "123", + Skjemaversjon = "1", + Skjemainnhold = + [ + new() + { + AltinnRowId = rowIdOla, + Navn = "Ola", + Alder = 30, + Deltar = true, + Adresse = new() + { + Gate = "Gata", + Postnummer = 1245, + Poststed = "Sted", + }, + }, + new() + { + AltinnRowId = rowIdKari, + Navn = "Kari", + Alder = 25, + Deltar = false, + Adresse = new() + { + Gate = "Gata", + Postnummer = null, + Poststed = "Sted", + }, + TidligereAdresse = + [ + new() + { + Gate = "TidligereGata", + Postnummer = 1234, + Poststed = "TidligereSted", + }, + new() + { + Gate = "TidligereGata2", + Postnummer = 1235, + Poststed = "TidligereSted2", + }, + ], + }, + ], + }; + + IFormDataWrapper wrapper = reflection + ? new ReflectionFormDataWrapper(data) + : new Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper(data); + + var copyWrapper = wrapper.Copy(); + var copy = copyWrapper.BackingData(); + + Assert.NotSame(data, copy); + Assert.Equivalent(data, copy); + //Just to be extra sure + Assert.Equal(rowIdOla, copy.Skjemainnhold?[0]?.AltinnRowId); + Assert.Equal(rowIdKari, copy.Skjemainnhold?[1]?.AltinnRowId); + Assert.Equal(1245, copy.Skjemainnhold?[0]?.Adresse?.Postnummer); + Assert.Equal(1245, copyWrapper.Get("skjemainnhold[0].adresse.postnummer")); + Assert.Null(copy.Skjemainnhold?[1]?.Adresse?.Postnummer); + Assert.Null(copyWrapper.Get("skjemainnhold[1].adresse.postnummer")); + Assert.Equal("TidligereGata", copy.Skjemainnhold?[1]?.TidligereAdresse?[0]?.Gate); + Assert.Equal("TidligereGata", copyWrapper.Get("skjemainnhold[1].tidligere-adresse[0].gate")); + Assert.Equal("TidligereGata2", copy.Skjemainnhold?[1]?.TidligereAdresse?[1]?.Gate); + + // Modifications to the copy should not affect the original + + copy.Skjemaversjon = "Changed"; + Assert.Equal("1", data.Skjemaversjon); + + copy.Skjemainnhold![1]!.Adresse!.Postnummer = 9999; + Assert.Null(data.Skjemainnhold![1]!.Adresse!.Postnummer); + + copy.Skjemainnhold![1]!.TidligereAdresse![0]!.Gate = "CHANGED"; + Assert.Equal("TidligereGata", data.Skjemainnhold![1]!.TidligereAdresse![0]!.Gate); + } +} diff --git a/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestGeneratedGetter.cs b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestGeneratedGetter.cs new file mode 100644 index 0000000000..e993eff14d --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestGeneratedGetter.cs @@ -0,0 +1,229 @@ +using System; +using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.SourceGenerator.Integration.Tests.Models; +using Xunit; + +namespace Altinn.App.SourceGenerator.Integration.Tests.UnitTest; + +public class TestGeneratedGetter +{ + /// + /// Create a shared instance of Skjema for testing. + /// + private readonly Skjema _skjema = new Skjema() + { + Skjemanummer = "1243", + Skjemaversjon = "x4", + Skjemainnhold = + [ + new SkjemaInnhold() + { + Navn = "navn", + Alder = 42, + Deltar = true, + }, + new SkjemaInnhold() + { + Navn = "navn2", + Alder = 43, + Deltar = false, + Adresse = new() { Gate = "gate", Postnummer = 1234 }, + TidligereAdresse = + [ + new() { Gate = "gate1", Postnummer = 1235 }, + new() { Gate = "gate2", Postnummer = 1236 }, + ], + }, + ], + }; + + [Theory] + [InlineData("skjemanummer", "1243")] + [InlineData("skjemaversjon", "x4")] + [InlineData("skjemainnhold[0].altinnRowId", "00000000-0000-0000-0000-000000000000")] + [InlineData("skjemainnhold[0].navn", "navn")] + [InlineData("skjemainnhold[0].alder", 42)] + [InlineData("skjemainnhold[0].deltar", true)] + [InlineData("skjemainnhold[1].altinnRowId", "00000000-0000-0000-0000-000000000000")] + [InlineData("skjemainnhold[1].navn", "navn2")] + [InlineData("skjemainnhold[1].alder", 43)] + [InlineData("skjemainnhold[1].deltar", false)] + [InlineData("skjemainnhold[1].adresse.gate", "gate")] + [InlineData("skjemainnhold[1].adresse.postnummer", 1234)] + [InlineData("skjemainnhold[1].tidligere-adresse[1].postnummer", 1236)] + public void TestGetRaw(string path, object expected) + { + IFormDataWrapper dataWrapper = new Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper( + _skjema + ); + var actual = dataWrapper.Get(path); + if (actual is Guid guid && expected is string stringGuid) + { + Assert.Equal(Guid.Parse(stringGuid), guid); + } + else + { + Assert.Equal(expected, actual); + } + + var reflector = new ReflectionFormDataWrapper(_skjema); + var reflectorActual = reflector.Get(path); + if (reflectorActual is Guid rGuid && expected is string rstringGuid) + { + Assert.Equal(Guid.Parse(rstringGuid), rGuid); + } + else + { + Assert.Equal(expected, reflectorActual); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("skjemanummer.not-exists")] + [InlineData("skjemanummer[4].not-exists")] + [InlineData("not-exists")] + [InlineData("skjemainnhold[2]")] + [InlineData("skjemainnhold[4]")] + [InlineData("skjemainnhold[2].navn")] + [InlineData("skjemainnhold[0].not-exists")] + [InlineData("skjemainnhold[0].navn.not-exists")] + [InlineData("skjemainnhold[1].tidligere-adresse[99].postnummer")] + [InlineData("skjemainnhold[0].adresse.gate")] + [InlineData("skjemainnhold[1].adresse[0]")] + [InlineData("skjemainnhold[1].")] + public void TestGetRawErrorReturnNull(string? path) + { + // These might all throw exceptions when we have better validation of data model bindings at startup + IFormDataWrapper dataWrapper = new Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper( + _skjema + ); + Assert.Null(dataWrapper.Get(path)); + + var reflector = new ReflectionFormDataWrapper(_skjema); + Assert.Null(reflector.Get(path)); + } + + [Theory] + [InlineData("skjemainnhold[-1]")] + [InlineData("skjemainnhold[a]")] + [InlineData("skjemainnhold[1].tidligere-adresse[-1]")] + public void TestGetRawErrorReturnException(string? path) + { + // These might all throw exceptions when we have better validation of data model bindings at startup + IFormDataWrapper dataWrapper = new Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper( + _skjema + ); + Assert.Throws(() => dataWrapper.Get(path)); + + var reflector = new ReflectionFormDataWrapper(_skjema); + Assert.Throws(() => reflector.Get(path)); + } + + [Fact] + public void TestPathHelper() + { + var path = "skjemainnhold.navn"; + var segment = Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper.ParseSegment( + path, + 0, + out int nextOffset, + out int literalIndex + ); + Assert.Equal("skjemainnhold", segment); + Assert.Equal(14, nextOffset); + Assert.Equal(-1, literalIndex); + segment = Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper.ParseSegment( + path, + nextOffset, + out nextOffset, + out literalIndex + ); + Assert.Equal("navn", segment); + Assert.Equal(-1, nextOffset); + Assert.Equal(-1, literalIndex); + Assert.Throws(() => + Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper.ParseSegment( + path, + nextOffset, + out nextOffset, + out literalIndex + ) + ); + } + + [Fact] + public void TestAltinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapperRecursive() + { + var path = "a.b[3].c"; + var segment = Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper.ParseSegment( + path, + 0, + out int nextOffset, + out int literalIndex + ); + Assert.Equal("a", segment); + Assert.Equal(2, nextOffset); + Assert.Equal(-1, literalIndex); + segment = Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper.ParseSegment( + path, + nextOffset, + out nextOffset, + out literalIndex + ); + Assert.Equal("b", segment); + Assert.Equal(7, nextOffset); + Assert.Equal(3, literalIndex); + + segment = Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper.ParseSegment( + path, + nextOffset, + out nextOffset, + out literalIndex + ); + Assert.Equal("c", segment); + Assert.Equal(-1, nextOffset); + Assert.Equal(-1, literalIndex); + + Assert.Throws(() => + Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper.ParseSegment( + path, + nextOffset, + out nextOffset, + out literalIndex + ) + ); + } + + [Fact] + public void TestParseSegment_HyphenWithIndex() + { + var path = "tidligere-adresse[1]"; + var segment = Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper.ParseSegment( + path, + 0, + out int nextOffset, + out int literalIndex + ); + Assert.Equal("tidligere-adresse", segment); + Assert.Equal(-1, nextOffset); + Assert.Equal(1, literalIndex); + } + + [Fact] + public void TestParseSegment_MultiDigitIndex() + { + var path = "x[12344567]"; + var segment = Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper.ParseSegment( + path, + 0, + out int nextOffset, + out int literalIndex + ); + Assert.Equal("x", segment); + Assert.Equal(-1, nextOffset); + Assert.Equal(12344567, literalIndex); + } +} diff --git a/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestGeneratedRemoval.cs b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestGeneratedRemoval.cs new file mode 100644 index 0000000000..9d3fe738a6 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Integration.Tests/UnitTest/TestGeneratedRemoval.cs @@ -0,0 +1,89 @@ +using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.SourceGenerator.Integration.Tests.Models; +using Xunit; + +namespace Altinn.App.SourceGenerator.Integration.Tests.UnitTest; + +public class TestGeneratedRemoval +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void TestRemove(bool reflection) + { + var data = new Skjema() + { + Skjemaversjon = "1.0", + Skjemanummer = "123456", + Skjemainnhold = + [ + new() + { + Navn = "Ola", + Alder = 30, + Deltar = true, + Adresse = new() + { + Gate = "Storgata 1", + Postnummer = 1234, + Poststed = "Oslo", + }, + }, + new() + { + Navn = "Kari", + Alder = 25, + Deltar = false, + Adresse = new() + { + Gate = "Storgata 2", + Postnummer = 5678, + Poststed = "Bergen", + }, + }, + new() + { + Navn = "Per", + Alder = 40, + Deltar = true, + Adresse = new() + { + Gate = "Storgata 3", + Postnummer = 9101, + Poststed = "Stavanger", + }, + }, + ], + EierAdresse = new() + { + Gate = "Eierveien 1", + Postnummer = 1234, + Poststed = "Oslo", + }, + }; + IFormDataWrapper wrapper = reflection + ? new ReflectionFormDataWrapper(data) + : new Altinn_App_SourceGenerator_Integration_Tests_Models_SkjemaFormDataWrapper(data); + var copy = wrapper.Copy().BackingData(); + Assert.NotSame(data, copy); + Assert.Equivalent(data, copy); + Assert.NotSame(data.Skjemainnhold, copy.Skjemainnhold); + + wrapper.RemoveField("skjemainnhold[0].navn", RowRemovalOption.Ignore); + Assert.Equal("Ola", copy.Skjemainnhold?[0]?.Navn); + Assert.Null(data.Skjemainnhold[0]!.Navn); + + Assert.Equal(3, data.Skjemainnhold!.Count); + wrapper.RemoveField("skjemainnhold[0]", RowRemovalOption.SetToNull); + Assert.Equal(3, data.Skjemainnhold!.Count); + wrapper.RemoveField("skjemainnhold[1]", RowRemovalOption.DeleteRow); + Assert.Equal(2, data.Skjemainnhold!.Count); + Assert.Null(data.Skjemainnhold![0]); + Assert.Equal("Per", data.Skjemainnhold?[1]?.Navn); + + wrapper.RemoveField("skjemainnhold", RowRemovalOption.Ignore); + Assert.Null(data.Skjemainnhold); + } +} diff --git a/test/Altinn.App.SourceGenerator.Integration.Tests/config/applicationmetadata.json b/test/Altinn.App.SourceGenerator.Integration.Tests/config/applicationmetadata.json new file mode 100644 index 0000000000..543c59b5da --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Integration.Tests/config/applicationmetadata.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/application/application-metadata.schema.v1.json", + "id": "ttd/source-generator-test", + "title": { + "nb": "Testapplikasjon for kildegenerator", + "en": "Test application for source generator" + }, + "org": "ttd", + "dataTypes": [ + { + "id": "form", + "appLogic": { + "classRef": "Altinn.App.SourceGenerator.Integration.Tests.Models.Skjema" + } + } + ], + "partyTypesAllowed": {} +} diff --git a/test/Altinn.App.SourceGenerator.Tests/AdditionalTextImplementation.cs b/test/Altinn.App.SourceGenerator.Tests/AdditionalTextImplementation.cs new file mode 100644 index 0000000000..63a63989be --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/AdditionalTextImplementation.cs @@ -0,0 +1,23 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Altinn.App.SourceGenerator.Tests; + +public sealed class AdditionalTextImplementation : AdditionalText +{ + private readonly string? _text; + + public AdditionalTextImplementation(string? text, string filePath) + { + _text = text; + Path = filePath; + } + + public override SourceText? GetText(CancellationToken cancellationToken = default) + { + return _text != null ? SourceText.From(_text, Encoding.UTF8) : null; + } + + public override string Path { get; } +} diff --git a/test/Altinn.App.SourceGenerator.Tests/Altinn.App.SourceGenerator.Tests.csproj b/test/Altinn.App.SourceGenerator.Tests/Altinn.App.SourceGenerator.Tests.csproj new file mode 100644 index 0000000000..75114911c2 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/Altinn.App.SourceGenerator.Tests.csproj @@ -0,0 +1,32 @@ + + + net8.0 + enable + enable + false + true + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.ClassNotFound.verified.txt b/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.ClassNotFound.verified.txt new file mode 100644 index 0000000000..a8977028ac --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.ClassNotFound.verified.txt @@ -0,0 +1,17 @@ +[ + { + Location: C:\temp\config\applicationmetadata.json: (9,24)-(9,67), + Message: Error in applicationmetadata.json: Could not find class Altinn.App.SourceGenerator.Tests.NotFound in the compilation, + Severity: Warning, + WarningLevel: 1, + Descriptor: { + Id: ALTINNAPP0002, + Title: Application metadata error, + HelpLink: https://docs.altinn.studio/nb/altinn-studio/reference/analysis/rules/altinnapp0002, + MessageFormat: Error in applicationmetadata.json: {0}, + Category: Metadata, + DefaultSeverity: Warning, + IsEnabledByDefault: true + } + } +] \ No newline at end of file diff --git a/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.ErrorReadingAppMetadata.verified.txt b/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.ErrorReadingAppMetadata.verified.txt new file mode 100644 index 0000000000..8fb77d6bbf --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.ErrorReadingAppMetadata.verified.txt @@ -0,0 +1,17 @@ +[ + { + Location: C:\temp\config\applicationmetadata.json: (0,0)-(0,0), + Message: Error in applicationmetadata.json: Failed to read, + Severity: Warning, + WarningLevel: 1, + Descriptor: { + Id: ALTINNAPP0002, + Title: Application metadata error, + HelpLink: https://docs.altinn.studio/nb/altinn-studio/reference/analysis/rules/altinnapp0002, + MessageFormat: Error in applicationmetadata.json: {0}, + Category: Metadata, + DefaultSeverity: Warning, + IsEnabledByDefault: true + } + } +] \ No newline at end of file diff --git a/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.NotJsonObject.verified.txt b/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.NotJsonObject.verified.txt new file mode 100644 index 0000000000..96152548a8 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.NotJsonObject.verified.txt @@ -0,0 +1,17 @@ +[ + { + Location: C:\temp\config\applicationmetadata.json: (0,0)-(0,4), + Message: Error in applicationmetadata.json: not a valid JSON object, + Severity: Warning, + WarningLevel: 1, + Descriptor: { + Id: ALTINNAPP0002, + Title: Application metadata error, + HelpLink: https://docs.altinn.studio/nb/altinn-studio/reference/analysis/rules/altinnapp0002, + MessageFormat: Error in applicationmetadata.json: {0}, + Category: Metadata, + DefaultSeverity: Warning, + IsEnabledByDefault: true + } + } +] \ No newline at end of file diff --git a/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.RunJsonError.verified.txt b/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.RunJsonError.verified.txt new file mode 100644 index 0000000000..a94f4f5d4b --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.RunJsonError.verified.txt @@ -0,0 +1,21 @@ +[ + { + Location: C:\temp\config\applicationmetadata.json: (1,14)-(1,14), + Message: +Error in applicationmetadata.json: Unexpected character ',' at index 17 +{␍␤ "title": {,},␍␤}␍␤ + ^ +, + Severity: Warning, + WarningLevel: 1, + Descriptor: { + Id: ALTINNAPP0002, + Title: Application metadata error, + HelpLink: https://docs.altinn.studio/nb/altinn-studio/reference/analysis/rules/altinnapp0002, + MessageFormat: Error in applicationmetadata.json: {0}, + Category: Metadata, + DefaultSeverity: Warning, + IsEnabledByDefault: true + } + } +] \ No newline at end of file diff --git a/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.RunJsonNoDataTypes.verified.txt b/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.RunJsonNoDataTypes.verified.txt new file mode 100644 index 0000000000..360b1e0725 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.RunJsonNoDataTypes.verified.txt @@ -0,0 +1,17 @@ +[ + { + Location: C:\temp\config\applicationmetadata.json: (0,0)-(3,1), + Message: Error in applicationmetadata.json: the property dataTypes is not a valid JSON array, + Severity: Warning, + WarningLevel: 1, + Descriptor: { + Id: ALTINNAPP0002, + Title: Application metadata error, + HelpLink: https://docs.altinn.studio/nb/altinn-studio/reference/analysis/rules/altinnapp0002, + MessageFormat: Error in applicationmetadata.json: {0}, + Category: Metadata, + DefaultSeverity: Warning, + IsEnabledByDefault: true + } + } +] \ No newline at end of file diff --git a/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.cs b/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.cs new file mode 100644 index 0000000000..8aa54eec4a --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/DiagnosticTests.cs @@ -0,0 +1,209 @@ +using System.Collections.Immutable; +using System.Reflection; +using System.Text.Json.Serialization; +using Altinn.App.Analyzers.IncrementalGenerator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Altinn.App.SourceGenerator.Tests; + +public class DiagnosticTests +{ + private const string Source = """ + #nullable enable + using System; + using System.Collections.Generic; + using System.Text.Json.Serialization; + + namespace Altinn.App.SourceGenerator.Tests; + + public class Skjema + { + [JsonPropertyName("skjemanummer")] + public string? Skjemanummer { get; set; } + + [JsonPropertyName("skjemaversjon")] + public string? Skjemaversjon { get; set; } + + [JsonPropertyName("skjemainnhold")] + public List? Skjemainnhold { get; set; } + } + + public class SkjemaInnhold + { + [JsonPropertyName("altinnRowId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Guid AltinnRowId { get; set; } + + [JsonPropertyName("navn")] + public string? Navn { get; set; } + + [JsonPropertyName("alder")] + public int? Alder { get; set; } + + [JsonPropertyName("deltar")] + public bool? Deltar { get; set; } + } + + """; + + [Fact] + public void RunNoDiagnostic() + { + var applicationMetadata = """ + { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/application/application-metadata.schema.v1.json", + "id": "ttd/source-generator-test", + "title": {}, + "org": "ttd", + "partyTypesAllowed": {}, + "dataTypes": [ + { + "id": "data-ref-pdf", + }, + { + "id": "data-ref", + "appLogic": {} + }, + { + "id": "data-ref", + "appLogic": { + "classRef": null, + } + }, + null, + { + "id": "form", + "appLogic": { + "classRef": "Altinn.App.SourceGenerator.Tests.Skjema" + } + } + ] + } + + """; + + var diagnostics = RunFormDataWrapper([Source], applicationMetadata); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task ClassNotFound() + { + var applicationMetadata = """ + { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/application/application-metadata.schema.v1.json", + "id": "ttd/source-generator-test", + "title": {}, + "org": "ttd", + "partyTypesAllowed": {}, + "dataTypes": [{ + "id": "form", + "appLogic": { + "classRef": "Altinn.App.SourceGenerator.Tests.NotFound" + } + }] + } + + """; + + var diagnostics = RunFormDataWrapper([Source], applicationMetadata); + await Verify(diagnostics); + } + + [Fact] + public async Task RunJsonError() + { + var applicationMetadata = """ + { + "title": {,}, + } + + """; + + var diagnostics = RunFormDataWrapper([Source], applicationMetadata); + await Verify(diagnostics); + } + + [Fact] + public async Task RunJsonNoDataTypes() + { + var applicationMetadata = """ + { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/application/application-metadata.schema.v1.json", + "id": "ttd/source-generator-test", + } + + """; + + var diagnostics = RunFormDataWrapper([Source], applicationMetadata); + await Verify(diagnostics); + } + + [Fact] + public void RunJsonEmptyDataTypes() + { + var applicationMetadata = """ + { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/application/application-metadata.schema.v1.json", + "id": "ttd/source-generator-test", + "dataTypes": [] + } + + """; + + var diagnostics = RunFormDataWrapper([Source], applicationMetadata); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task NotJsonObject() + { + var applicationMetadata = "null"; + + var diagnostics = RunFormDataWrapper([Source], applicationMetadata); + await Verify(diagnostics); + } + + [Fact] + public async Task ErrorReadingAppMetadata() + { + string? applicationMetadata = null; + + var diagnostics = RunFormDataWrapper([Source], applicationMetadata); + await Verify(diagnostics); + } + + static ImmutableArray RunFormDataWrapper(string[] syntax, string? applicationMetadata) + { + var currentAssembly = Assembly.GetAssembly(typeof(Skjema)); + // Get references so that the test compilation can reference system libraries + IEnumerable references = AppDomain + .CurrentDomain.GetAssemblies() + .Where(static assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location)) + .Where(assembly => assembly != currentAssembly) + .Select(static assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Concat([MetadataReference.CreateFromFile(typeof(JsonPropertyNameAttribute).Assembly.Location)]); + + var sources = syntax + .Select(s => CSharpSyntaxTree.ParseText(s, path: "/Altinn/ttd/altinn-app-frontend/models/Models.cs")) + .ToArray(); + var compilation = CSharpCompilation.Create( + "name", + sources, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + var generator = new FormDataWrapperGenerator(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + driver = driver.AddAdditionalTexts( + [new AdditionalTextImplementation(applicationMetadata, "C:\\temp\\config\\applicationmetadata.json")] + ); + var results = driver.RunGenerators(compilation); + + var runResult = results.GetRunResult(); + + return runResult.Diagnostics; + } +} diff --git a/test/Altinn.App.SourceGenerator.Tests/FullTests.Run#Altinn_App_SourceGenerator_Tests_SkjemaFormDataWrapper.g.verified.cs b/test/Altinn.App.SourceGenerator.Tests/FullTests.Run#Altinn_App_SourceGenerator_Tests_SkjemaFormDataWrapper.g.verified.cs new file mode 100644 index 0000000000..0fb1001091 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/FullTests.Run#Altinn_App_SourceGenerator_Tests_SkjemaFormDataWrapper.g.verified.cs @@ -0,0 +1,962 @@ +//HintName: Altinn_App_SourceGenerator_Tests_SkjemaFormDataWrapper.g.cs +// +#nullable enable + +[global::System.CodeDom.Compiler.GeneratedCode("Altinn.App.Analyzers", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +public sealed class Altinn_App_SourceGenerator_Tests_SkjemaFormDataWrapper + : global::Altinn.App.Core.Features.IFormDataWrapper +{ + private readonly global::Altinn.App.SourceGenerator.Tests.Skjema _dataModel; + + public global::System.Type BackingDataType => typeof(global::Altinn.App.SourceGenerator.Tests.Skjema); + + public T BackingData() + where T : class + { + return _dataModel as T + ?? throw new global::System.InvalidCastException( + $"Attempted to cast data model of type Altinn.App.SourceGenerator.Tests.Skjema to {typeof(T).FullName}" + ); + } + + public Altinn_App_SourceGenerator_Tests_SkjemaFormDataWrapper(object dataModel) + { + _dataModel = + dataModel as global::Altinn.App.SourceGenerator.Tests.Skjema + ?? throw new global::System.ArgumentException( + $"Data model must be of type Altinn.App.SourceGenerator.Tests.Skjema, (was {dataModel?.GetType().FullName ?? "null"})" + ); + } + + #region Getters + + /// + public object? Get(global::System.ReadOnlySpan path) + { + if (path.IsEmpty) + { + return null; + } + + return GetRecursive(_dataModel, path, 0); + } + + private static object? GetRecursive( + global::Altinn.App.SourceGenerator.Tests.Skjema? model, + global::System.ReadOnlySpan path, + int offset + ) + { + if(model is null) + { + return null; + } + + return ParseSegment(path, offset, out int nextOffset, out int literalIndex) switch + { + "skjemanummer" when nextOffset is -1 && literalIndex is -1 => model.Skjemanummer, + "skjemaversjon" when nextOffset is -1 && literalIndex is -1 => model.Skjemaversjon, + "skjemainnhold" when nextOffset > -1 && literalIndex > -1 => GetRecursive(model.Skjemainnhold, path, literalIndex, nextOffset), + "eierAdresse" when nextOffset > -1 && literalIndex is -1 => GetRecursive(model.EierAdresse, path, nextOffset), + "" => model, + // _ => throw new global::System.ArgumentException("{path} is not a valid path."), + _ => null, + }; + } + + private static object? GetRecursive( + global::System.Collections.Generic.List? model, + global::System.ReadOnlySpan path, + int literalIndex, + int offset + ) + { + if (model is null || literalIndex < 0 || literalIndex >= model.Count) + { + return null; + } + + return GetRecursive(model[literalIndex], path, offset); + } + + private static object? GetRecursive( + global::Altinn.App.SourceGenerator.Tests.SkjemaInnhold? model, + global::System.ReadOnlySpan path, + int offset + ) + { + if(model is null) + { + return null; + } + + return ParseSegment(path, offset, out int nextOffset, out int literalIndex) switch + { + "altinnRowId" when nextOffset is -1 && literalIndex is -1 => model.AltinnRowId, + "navn" when nextOffset is -1 && literalIndex is -1 => model.Navn, + "alder" when nextOffset is -1 && literalIndex is -1 => model.Alder, + "deltar" when nextOffset is -1 && literalIndex is -1 => model.Deltar, + "adresse" when nextOffset > -1 && literalIndex is -1 => GetRecursive(model.Adresse, path, nextOffset), + "tidligere-adresse" when nextOffset > -1 && literalIndex > -1 => GetRecursive(model.TidligereAdresse, path, literalIndex, nextOffset), + "" => model, + // _ => throw new global::System.ArgumentException("{path} is not a valid path."), + _ => null, + }; + } + + private static object? GetRecursive( + global::Altinn.App.SourceGenerator.Tests.Adresse? model, + global::System.ReadOnlySpan path, + int offset + ) + { + if(model is null) + { + return null; + } + + return ParseSegment(path, offset, out int nextOffset, out int literalIndex) switch + { + "altinnRowId" when nextOffset is -1 && literalIndex is -1 => model.AltinnRowId, + "gate" when nextOffset is -1 && literalIndex is -1 => model.Gate, + "postnummer" when nextOffset is -1 && literalIndex is -1 => model.Postnummer, + "poststed" when nextOffset is -1 && literalIndex is -1 => model.Poststed, + "" => model, + // _ => throw new global::System.ArgumentException("{path} is not a valid path."), + _ => null, + }; + } + + private static object? GetRecursive( + global::System.Collections.Generic.List? model, + global::System.ReadOnlySpan path, + int literalIndex, + int offset + ) + { + if (model is null || literalIndex < 0 || literalIndex >= model.Count) + { + return null; + } + + return GetRecursive(model[literalIndex], path, offset); + } + + #endregion Getters + #region AddIndexToPath + + /// + public global::System.ReadOnlySpan AddIndexToPath(global::System.ReadOnlySpan path, global::System.ReadOnlySpan rowIndexes, global::System.Span buffer) + { + if(path.IsEmpty) + { + return global::System.ReadOnlySpan.Empty; + } + + var bufferOffset = 0; + var pathOffset = 0; + + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_Skjema( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + + return buffer[..bufferOffset]; + } + + private void AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_Skjema( + global::System.ReadOnlySpan path, + int pathOffset, + global::System.ReadOnlySpan rowIndexes, + global::System.Span buffer, + ref int bufferOffset + ) + { + if (bufferOffset > 0) + { + buffer[bufferOffset++] = '.'; + } + var segment = ParseSegment(path, pathOffset, out pathOffset, out int literalIndex); + switch (segment) + { + case "skjemainnhold": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 13; + + if (literalIndex != -1) + { + // Copy index from path to buffer + buffer[bufferOffset++] = '['; + if (!literalIndex.TryFormat(buffer[bufferOffset..], out int charsWritten)) + { + throw new global::System.FormatException($"Invalid index in {path}."); + } + + bufferOffset += charsWritten; + buffer[bufferOffset++] = ']'; + rowIndexes = default; + } + else if (rowIndexes.Length >= 1) + { + // Write index from rowIndexes to buffer + buffer[bufferOffset++] = '['; + rowIndexes[0].TryFormat(buffer.Slice(bufferOffset), out int charsWritten); + bufferOffset += charsWritten; + buffer[bufferOffset++] = ']'; + rowIndexes = rowIndexes.Slice(1); + } + else if (pathOffset == -1) + { + // No more segments in the path, and the last part is valid in a list + // without index (e.g. "model.listProperty" is valid, but "model.listProperty.val" needs an index) + return; + } + else + { + // No index to write, but there are more segments in the path + // thus the path is not valid + bufferOffset = 0; + return; + } + if (pathOffset != -1) + { + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_SkjemaInnhold( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + } + return; + case "eierAdresse": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 11; + if (pathOffset != -1) + { + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_Adresse( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + } + return; + case "skjemanummer": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 12; + return; + case "skjemaversjon": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 13; + return; + default: + bufferOffset = 0; + return; + } + } + + private void AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_SkjemaInnhold( + global::System.ReadOnlySpan path, + int pathOffset, + global::System.ReadOnlySpan rowIndexes, + global::System.Span buffer, + ref int bufferOffset + ) + { + if (bufferOffset > 0) + { + buffer[bufferOffset++] = '.'; + } + var segment = ParseSegment(path, pathOffset, out pathOffset, out int literalIndex); + switch (segment) + { + case "adresse": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 7; + if (pathOffset != -1) + { + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_Adresse( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + } + return; + case "tidligere-adresse": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 17; + + if (literalIndex != -1) + { + // Copy index from path to buffer + buffer[bufferOffset++] = '['; + if (!literalIndex.TryFormat(buffer[bufferOffset..], out int charsWritten)) + { + throw new global::System.FormatException($"Invalid index in {path}."); + } + + bufferOffset += charsWritten; + buffer[bufferOffset++] = ']'; + rowIndexes = default; + } + else if (rowIndexes.Length >= 1) + { + // Write index from rowIndexes to buffer + buffer[bufferOffset++] = '['; + rowIndexes[0].TryFormat(buffer.Slice(bufferOffset), out int charsWritten); + bufferOffset += charsWritten; + buffer[bufferOffset++] = ']'; + rowIndexes = rowIndexes.Slice(1); + } + else if (pathOffset == -1) + { + // No more segments in the path, and the last part is valid in a list + // without index (e.g. "model.listProperty" is valid, but "model.listProperty.val" needs an index) + return; + } + else + { + // No index to write, but there are more segments in the path + // thus the path is not valid + bufferOffset = 0; + return; + } + if (pathOffset != -1) + { + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_Adresse( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + } + return; + case "altinnRowId": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 11; + return; + case "navn": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 4; + return; + case "alder": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 5; + return; + case "deltar": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 6; + return; + default: + bufferOffset = 0; + return; + } + } + + private void AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_Adresse( + global::System.ReadOnlySpan path, + int pathOffset, + global::System.ReadOnlySpan rowIndexes, + global::System.Span buffer, + ref int bufferOffset + ) + { + if (bufferOffset > 0) + { + buffer[bufferOffset++] = '.'; + } + var segment = ParseSegment(path, pathOffset, out pathOffset, out int literalIndex); + switch (segment) + { + case "altinnRowId": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 11; + return; + case "gate": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 4; + return; + case "postnummer": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 10; + return; + case "poststed": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 8; + return; + default: + bufferOffset = 0; + return; + } + } + + #endregion AddIndexToPath + #region Copy + + /// + public global::Altinn.App.Core.Features.IFormDataWrapper Copy() + { + return new Altinn_App_SourceGenerator_Tests_SkjemaFormDataWrapper(CopyRecursive(_dataModel)); + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("data")] + private static global::Altinn.App.SourceGenerator.Tests.Skjema? CopyRecursive( + global::Altinn.App.SourceGenerator.Tests.Skjema? data + ) + { + if (data is null) + { + return null; + } + + return new() + { + Skjemanummer = data.Skjemanummer, + Skjemaversjon = data.Skjemaversjon, + Skjemainnhold = CopyRecursive(data.Skjemainnhold), + EierAdresse = CopyRecursive(data.EierAdresse), + }; + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("list")] + private static global::System.Collections.Generic.List? CopyRecursive( + global::System.Collections.Generic.List? list + ) + { + if (list is null) + { + return null; + } + + global::System.Collections.Generic.List result = new(list.Count); + foreach (var item in list) + { + result.Add(CopyRecursive(item)); + } + + return result; + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("data")] + private static global::Altinn.App.SourceGenerator.Tests.SkjemaInnhold? CopyRecursive( + global::Altinn.App.SourceGenerator.Tests.SkjemaInnhold? data + ) + { + if (data is null) + { + return null; + } + + return new() + { + AltinnRowId = data.AltinnRowId, + Navn = data.Navn, + Alder = data.Alder, + Deltar = data.Deltar, + Adresse = CopyRecursive(data.Adresse), + TidligereAdresse = CopyRecursive(data.TidligereAdresse), + }; + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("data")] + private static global::Altinn.App.SourceGenerator.Tests.Adresse? CopyRecursive( + global::Altinn.App.SourceGenerator.Tests.Adresse? data + ) + { + if (data is null) + { + return null; + } + + return new() + { + AltinnRowId = data.AltinnRowId, + Gate = data.Gate, + Postnummer = data.Postnummer, + Poststed = data.Poststed, + }; + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("list")] + private static global::System.Collections.Generic.List? CopyRecursive( + global::System.Collections.Generic.List? list + ) + { + if (list is null) + { + return null; + } + + global::System.Collections.Generic.List result = new(list.Count); + foreach (var item in list) + { + result.Add(CopyRecursive(item)); + } + + return result; + } + + #endregion Copy + #region Remove + + /// + public void RemoveField(global::System.ReadOnlySpan path, global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption) + { + if (path.IsEmpty) + { + return; + } + + RemoveRecursive(_dataModel, path, 0, rowRemovalOption); + } + + private static void RemoveRecursive( + global::Altinn.App.SourceGenerator.Tests.Skjema? model, + global::System.ReadOnlySpan path, + int offset, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + switch (ParseSegment(path, offset, out int nextOffset, out int literalIndex)) + { + case "skjemanummer" when (nextOffset is -1) && (literalIndex is -1): + model.Skjemanummer = default; + break; + case "skjemaversjon" when (nextOffset is -1) && (literalIndex is -1): + model.Skjemaversjon = default; + break; + case "skjemainnhold" when (nextOffset is -1) && (literalIndex is -1): + model.Skjemainnhold = default; + break; + case "skjemainnhold": + RemoveRecursive(model.Skjemainnhold, path, nextOffset, literalIndex, rowRemovalOption); + break; + case "eierAdresse" when (nextOffset is -1) && (literalIndex is -1): + model.EierAdresse = default; + break; + case "eierAdresse": + RemoveRecursive(model.EierAdresse, path, nextOffset, rowRemovalOption); + break; + default: + // throw new ArgumentException("{path} is not a valid path."); + return; + } + } + + private static void RemoveRecursive( + global::System.Collections.Generic.List? model, + global::System.ReadOnlySpan path, + int offset, + int index, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + if (index < 0 || index >= model.Count) + { + return; + } + if (offset == -1) + { + switch (rowRemovalOption) + { + case global::Altinn.App.Core.Helpers.RowRemovalOption.DeleteRow: + model.RemoveAt(index); + break; + case global::Altinn.App.Core.Helpers.RowRemovalOption.SetToNull: + model[index] = default!; + break; + } + } + else + { + RemoveRecursive(model?[index], path, offset, rowRemovalOption); + } + } + + private static void RemoveRecursive( + global::Altinn.App.SourceGenerator.Tests.SkjemaInnhold? model, + global::System.ReadOnlySpan path, + int offset, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + switch (ParseSegment(path, offset, out int nextOffset, out int literalIndex)) + { + case "navn" when (nextOffset is -1) && (literalIndex is -1): + model.Navn = default; + break; + case "alder" when (nextOffset is -1) && (literalIndex is -1): + model.Alder = default; + break; + case "deltar" when (nextOffset is -1) && (literalIndex is -1): + model.Deltar = default; + break; + case "adresse" when (nextOffset is -1) && (literalIndex is -1): + model.Adresse = default; + break; + case "adresse": + RemoveRecursive(model.Adresse, path, nextOffset, rowRemovalOption); + break; + case "tidligere-adresse" when (nextOffset is -1) && (literalIndex is -1): + model.TidligereAdresse = default; + break; + case "tidligere-adresse": + RemoveRecursive(model.TidligereAdresse, path, nextOffset, literalIndex, rowRemovalOption); + break; + default: + // throw new ArgumentException("{path} is not a valid path."); + return; + } + } + + private static void RemoveRecursive( + global::Altinn.App.SourceGenerator.Tests.Adresse? model, + global::System.ReadOnlySpan path, + int offset, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + switch (ParseSegment(path, offset, out int nextOffset, out int literalIndex)) + { + case "gate" when (nextOffset is -1) && (literalIndex is -1): + model.Gate = default; + break; + case "postnummer" when (nextOffset is -1) && (literalIndex is -1): + model.Postnummer = default; + break; + case "poststed" when (nextOffset is -1) && (literalIndex is -1): + model.Poststed = default; + break; + default: + // throw new ArgumentException("{path} is not a valid path."); + return; + } + } + + private static void RemoveRecursive( + global::System.Collections.Generic.List? model, + global::System.ReadOnlySpan path, + int offset, + int index, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + if (index < 0 || index >= model.Count) + { + return; + } + if (offset == -1) + { + switch (rowRemovalOption) + { + case global::Altinn.App.Core.Helpers.RowRemovalOption.DeleteRow: + model.RemoveAt(index); + break; + case global::Altinn.App.Core.Helpers.RowRemovalOption.SetToNull: + model[index] = default!; + break; + } + } + else + { + RemoveRecursive(model?[index], path, offset, rowRemovalOption); + } + } + + #endregion Remove + #region AltinnRowIds + + /// + public void RemoveAltinnRowIds() + { + SetAltinnRowIds(_dataModel, initialize: false); + } + + /// + public void InitializeAltinnRowIds() + { + SetAltinnRowIds(_dataModel, initialize: true); + } + + private static void SetAltinnRowIds(global::Altinn.App.SourceGenerator.Tests.Skjema dataModel, bool initialize) + { + if(dataModel.Skjemainnhold is not null) + { + foreach (var item in dataModel.Skjemainnhold) + { + if (item is not null) + { + SetAltinnRowIds(item, initialize); + } + } + } + if(dataModel.EierAdresse is not null) + { + SetAltinnRowIds(dataModel.EierAdresse, initialize); + } + } + + private static void SetAltinnRowIds(global::Altinn.App.SourceGenerator.Tests.SkjemaInnhold dataModel, bool initialize) + { + if (!initialize) + { + dataModel.AltinnRowId = global::System.Guid.Empty; + } + else if (dataModel.AltinnRowId == global::System.Guid.Empty) + { + dataModel.AltinnRowId = global::System.Guid.NewGuid(); + } + if(dataModel.Adresse is not null) + { + SetAltinnRowIds(dataModel.Adresse, initialize); + } + if(dataModel.TidligereAdresse is not null) + { + foreach (var item in dataModel.TidligereAdresse) + { + if (item is not null) + { + SetAltinnRowIds(item, initialize); + } + } + } + } + + private static void SetAltinnRowIds(global::Altinn.App.SourceGenerator.Tests.Adresse dataModel, bool initialize) + { + if (!initialize) + { + dataModel.AltinnRowId = global::System.Guid.Empty; + } + else if (dataModel.AltinnRowId == global::System.Guid.Empty) + { + dataModel.AltinnRowId = global::System.Guid.NewGuid(); + } + } + + #endregion AltinnRowIds + #region XmlStorage + + /// + public void PrepareModelForXmlStorage() + { + global::Altinn.App.Core.Helpers.ObjectUtils.PrepareModelForXmlStorage(_dataModel); + } + + #endregion XmlStorage + public static global::System.ReadOnlySpan ParseSegment(global::System.ReadOnlySpan path, int offset, out int nextOffset, out int literalIndex) + { + if (offset < 0 || offset >= path.Length) + { + throw new global::System.ArgumentOutOfRangeException(nameof(offset)); + } + + for (int i = offset; i < path.Length; i++) + { + switch (path[i]) + { + case '.': + nextOffset = i + 1; + literalIndex = -1; + return path[offset..i]; + case '[': + literalIndex = GetIndex(path, i + 1, out nextOffset); + return path[offset..i]; + } + } + + // No markers found. Just return the rest of the path. + nextOffset = -1; + literalIndex = -1; + return path[offset..]; + } + + private static int GetIndex(global::System.ReadOnlySpan path, int offset, out int nextOffset) + { + var segment = path[offset..]; + var bracketOffset = global::System.MemoryExtensions.IndexOf(segment, ']'); + if (bracketOffset < 0) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Missing closing bracket ']' in {path}."); + } + + if (!int.TryParse(segment[..bracketOffset], out var index)) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Invalid index in {path}."); + } + + if (index < 0) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Invalid negative index in {path}."); + } + + nextOffset = offset + bracketOffset + 2; + if (nextOffset >= path.Length) + { + nextOffset = -1; + } + + return index; + } +} + +// -------------------------------------------------- +// ModelPathNode as json (for debugging) +// -------------------------------------------------- +// +// { +// "JsonName": "", +// "CSharpName": "", +// "TypeName": "global::Altinn.App.SourceGenerator.Tests.Skjema", +// "Properties": [ +// { +// "JsonName": "skjemanummer", +// "CSharpName": "Skjemanummer", +// "TypeName": "string", +// }, +// { +// "JsonName": "skjemaversjon", +// "CSharpName": "Skjemaversjon", +// "TypeName": "string", +// }, +// { +// "JsonName": "skjemainnhold", +// "CSharpName": "Skjemainnhold", +// "TypeName": "global::Altinn.App.SourceGenerator.Tests.SkjemaInnhold", +// "ListType": "global::System.Collections.Generic.List", +// "Properties": [ +// { +// "JsonName": "altinnRowId", +// "CSharpName": "AltinnRowId", +// "TypeName": "global::System.Guid", +// }, +// { +// "JsonName": "navn", +// "CSharpName": "Navn", +// "TypeName": "string", +// }, +// { +// "JsonName": "alder", +// "CSharpName": "Alder", +// "TypeName": "int", +// }, +// { +// "JsonName": "deltar", +// "CSharpName": "Deltar", +// "TypeName": "bool", +// }, +// { +// "JsonName": "adresse", +// "CSharpName": "Adresse", +// "TypeName": "global::Altinn.App.SourceGenerator.Tests.Adresse", +// "Properties": [ +// { +// "JsonName": "altinnRowId", +// "CSharpName": "AltinnRowId", +// "TypeName": "global::System.Guid", +// }, +// { +// "JsonName": "gate", +// "CSharpName": "Gate", +// "TypeName": "string", +// }, +// { +// "JsonName": "postnummer", +// "CSharpName": "Postnummer", +// "TypeName": "int", +// }, +// { +// "JsonName": "poststed", +// "CSharpName": "Poststed", +// "TypeName": "string", +// } +// ] +// }, +// { +// "JsonName": "tidligere-adresse", +// "CSharpName": "TidligereAdresse", +// "TypeName": "global::Altinn.App.SourceGenerator.Tests.Adresse", +// "ListType": "global::System.Collections.Generic.List", +// "Properties": [ +// { +// "JsonName": "altinnRowId", +// "CSharpName": "AltinnRowId", +// "TypeName": "global::System.Guid", +// }, +// { +// "JsonName": "gate", +// "CSharpName": "Gate", +// "TypeName": "string", +// }, +// { +// "JsonName": "postnummer", +// "CSharpName": "Postnummer", +// "TypeName": "int", +// }, +// { +// "JsonName": "poststed", +// "CSharpName": "Poststed", +// "TypeName": "string", +// } +// ] +// } +// ] +// }, +// { +// "JsonName": "eierAdresse", +// "CSharpName": "EierAdresse", +// "TypeName": "global::Altinn.App.SourceGenerator.Tests.Adresse", +// "Properties": [ +// { +// "JsonName": "altinnRowId", +// "CSharpName": "AltinnRowId", +// "TypeName": "global::System.Guid", +// }, +// { +// "JsonName": "gate", +// "CSharpName": "Gate", +// "TypeName": "string", +// }, +// { +// "JsonName": "postnummer", +// "CSharpName": "Postnummer", +// "TypeName": "int", +// }, +// { +// "JsonName": "poststed", +// "CSharpName": "Poststed", +// "TypeName": "string", +// } +// ] +// } +// ] +// } diff --git a/test/Altinn.App.SourceGenerator.Tests/FullTests.cs b/test/Altinn.App.SourceGenerator.Tests/FullTests.cs new file mode 100644 index 0000000000..f491ef322d --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/FullTests.cs @@ -0,0 +1,137 @@ +using System.Reflection; +using System.Text.Json.Serialization; +using Altinn.App.Analyzers.IncrementalGenerator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Altinn.App.SourceGenerator.Tests; + +public class FullTests +{ + [Fact] + public async Task Run() + { + var source = """ + #nullable enable + using System; + using System.Collections.Generic; + using System.Text.Json.Serialization; + + namespace Altinn.App.SourceGenerator.Tests; + + public class Skjema + { + // Extra properties to test that they get ignored by source generator + public const string FormDataType = "form"; + public static readonly string FormDataTypeStatic = FormDataType; + public string FormDataTypeId => FormDataType; + + [JsonPropertyName("skjemanummer")] + public string? Skjemanummer { get; set; } + + [JsonPropertyName("skjemaversjon")] + public string? Skjemaversjon { get; set; } + + [JsonPropertyName("skjemainnhold")] + public List? Skjemainnhold { get; set; } + + [JsonPropertyName("eierAdresse")] + public Adresse? EierAdresse { get; set; } + } + + public class SkjemaInnhold + { + [JsonPropertyName("altinnRowId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Guid AltinnRowId { get; set; } + + [JsonPropertyName("navn")] + public string? Navn { get; set; } + + [JsonPropertyName("alder")] + public int? Alder { get; set; } + + [JsonPropertyName("deltar")] + public bool? Deltar { get; set; } + + [JsonPropertyName("adresse")] + public Adresse? Adresse { get; set; } + + [JsonPropertyName("tidligere-adresse")] + public List? TidligereAdresse { get; set; } + + + [JsonPropertyName("withCollection")] + public ICollection? WithCollection { get; set; } + } + + public class Adresse + { + [JsonPropertyName("altinnRowId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Guid AltinnRowId { get; set; } + + [JsonPropertyName("gate")] + public string? Gate { get; set; } + + [JsonPropertyName("postnummer")] + public int? Postnummer { get; set; } + + [JsonPropertyName("poststed")] + public string? Poststed { get; set; } + } + """; + var syntax = CSharpSyntaxTree.ParseText(source, path: "models/Models.cs"); + + var applicationMetadata = """ + { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/application/application-metadata.schema.v1.json", + "id": "ttd/source-generator-test", + "title": {}, + "org": "ttd", + "partyTypesAllowed": {}, + "dataTypes": [{ + "id": "form", + "appLogic": { + "classRef": "Altinn.App.SourceGenerator.Tests.Skjema" + } + }] + } + + """; + + await Verify(RunFormDataWrapper([syntax], applicationMetadata)); + } + + public static GeneratorDriverRunResult RunFormDataWrapper(SyntaxTree[] sources, string applicationMetadata) + { + var currentAssembly = Assembly.GetAssembly(typeof(Skjema)); + // Get references so that the test compilation can reference system libraries + IEnumerable references = AppDomain + .CurrentDomain.GetAssemblies() + .Where(static assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location)) + .Where(assembly => assembly != currentAssembly) + .Select(static assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Concat([MetadataReference.CreateFromFile(typeof(JsonPropertyNameAttribute).Assembly.Location)]); + + var compilation = CSharpCompilation.Create( + "name", + sources, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + var generator = new FormDataWrapperGenerator(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + driver = driver.AddAdditionalTexts( + [new AdditionalTextImplementation(applicationMetadata, "C:\\temp\\config\\applicationmetadata.json")] + ); + var results = driver.RunGenerators(compilation); + + var runResult = results.GetRunResult(); + + Assert.Empty(compilation.GetDiagnostics()); + // Verify the generated code + return runResult; + } +} diff --git a/test/Altinn.App.SourceGenerator.Tests/ModuleInitializer.cs b/test/Altinn.App.SourceGenerator.Tests/ModuleInitializer.cs new file mode 100644 index 0000000000..cb3cd90fe6 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/ModuleInitializer.cs @@ -0,0 +1,13 @@ +using System.Runtime.CompilerServices; + +namespace Altinn.App.SourceGenerator.Tests; + +internal static class ModuleInitializer +{ + [ModuleInitializer] + public static void Init() + { + // VerifierSettings.AutoVerify(includeBuildServer: false); + VerifySourceGenerators.Initialize(); + } +} diff --git a/test/Altinn.App.SourceGenerator.Tests/NanoJsonReader/NanoJsonReaderTests.cs b/test/Altinn.App.SourceGenerator.Tests/NanoJsonReader/NanoJsonReaderTests.cs new file mode 100644 index 0000000000..615141cad9 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/NanoJsonReader/NanoJsonReaderTests.cs @@ -0,0 +1,298 @@ +using System.Collections; +using NanoJsonReader; +using Xunit.Abstractions; + +namespace Altinn.App.SourceGenerator.Tests.NanoJsonReader; + +public class NanoJsonReaderTests(ITestOutputHelper output) +{ + [Fact] + public void Test() + { + string json = """ + { + "number": 1, + "bigNumber": 233e+10, + "true": true, + "false": false, + "null": null, + "string": "string", + "stringWithEscapes": "string with \"escapes\"", + "stringWithEscapes2": "string with \\\\escapes", + "stringWithEscapes3": "string with \\\\n", + "array": [1, 2, 3], + "array2": [1, 2, 3, "string", true, false, null], + "object": { + "number": 1, + "bigNumber": 233e+10, + "true": true, + "false": false, + "null": null, + "string": "string", + "stringWithEscapes": "string with \"escapes\"", + "stringWithEscapes2": "string with \\\\escapes", + "stringWithEscapes3": "string with \\\\n", + "array": [1, 2, 3], + "array2": [1, 2, 3, "string", true, false, null] + } + } + """; + + JsonValue root = JsonValue.Parse(json); + Assert.Equal(JsonType.Object, root.Type); + + Dictionary expectedValues = new() + { + { "number", 1.0 }, + { "bigNumber", 233e+10 }, + { "true", true }, + { "false", false }, + { "null", null }, + { "string", "string" }, + { "stringWithEscapes", "string with \"escapes\"" }, + { "stringWithEscapes2", @"string with \\escapes" }, + { "stringWithEscapes3", @"string with \\n" }, + { + "array", + new List { 1, 2, 3 } + }, + { + "array2", + new List { 1.0, 2.0, 3.0, "string", true, false, null } + }, + { + "object", + new Dictionary + { + { "number", 1.0 }, + { "bigNumber", 233e+10 }, + { "true", true }, + { "false", false }, + { "null", null }, + { "string", "string" }, + { "stringWithEscapes", "string with \"escapes\"" }, + { "stringWithEscapes2", @"string with \\escapes" }, + { "stringWithEscapes3", @"string with \\n" }, + { + "array", + new List { 1, 2, 3 } + }, + { + "array2", + new List { 1.0, 2.0, 3.0, "string", true, false, null } + }, + } + }, + }; + AssertEquivalency(expectedValues, root); + } + + [Fact] + public void TestSimple() + { + string json = """ + { + "array": [1, 2, 3], + "array2": [ + 1, + 2, + 3, + "string\n\r\b\t\f\\v\u0020", + true, + {"key": "value"}, + false, + null], + } + """; + + JsonValue root = JsonValue.Parse(json); + Assert.Equal(JsonType.Object, root.Type); + + Dictionary expectedValues = new() + { + { + "array", + new List { 1, 2, 3 } + }, + { + "array2", + new List + { + 1.0, + 2.0, + 3.0, + "string\n\r\b\t\f\\v\u0020", + true, + new Dictionary() { { "key", "value" } }, + false, + null, + } + }, + }; + AssertEquivalency(expectedValues, root); + } + + private void AssertEquivalency(object? expectedValue, JsonValue currentValue) + { + output.WriteLine("asserting " + expectedValue); + switch (expectedValue) + { + case null: + Assert.Equal(JsonType.Null, currentValue.Type); + break; + case string s: + Assert.Equal(JsonType.String, currentValue.Type); + Assert.Equal(s, currentValue.GetString()); + Assert.True(currentValue.StringEquals(s.AsSpan())); + break; + case double d: + Assert.Equal(JsonType.Number, currentValue.Type); + Assert.Equal(d, currentValue.GetNumber()); + break; + case bool b: + Assert.Equal(JsonType.Boolean, currentValue.Type); + Assert.Equal(b, currentValue.GetBool()); + break; + case IDictionary dictionary: + Assert.Equal(JsonType.Object, currentValue.Type); + using (var propEnumerator = currentValue.GetObjectValues().GetEnumerator()) + { + foreach (DictionaryEntry entry in dictionary) + { + Assert.True(propEnumerator.MoveNext()); + Assert.Equal(entry.Key, propEnumerator.Current.Key); + AssertEquivalency(entry.Value, propEnumerator.Current.Value); + } + Assert.False(propEnumerator.MoveNext()); + } + break; + case ICollection collection: + Assert.Equal(JsonType.Array, currentValue.Type); + using (var arrayEnumerator = currentValue.GetArrayValues().GetEnumerator()) + { + foreach (var item in collection) + { + Assert.True(arrayEnumerator.MoveNext()); + AssertEquivalency(item, arrayEnumerator.Current); + } + Assert.False(arrayEnumerator.MoveNext()); + } + + break; + default: + throw new Exception($"Unexpected value type {expectedValue.GetType()}, assert with only json types"); + } + } + + [Fact] + public void TestAppMetadata() + { + var applicationMetadata = """ + { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/application/application-metadata.schema.v1.json", + "id": "ttd/source-generator-test", + "title": {}, + "org": "ttd", + "partyTypesAllowed": {}, + "dataTypes": [{ + "id": "form", + "appLogic": { + "classRef": "Altinn.App.SourceGenerator.Tests.Skjema" + } + }] + } + + """; + + JsonValue root = JsonValue.Parse(applicationMetadata); + Assert.Equal(JsonType.Object, root.Type); + var expected = new Dictionary() + { + { + "$schema", + "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/application/application-metadata.schema.v1.json" + }, + { "id", "ttd/source-generator-test" }, + { "title", new Dictionary() }, + { "org", "ttd" }, + { "partyTypesAllowed", new Dictionary() }, + { + "dataTypes", + new List() + { + new Dictionary() + { + { "id", "form" }, + { + "appLogic", + new Dictionary() + { + { "classRef", "Altinn.App.SourceGenerator.Tests.Skjema" }, + } + }, + }, + } + }, + }; + AssertEquivalency(expected, root); + } + + [Theory] + [InlineData("""{"\"}""", 1, 5)] + [InlineData("""{"b": {},"e":tRue}""", 13, 17)] + [InlineData("""{"d":4true}""", 6, null)] + [InlineData("""{"e"4.4.4}""", 4, null)] + [InlineData("""{ """, 1, 2)] + [InlineData("""{""", 1, null)] + public void TestInvalid(string json, int start, int? end) + { + var exception = Assert.Throws(() => JsonValue.Parse(json).GetProperty("e")); + output.WriteLine(exception.Message); + Assert.Equal(start, exception.StartIndex); + Assert.Equal(end, exception.EndIndex); + } + + [Theory] + [InlineData("""{"e":4.4.4}""", 5, 10)] + [InlineData("""{"e":4.4e43e}""", 5, 12)] + public void TestInvalidNumber(string json, int start, int? end) + { + var exception = Assert.Throws(() => JsonValue.Parse(json).GetProperty("e")?.GetNumber()); + output.WriteLine(exception.Message); + Assert.Equal(start, exception.StartIndex); + Assert.Equal(end, exception.EndIndex); + } + + [Theory] + [InlineData("""{"e":"\u234ø"}""", 5, 13)] + [InlineData("""{"e":"\"}""", 5, 9)] + [InlineData("""{"e":"\ø"}""", 5, 9)] + [InlineData("""{"":[2,null,}, "e"}""", 12, null)] + [InlineData("""{"":[2,null}, "e"}""", 11, null)] + public void TestInvalidString(string json, int start, int? end) + { + var exception = Assert.Throws(() => JsonValue.Parse(json).GetProperty("e")?.GetString()); + output.WriteLine(exception.Message); + Assert.Equal(start, exception.StartIndex); + Assert.Equal(end, exception.EndIndex); + } + + [Theory] + [InlineData("""[]""")] + [InlineData("""[null,]""")] + [InlineData("""{}""")] + [InlineData("""{"e":null,}""")] + [InlineData("""null""")] + [InlineData("""true""")] + [InlineData("""false""")] + [InlineData("""234e-23""")] + public void TestSkipProperties(string skip) + { + var value = JsonValue.Parse($$"""{"skip": {{skip}}, "end":"e\bnd"}"""); + var end = value.GetProperty("end"); + Assert.NotNull(end); + Assert.Equal(JsonType.String, end.Type); + Assert.Equal("e\bnd", end.GetString()); + Assert.Equal("\"e\\bnd\"", end.ToString()); + } +} diff --git a/test/Altinn.App.SourceGenerator.Tests/SourceTextGeneratorTests.Generate.verified.cs b/test/Altinn.App.SourceGenerator.Tests/SourceTextGeneratorTests.Generate.verified.cs new file mode 100644 index 0000000000..52a507f0a7 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/SourceTextGeneratorTests.Generate.verified.cs @@ -0,0 +1,961 @@ +// +#nullable enable + +[global::System.CodeDom.Compiler.GeneratedCode("Altinn.App.Analyzers", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +public sealed class Altinn_App_SourceGenerator_Tests_SkjemaFormDataWrapper + : global::Altinn.App.Core.Features.IFormDataWrapper +{ + private readonly global::Altinn.App.SourceGenerator.Tests.Skjema _dataModel; + + public global::System.Type BackingDataType => typeof(global::Altinn.App.SourceGenerator.Tests.Skjema); + + public T BackingData() + where T : class + { + return _dataModel as T + ?? throw new global::System.InvalidCastException( + $"Attempted to cast data model of type Altinn.App.SourceGenerator.Tests.Skjema to {typeof(T).FullName}" + ); + } + + public Altinn_App_SourceGenerator_Tests_SkjemaFormDataWrapper(object dataModel) + { + _dataModel = + dataModel as global::Altinn.App.SourceGenerator.Tests.Skjema + ?? throw new global::System.ArgumentException( + $"Data model must be of type Altinn.App.SourceGenerator.Tests.Skjema, (was {dataModel?.GetType().FullName ?? "null"})" + ); + } + + #region Getters + + /// + public object? Get(global::System.ReadOnlySpan path) + { + if (path.IsEmpty) + { + return null; + } + + return GetRecursive(_dataModel, path, 0); + } + + private static object? GetRecursive( + global::Altinn.App.SourceGenerator.Tests.Skjema? model, + global::System.ReadOnlySpan path, + int offset + ) + { + if(model is null) + { + return null; + } + + return ParseSegment(path, offset, out int nextOffset, out int literalIndex) switch + { + "skjemanummer" when nextOffset is -1 && literalIndex is -1 => model.Skjemanummer, + "skjemaversjon" when nextOffset is -1 && literalIndex is -1 => model.Skjemaversjon, + "skjemainnhold" when nextOffset > -1 && literalIndex > -1 => GetRecursive(model.Skjemainnhold, path, literalIndex, nextOffset), + "eierAdresse" when nextOffset > -1 && literalIndex is -1 => GetRecursive(model.EierAdresse, path, nextOffset), + "" => model, + // _ => throw new global::System.ArgumentException("{path} is not a valid path."), + _ => null, + }; + } + + private static object? GetRecursive( + global::System.Collections.Generic.List? model, + global::System.ReadOnlySpan path, + int literalIndex, + int offset + ) + { + if (model is null || literalIndex < 0 || literalIndex >= model.Count) + { + return null; + } + + return GetRecursive(model[literalIndex], path, offset); + } + + private static object? GetRecursive( + global::Altinn.App.SourceGenerator.Tests.SkjemaInnhold? model, + global::System.ReadOnlySpan path, + int offset + ) + { + if(model is null) + { + return null; + } + + return ParseSegment(path, offset, out int nextOffset, out int literalIndex) switch + { + "altinnRowId" when nextOffset is -1 && literalIndex is -1 => model.AltinnRowId, + "navn" when nextOffset is -1 && literalIndex is -1 => model.Navn, + "alder" when nextOffset is -1 && literalIndex is -1 => model.Alder, + "deltar" when nextOffset is -1 && literalIndex is -1 => model.Deltar, + "adresse" when nextOffset > -1 && literalIndex is -1 => GetRecursive(model.Adresse, path, nextOffset), + "tidligere-adresse" when nextOffset > -1 && literalIndex > -1 => GetRecursive(model.TidligereAdresse, path, literalIndex, nextOffset), + "" => model, + // _ => throw new global::System.ArgumentException("{path} is not a valid path."), + _ => null, + }; + } + + private static object? GetRecursive( + global::Altinn.App.SourceGenerator.Tests.Adresse? model, + global::System.ReadOnlySpan path, + int offset + ) + { + if(model is null) + { + return null; + } + + return ParseSegment(path, offset, out int nextOffset, out int literalIndex) switch + { + "altinnRowId" when nextOffset is -1 && literalIndex is -1 => model.AltinnRowId, + "gate" when nextOffset is -1 && literalIndex is -1 => model.Gate, + "postnummer" when nextOffset is -1 && literalIndex is -1 => model.Postnummer, + "poststed" when nextOffset is -1 && literalIndex is -1 => model.Poststed, + "" => model, + // _ => throw new global::System.ArgumentException("{path} is not a valid path."), + _ => null, + }; + } + + private static object? GetRecursive( + global::System.Collections.Generic.List? model, + global::System.ReadOnlySpan path, + int literalIndex, + int offset + ) + { + if (model is null || literalIndex < 0 || literalIndex >= model.Count) + { + return null; + } + + return GetRecursive(model[literalIndex], path, offset); + } + + #endregion Getters + #region AddIndexToPath + + /// + public global::System.ReadOnlySpan AddIndexToPath(global::System.ReadOnlySpan path, global::System.ReadOnlySpan rowIndexes, global::System.Span buffer) + { + if(path.IsEmpty) + { + return global::System.ReadOnlySpan.Empty; + } + + var bufferOffset = 0; + var pathOffset = 0; + + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_Skjema( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + + return buffer[..bufferOffset]; + } + + private void AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_Skjema( + global::System.ReadOnlySpan path, + int pathOffset, + global::System.ReadOnlySpan rowIndexes, + global::System.Span buffer, + ref int bufferOffset + ) + { + if (bufferOffset > 0) + { + buffer[bufferOffset++] = '.'; + } + var segment = ParseSegment(path, pathOffset, out pathOffset, out int literalIndex); + switch (segment) + { + case "skjemainnhold": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 13; + + if (literalIndex != -1) + { + // Copy index from path to buffer + buffer[bufferOffset++] = '['; + if (!literalIndex.TryFormat(buffer[bufferOffset..], out int charsWritten)) + { + throw new global::System.FormatException($"Invalid index in {path}."); + } + + bufferOffset += charsWritten; + buffer[bufferOffset++] = ']'; + rowIndexes = default; + } + else if (rowIndexes.Length >= 1) + { + // Write index from rowIndexes to buffer + buffer[bufferOffset++] = '['; + rowIndexes[0].TryFormat(buffer.Slice(bufferOffset), out int charsWritten); + bufferOffset += charsWritten; + buffer[bufferOffset++] = ']'; + rowIndexes = rowIndexes.Slice(1); + } + else if (pathOffset == -1) + { + // No more segments in the path, and the last part is valid in a list + // without index (e.g. "model.listProperty" is valid, but "model.listProperty.val" needs an index) + return; + } + else + { + // No index to write, but there are more segments in the path + // thus the path is not valid + bufferOffset = 0; + return; + } + if (pathOffset != -1) + { + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_SkjemaInnhold( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + } + return; + case "eierAdresse": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 11; + if (pathOffset != -1) + { + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_Adresse( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + } + return; + case "skjemanummer": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 12; + return; + case "skjemaversjon": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 13; + return; + default: + bufferOffset = 0; + return; + } + } + + private void AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_SkjemaInnhold( + global::System.ReadOnlySpan path, + int pathOffset, + global::System.ReadOnlySpan rowIndexes, + global::System.Span buffer, + ref int bufferOffset + ) + { + if (bufferOffset > 0) + { + buffer[bufferOffset++] = '.'; + } + var segment = ParseSegment(path, pathOffset, out pathOffset, out int literalIndex); + switch (segment) + { + case "adresse": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 7; + if (pathOffset != -1) + { + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_Adresse( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + } + return; + case "tidligere-adresse": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 17; + + if (literalIndex != -1) + { + // Copy index from path to buffer + buffer[bufferOffset++] = '['; + if (!literalIndex.TryFormat(buffer[bufferOffset..], out int charsWritten)) + { + throw new global::System.FormatException($"Invalid index in {path}."); + } + + bufferOffset += charsWritten; + buffer[bufferOffset++] = ']'; + rowIndexes = default; + } + else if (rowIndexes.Length >= 1) + { + // Write index from rowIndexes to buffer + buffer[bufferOffset++] = '['; + rowIndexes[0].TryFormat(buffer.Slice(bufferOffset), out int charsWritten); + bufferOffset += charsWritten; + buffer[bufferOffset++] = ']'; + rowIndexes = rowIndexes.Slice(1); + } + else if (pathOffset == -1) + { + // No more segments in the path, and the last part is valid in a list + // without index (e.g. "model.listProperty" is valid, but "model.listProperty.val" needs an index) + return; + } + else + { + // No index to write, but there are more segments in the path + // thus the path is not valid + bufferOffset = 0; + return; + } + if (pathOffset != -1) + { + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_Adresse( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + } + return; + case "altinnRowId": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 11; + return; + case "navn": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 4; + return; + case "alder": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 5; + return; + case "deltar": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 6; + return; + default: + bufferOffset = 0; + return; + } + } + + private void AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_Adresse( + global::System.ReadOnlySpan path, + int pathOffset, + global::System.ReadOnlySpan rowIndexes, + global::System.Span buffer, + ref int bufferOffset + ) + { + if (bufferOffset > 0) + { + buffer[bufferOffset++] = '.'; + } + var segment = ParseSegment(path, pathOffset, out pathOffset, out int literalIndex); + switch (segment) + { + case "altinnRowId": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 11; + return; + case "gate": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 4; + return; + case "postnummer": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 10; + return; + case "poststed": + segment.CopyTo(buffer.Slice(bufferOffset)); + bufferOffset += 8; + return; + default: + bufferOffset = 0; + return; + } + } + + #endregion AddIndexToPath + #region Copy + + /// + public global::Altinn.App.Core.Features.IFormDataWrapper Copy() + { + return new Altinn_App_SourceGenerator_Tests_SkjemaFormDataWrapper(CopyRecursive(_dataModel)); + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("data")] + private static global::Altinn.App.SourceGenerator.Tests.Skjema? CopyRecursive( + global::Altinn.App.SourceGenerator.Tests.Skjema? data + ) + { + if (data is null) + { + return null; + } + + return new() + { + Skjemanummer = data.Skjemanummer, + Skjemaversjon = data.Skjemaversjon, + Skjemainnhold = CopyRecursive(data.Skjemainnhold), + EierAdresse = CopyRecursive(data.EierAdresse), + }; + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("list")] + private static global::System.Collections.Generic.List? CopyRecursive( + global::System.Collections.Generic.List? list + ) + { + if (list is null) + { + return null; + } + + global::System.Collections.Generic.List result = new(list.Count); + foreach (var item in list) + { + result.Add(CopyRecursive(item)); + } + + return result; + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("data")] + private static global::Altinn.App.SourceGenerator.Tests.SkjemaInnhold? CopyRecursive( + global::Altinn.App.SourceGenerator.Tests.SkjemaInnhold? data + ) + { + if (data is null) + { + return null; + } + + return new() + { + AltinnRowId = data.AltinnRowId, + Navn = data.Navn, + Alder = data.Alder, + Deltar = data.Deltar, + Adresse = CopyRecursive(data.Adresse), + TidligereAdresse = CopyRecursive(data.TidligereAdresse), + }; + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("data")] + private static global::Altinn.App.SourceGenerator.Tests.Adresse? CopyRecursive( + global::Altinn.App.SourceGenerator.Tests.Adresse? data + ) + { + if (data is null) + { + return null; + } + + return new() + { + AltinnRowId = data.AltinnRowId, + Gate = data.Gate, + Postnummer = data.Postnummer, + Poststed = data.Poststed, + }; + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("list")] + private static global::System.Collections.Generic.List? CopyRecursive( + global::System.Collections.Generic.List? list + ) + { + if (list is null) + { + return null; + } + + global::System.Collections.Generic.List result = new(list.Count); + foreach (var item in list) + { + result.Add(CopyRecursive(item)); + } + + return result; + } + + #endregion Copy + #region Remove + + /// + public void RemoveField(global::System.ReadOnlySpan path, global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption) + { + if (path.IsEmpty) + { + return; + } + + RemoveRecursive(_dataModel, path, 0, rowRemovalOption); + } + + private static void RemoveRecursive( + global::Altinn.App.SourceGenerator.Tests.Skjema? model, + global::System.ReadOnlySpan path, + int offset, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + switch (ParseSegment(path, offset, out int nextOffset, out int literalIndex)) + { + case "skjemanummer" when (nextOffset is -1) && (literalIndex is -1): + model.Skjemanummer = default; + break; + case "skjemaversjon" when (nextOffset is -1) && (literalIndex is -1): + model.Skjemaversjon = default; + break; + case "skjemainnhold" when (nextOffset is -1) && (literalIndex is -1): + model.Skjemainnhold = default; + break; + case "skjemainnhold": + RemoveRecursive(model.Skjemainnhold, path, nextOffset, literalIndex, rowRemovalOption); + break; + case "eierAdresse" when (nextOffset is -1) && (literalIndex is -1): + model.EierAdresse = default; + break; + case "eierAdresse": + RemoveRecursive(model.EierAdresse, path, nextOffset, rowRemovalOption); + break; + default: + // throw new ArgumentException("{path} is not a valid path."); + return; + } + } + + private static void RemoveRecursive( + global::System.Collections.Generic.List? model, + global::System.ReadOnlySpan path, + int offset, + int index, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + if (index < 0 || index >= model.Count) + { + return; + } + if (offset == -1) + { + switch (rowRemovalOption) + { + case global::Altinn.App.Core.Helpers.RowRemovalOption.DeleteRow: + model.RemoveAt(index); + break; + case global::Altinn.App.Core.Helpers.RowRemovalOption.SetToNull: + model[index] = default!; + break; + } + } + else + { + RemoveRecursive(model?[index], path, offset, rowRemovalOption); + } + } + + private static void RemoveRecursive( + global::Altinn.App.SourceGenerator.Tests.SkjemaInnhold? model, + global::System.ReadOnlySpan path, + int offset, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + switch (ParseSegment(path, offset, out int nextOffset, out int literalIndex)) + { + case "navn" when (nextOffset is -1) && (literalIndex is -1): + model.Navn = default; + break; + case "alder" when (nextOffset is -1) && (literalIndex is -1): + model.Alder = default; + break; + case "deltar" when (nextOffset is -1) && (literalIndex is -1): + model.Deltar = default; + break; + case "adresse" when (nextOffset is -1) && (literalIndex is -1): + model.Adresse = default; + break; + case "adresse": + RemoveRecursive(model.Adresse, path, nextOffset, rowRemovalOption); + break; + case "tidligere-adresse" when (nextOffset is -1) && (literalIndex is -1): + model.TidligereAdresse = default; + break; + case "tidligere-adresse": + RemoveRecursive(model.TidligereAdresse, path, nextOffset, literalIndex, rowRemovalOption); + break; + default: + // throw new ArgumentException("{path} is not a valid path."); + return; + } + } + + private static void RemoveRecursive( + global::Altinn.App.SourceGenerator.Tests.Adresse? model, + global::System.ReadOnlySpan path, + int offset, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + switch (ParseSegment(path, offset, out int nextOffset, out int literalIndex)) + { + case "gate" when (nextOffset is -1) && (literalIndex is -1): + model.Gate = default; + break; + case "postnummer" when (nextOffset is -1) && (literalIndex is -1): + model.Postnummer = default; + break; + case "poststed" when (nextOffset is -1) && (literalIndex is -1): + model.Poststed = default; + break; + default: + // throw new ArgumentException("{path} is not a valid path."); + return; + } + } + + private static void RemoveRecursive( + global::System.Collections.Generic.List? model, + global::System.ReadOnlySpan path, + int offset, + int index, + global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption + ) + { + if (model is null) + { + return; + } + if (index < 0 || index >= model.Count) + { + return; + } + if (offset == -1) + { + switch (rowRemovalOption) + { + case global::Altinn.App.Core.Helpers.RowRemovalOption.DeleteRow: + model.RemoveAt(index); + break; + case global::Altinn.App.Core.Helpers.RowRemovalOption.SetToNull: + model[index] = default!; + break; + } + } + else + { + RemoveRecursive(model?[index], path, offset, rowRemovalOption); + } + } + + #endregion Remove + #region AltinnRowIds + + /// + public void RemoveAltinnRowIds() + { + SetAltinnRowIds(_dataModel, initialize: false); + } + + /// + public void InitializeAltinnRowIds() + { + SetAltinnRowIds(_dataModel, initialize: true); + } + + private static void SetAltinnRowIds(global::Altinn.App.SourceGenerator.Tests.Skjema dataModel, bool initialize) + { + if(dataModel.Skjemainnhold is not null) + { + foreach (var item in dataModel.Skjemainnhold) + { + if (item is not null) + { + SetAltinnRowIds(item, initialize); + } + } + } + if(dataModel.EierAdresse is not null) + { + SetAltinnRowIds(dataModel.EierAdresse, initialize); + } + } + + private static void SetAltinnRowIds(global::Altinn.App.SourceGenerator.Tests.SkjemaInnhold dataModel, bool initialize) + { + if (!initialize) + { + dataModel.AltinnRowId = global::System.Guid.Empty; + } + else if (dataModel.AltinnRowId == global::System.Guid.Empty) + { + dataModel.AltinnRowId = global::System.Guid.NewGuid(); + } + if(dataModel.Adresse is not null) + { + SetAltinnRowIds(dataModel.Adresse, initialize); + } + if(dataModel.TidligereAdresse is not null) + { + foreach (var item in dataModel.TidligereAdresse) + { + if (item is not null) + { + SetAltinnRowIds(item, initialize); + } + } + } + } + + private static void SetAltinnRowIds(global::Altinn.App.SourceGenerator.Tests.Adresse dataModel, bool initialize) + { + if (!initialize) + { + dataModel.AltinnRowId = global::System.Guid.Empty; + } + else if (dataModel.AltinnRowId == global::System.Guid.Empty) + { + dataModel.AltinnRowId = global::System.Guid.NewGuid(); + } + } + + #endregion AltinnRowIds + #region XmlStorage + + /// + public void PrepareModelForXmlStorage() + { + global::Altinn.App.Core.Helpers.ObjectUtils.PrepareModelForXmlStorage(_dataModel); + } + + #endregion XmlStorage + public static global::System.ReadOnlySpan ParseSegment(global::System.ReadOnlySpan path, int offset, out int nextOffset, out int literalIndex) + { + if (offset < 0 || offset >= path.Length) + { + throw new global::System.ArgumentOutOfRangeException(nameof(offset)); + } + + for (int i = offset; i < path.Length; i++) + { + switch (path[i]) + { + case '.': + nextOffset = i + 1; + literalIndex = -1; + return path[offset..i]; + case '[': + literalIndex = GetIndex(path, i + 1, out nextOffset); + return path[offset..i]; + } + } + + // No markers found. Just return the rest of the path. + nextOffset = -1; + literalIndex = -1; + return path[offset..]; + } + + private static int GetIndex(global::System.ReadOnlySpan path, int offset, out int nextOffset) + { + var segment = path[offset..]; + var bracketOffset = global::System.MemoryExtensions.IndexOf(segment, ']'); + if (bracketOffset < 0) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Missing closing bracket ']' in {path}."); + } + + if (!int.TryParse(segment[..bracketOffset], out var index)) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Invalid index in {path}."); + } + + if (index < 0) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Invalid negative index in {path}."); + } + + nextOffset = offset + bracketOffset + 2; + if (nextOffset >= path.Length) + { + nextOffset = -1; + } + + return index; + } +} + +// -------------------------------------------------- +// ModelPathNode as json (for debugging) +// -------------------------------------------------- +// +// { +// "JsonName": "", +// "CSharpName": "", +// "TypeName": "global::Altinn.App.SourceGenerator.Tests.Skjema", +// "Properties": [ +// { +// "JsonName": "skjemanummer", +// "CSharpName": "Skjemanummer", +// "TypeName": "global::System.String", +// }, +// { +// "JsonName": "skjemaversjon", +// "CSharpName": "Skjemaversjon", +// "TypeName": "global::System.String", +// }, +// { +// "JsonName": "skjemainnhold", +// "CSharpName": "Skjemainnhold", +// "TypeName": "global::Altinn.App.SourceGenerator.Tests.SkjemaInnhold", +// "ListType": "global::System.Collections.Generic.List", +// "Properties": [ +// { +// "JsonName": "altinnRowId", +// "CSharpName": "AltinnRowId", +// "TypeName": "global::System.Guid", +// }, +// { +// "JsonName": "navn", +// "CSharpName": "Navn", +// "TypeName": "global::System.String", +// }, +// { +// "JsonName": "alder", +// "CSharpName": "Alder", +// "TypeName": "global::System.Int32", +// }, +// { +// "JsonName": "deltar", +// "CSharpName": "Deltar", +// "TypeName": "global::System.Boolean", +// }, +// { +// "JsonName": "adresse", +// "CSharpName": "Adresse", +// "TypeName": "global::Altinn.App.SourceGenerator.Tests.Adresse", +// "Properties": [ +// { +// "JsonName": "altinnRowId", +// "CSharpName": "AltinnRowId", +// "TypeName": "global::System.Guid", +// }, +// { +// "JsonName": "gate", +// "CSharpName": "Gate", +// "TypeName": "global::System.String", +// }, +// { +// "JsonName": "postnummer", +// "CSharpName": "Postnummer", +// "TypeName": "global::System.Int32", +// }, +// { +// "JsonName": "poststed", +// "CSharpName": "Poststed", +// "TypeName": "global::System.String", +// } +// ] +// }, +// { +// "JsonName": "tidligere-adresse", +// "CSharpName": "TidligereAdresse", +// "TypeName": "global::Altinn.App.SourceGenerator.Tests.Adresse", +// "ListType": "global::System.Collections.Generic.List", +// "Properties": [ +// { +// "JsonName": "altinnRowId", +// "CSharpName": "AltinnRowId", +// "TypeName": "global::System.Guid", +// }, +// { +// "JsonName": "gate", +// "CSharpName": "Gate", +// "TypeName": "global::System.String", +// }, +// { +// "JsonName": "postnummer", +// "CSharpName": "Postnummer", +// "TypeName": "global::System.Int32", +// }, +// { +// "JsonName": "poststed", +// "CSharpName": "Poststed", +// "TypeName": "global::System.String", +// } +// ] +// } +// ] +// }, +// { +// "JsonName": "eierAdresse", +// "CSharpName": "EierAdresse", +// "TypeName": "global::Altinn.App.SourceGenerator.Tests.Adresse", +// "Properties": [ +// { +// "JsonName": "altinnRowId", +// "CSharpName": "AltinnRowId", +// "TypeName": "global::System.Guid", +// }, +// { +// "JsonName": "gate", +// "CSharpName": "Gate", +// "TypeName": "global::System.String", +// }, +// { +// "JsonName": "postnummer", +// "CSharpName": "Postnummer", +// "TypeName": "global::System.Int32", +// }, +// { +// "JsonName": "poststed", +// "CSharpName": "Poststed", +// "TypeName": "global::System.String", +// } +// ] +// } +// ] +// } diff --git a/test/Altinn.App.SourceGenerator.Tests/SourceTextGeneratorTests.GenerateEmpty.verified.cs b/test/Altinn.App.SourceGenerator.Tests/SourceTextGeneratorTests.GenerateEmpty.verified.cs new file mode 100644 index 0000000000..f064975e25 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/SourceTextGeneratorTests.GenerateEmpty.verified.cs @@ -0,0 +1,193 @@ +// +#nullable enable + +[global::System.CodeDom.Compiler.GeneratedCode("Altinn.App.Analyzers", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +public sealed class Altinn_App_SourceGenerator_Tests_EmptyFormDataWrapper + : global::Altinn.App.Core.Features.IFormDataWrapper +{ + private readonly global::Altinn.App.SourceGenerator.Tests.Empty _dataModel; + + public global::System.Type BackingDataType => typeof(global::Altinn.App.SourceGenerator.Tests.Empty); + + public T BackingData() + where T : class + { + return _dataModel as T + ?? throw new global::System.InvalidCastException( + $"Attempted to cast data model of type Altinn.App.SourceGenerator.Tests.Empty to {typeof(T).FullName}" + ); + } + + public Altinn_App_SourceGenerator_Tests_EmptyFormDataWrapper(object dataModel) + { + _dataModel = + dataModel as global::Altinn.App.SourceGenerator.Tests.Empty + ?? throw new global::System.ArgumentException( + $"Data model must be of type Altinn.App.SourceGenerator.Tests.Empty, (was {dataModel?.GetType().FullName ?? "null"})" + ); + } + + #region Getters + + /// + public object? Get(global::System.ReadOnlySpan path) => null; + + #endregion Getters + #region AddIndexToPath + + /// + public global::System.ReadOnlySpan AddIndexToPath(global::System.ReadOnlySpan path, global::System.ReadOnlySpan rowIndexes, global::System.Span buffer) + { + if(path.IsEmpty) + { + return global::System.ReadOnlySpan.Empty; + } + + var bufferOffset = 0; + var pathOffset = 0; + + AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_Empty( + path, + pathOffset, + rowIndexes, + buffer, + ref bufferOffset + ); + + return buffer[..bufferOffset]; + } + + private void AddIndexToPathRecursive_Altinn_App_SourceGenerator_Tests_Empty( + global::System.ReadOnlySpan path, + int pathOffset, + global::System.ReadOnlySpan rowIndexes, + global::System.Span buffer, + ref int bufferOffset + ) + { + if (bufferOffset > 0) + { + buffer[bufferOffset++] = '.'; + } + var segment = ParseSegment(path, pathOffset, out pathOffset, out int literalIndex); + switch (segment) + { + default: + bufferOffset = 0; + return; + } + } + + #endregion AddIndexToPath + #region Copy + + /// + public global::Altinn.App.Core.Features.IFormDataWrapper Copy() + { + return new Altinn_App_SourceGenerator_Tests_EmptyFormDataWrapper(CopyRecursive(_dataModel)); + } + + [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull("data")] + private static global::Altinn.App.SourceGenerator.Tests.Empty? CopyRecursive( + global::Altinn.App.SourceGenerator.Tests.Empty? data + ) + { + if (data is null) + { + return null; + } + + return new(); + } + + #endregion Copy + #region Remove + + /// + public void RemoveField(global::System.ReadOnlySpan path, global::Altinn.App.Core.Helpers.RowRemovalOption rowRemovalOption) { } + + #endregion Remove + #region AltinnRowIds + + /// + public void RemoveAltinnRowIds() { } + + /// + public void InitializeAltinnRowIds() { } + + #endregion AltinnRowIds + #region XmlStorage + + /// + public void PrepareModelForXmlStorage() + { + global::Altinn.App.Core.Helpers.ObjectUtils.PrepareModelForXmlStorage(_dataModel); + } + + #endregion XmlStorage + public static global::System.ReadOnlySpan ParseSegment(global::System.ReadOnlySpan path, int offset, out int nextOffset, out int literalIndex) + { + if (offset < 0 || offset >= path.Length) + { + throw new global::System.ArgumentOutOfRangeException(nameof(offset)); + } + + for (int i = offset; i < path.Length; i++) + { + switch (path[i]) + { + case '.': + nextOffset = i + 1; + literalIndex = -1; + return path[offset..i]; + case '[': + literalIndex = GetIndex(path, i + 1, out nextOffset); + return path[offset..i]; + } + } + + // No markers found. Just return the rest of the path. + nextOffset = -1; + literalIndex = -1; + return path[offset..]; + } + + private static int GetIndex(global::System.ReadOnlySpan path, int offset, out int nextOffset) + { + var segment = path[offset..]; + var bracketOffset = global::System.MemoryExtensions.IndexOf(segment, ']'); + if (bracketOffset < 0) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Missing closing bracket ']' in {path}."); + } + + if (!int.TryParse(segment[..bracketOffset], out var index)) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Invalid index in {path}."); + } + + if (index < 0) + { + throw new global::Altinn.App.Core.Helpers.DataModel.DataModelException($"Invalid negative index in {path}."); + } + + nextOffset = offset + bracketOffset + 2; + if (nextOffset >= path.Length) + { + nextOffset = -1; + } + + return index; + } +} + +// -------------------------------------------------- +// ModelPathNode as json (for debugging) +// -------------------------------------------------- +// +// { +// "JsonName": "", +// "CSharpName": "", +// "TypeName": "global::Altinn.App.SourceGenerator.Tests.Empty", +// } diff --git a/test/Altinn.App.SourceGenerator.Tests/SourceTextGeneratorTests.cs b/test/Altinn.App.SourceGenerator.Tests/SourceTextGeneratorTests.cs new file mode 100644 index 0000000000..1b9bd78957 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/SourceTextGeneratorTests.cs @@ -0,0 +1,99 @@ +using System.Reflection; +using System.Text; +using System.Text.Json.Serialization; +using Altinn.App.Analyzers; +using Altinn.App.Analyzers.SourceTextGenerator; +using Xunit.Abstractions; + +namespace Altinn.App.SourceGenerator.Tests; + +public class SourceTextGeneratorTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task Generate() + { + var rootNode = GetRoot(); + + var text = SourceTextGenerator.GenerateSourceText(rootNode); + outputHelper.WriteLine(AddLineNumbers(text)); + + await Verify(text, extension: "cs"); + } + + [Fact] + public async Task GenerateEmpty() + { + var rootNode = GetRoot(); + + var text = SourceTextGenerator.GenerateSourceText(rootNode); + outputHelper.WriteLine(AddLineNumbers(text)); + + await Verify(text, extension: "cs"); + } + + private string AddLineNumbers(string text) + { + var lines = text.Split('\n'); + var bytes = 0; + var sb = new StringBuilder(); + for (int i = 0; i < lines.Length; i++) + { + sb.Append($"{i + 1, 4} {$"({bytes})", -6}: {lines[i]}\n"); + bytes += lines[i].Length + 1; + } + return sb.ToString(); + } + + private ModelPathNode GetRoot() + { + var children = typeof(T).GetProperties().Select(GetFromType).ToArray(); + return new ModelPathNode("", "", "global::" + typeof(T).FullName!, children); + } + + private ModelPathNode GetFromType(PropertyInfo propertyInfo) + { + var propertyType = propertyInfo.PropertyType; + var cSharpName = propertyInfo.Name; + var jsonPath = propertyInfo.GetCustomAttribute()?.Name ?? cSharpName; + + string? listType = null; + var typeString = FullTypeName(propertyType); + var collectionInterface = propertyType.GetInterface("System.Collections.Generic.ICollection`1"); + + if (collectionInterface != null) + { + var typeParam = collectionInterface.GetGenericArguments()[0]; + typeString = FullTypeName(typeParam); + listType = $"{FullTypeName(propertyType.GetGenericTypeDefinition())}<{typeString}>"; + var children = GetChildren(typeParam); + + return new ModelPathNode(cSharpName, jsonPath, typeString, children, listType: listType); + } + else + { + var children = GetChildren(propertyType); + + return new ModelPathNode(cSharpName, jsonPath, typeString, children); + } + } + + private static string FullTypeName(Type typeParam) + { + if (typeParam.Name == "Nullable`1") + { + typeParam = typeParam.GenericTypeArguments[0]; + } + return "global::" + typeParam.FullName?.Replace("`1", ""); + } + + private ModelPathNode[] GetChildren(Type propertyType) + { + if (propertyType.Namespace?.StartsWith("System") == true) + { + return []; + } + var properties = propertyType.GetProperties(); + var children = properties.Select(GetFromType).ToArray(); + return children; + } +} diff --git a/test/Altinn.App.SourceGenerator.Tests/models.cs b/test/Altinn.App.SourceGenerator.Tests/models.cs new file mode 100644 index 0000000000..fcb3181d60 --- /dev/null +++ b/test/Altinn.App.SourceGenerator.Tests/models.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.SourceGenerator.Tests; + +public class Skjema +{ + [JsonPropertyName("skjemanummer")] + public string? Skjemanummer { get; set; } + + [JsonPropertyName("skjemaversjon")] + public string? Skjemaversjon { get; set; } + + [JsonPropertyName("skjemainnhold")] + public List? Skjemainnhold { get; set; } + + [JsonPropertyName("eierAdresse")] + public Adresse? EierAdresse { get; set; } +} + +public class SkjemaInnhold +{ + [JsonPropertyName("altinnRowId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Guid AltinnRowId { get; set; } + + [JsonPropertyName("navn")] + public string? Navn { get; set; } + + [JsonPropertyName("alder")] + public int? Alder { get; set; } + + [JsonPropertyName("deltar")] + public bool? Deltar { get; set; } + + [JsonPropertyName("adresse")] + public Adresse? Adresse { get; set; } + + [JsonPropertyName("tidligere-adresse")] + public List? TidligereAdresse { get; set; } +} + +public class Adresse +{ + [JsonPropertyName("altinnRowId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public Guid AltinnRowId { get; set; } + + [JsonPropertyName("gate")] + public string? Gate { get; set; } + + [JsonPropertyName("postnummer")] + public int? Postnummer { get; set; } + + [JsonPropertyName("poststed")] + public string? Poststed { get; set; } +} + +public class Empty { }