From d24d890419f92a909f54ba8eaf43bc170722af27 Mon Sep 17 00:00:00 2001 From: Brian Postlethwaite Date: Tue, 24 Jun 2025 21:16:34 +1000 Subject: [PATCH 01/78] Functions and child properties the name is an identifier, not a constant (that's the base class of identifier) Permits being able to differentiate in the expression tree between them. --- src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs | 2 +- src/Hl7.FhirPath.Tests/Tests/FhirPathGrammarTest.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs b/src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs index a019b18eed..b6f00e91e7 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs @@ -131,7 +131,7 @@ public static Parser FunctionParameter(string name) public static Parser FunctionInvocation(Expression focus) { return Function(focus) - .Or(WhitespaceOrComments().Then(wsLeading => Lexer.Identifier.Select(i => new ConstantExpression(i).WithLeadingWS(wsLeading)).Positioned()).Select(i => new ChildExpression(focus, i)).Positioned()) + .Or(WhitespaceOrComments().Then(wsLeading => Lexer.Identifier.Select(i => new IdentifierExpression(i).WithLeadingWS(wsLeading)).Positioned()).Select(i => new ChildExpression(focus, i)).Positioned()) //.XOr(Lexer.Axis.Select(a => new AxisExpression(a))) ; } diff --git a/src/Hl7.FhirPath.Tests/Tests/FhirPathGrammarTest.cs b/src/Hl7.FhirPath.Tests/Tests/FhirPathGrammarTest.cs index cf63bc92c6..376128d989 100644 --- a/src/Hl7.FhirPath.Tests/Tests/FhirPathGrammarTest.cs +++ b/src/Hl7.FhirPath.Tests/Tests/FhirPathGrammarTest.cs @@ -58,7 +58,7 @@ public void FhirPath_Gramm_Invocation() AxisExpression.This, AxisExpression.Index, new FunctionCallExpression(AxisExpression.That, "somethingElse", TypeSpecifier.Any, new ConstantExpression(true)))); - AssertParser.SucceedsMatch(parser, "as(Patient)", new FunctionCallExpression(AxisExpression.That, "as", TypeSpecifier.Any, new ConstantExpression("Patient"))); + AssertParser.SucceedsMatch(parser, "as(Patient)", new FunctionCallExpression(AxisExpression.That, "as", TypeSpecifier.Any, new IdentifierExpression("Patient"))); var fexRaw = parser.Parse("as(Patient)"); if (fexRaw is FunctionCallExpression fex) From 269c6e3ad62c8cfdd80f1c5953375c6c46278560 Mon Sep 17 00:00:00 2001 From: Brian Postlethwaite Date: Tue, 24 Jun 2025 21:18:11 +1000 Subject: [PATCH 02/78] ChildExpression name is an identifier (derived from constant) --- src/Hl7.Fhir.Base/FhirPath/Expressions/ExpressionNode.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/ExpressionNode.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/ExpressionNode.cs index 1a34650a26..effcf16b41 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/ExpressionNode.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/ExpressionNode.cs @@ -356,12 +356,12 @@ public string DebuggerDisplay public class ChildExpression : FunctionCallExpression, Sprache.IPositionAware { public ChildExpression(Expression focus, string name) : base(focus, OP_PREFIX + "children", TypeSpecifier.Any, - new ConstantExpression(name, TypeSpecifier.String)) + new IdentifierExpression(name, TypeSpecifier.String)) { } public ChildExpression(Expression focus, string name, ISourcePositionInfo location) : base(focus, OP_PREFIX + "children", TypeSpecifier.Any, - new ConstantExpression(name, TypeSpecifier.String), location) + new IdentifierExpression(name, TypeSpecifier.String), location) { } From 4d45c55df631a6a676a153375596d77a74f5a12d Mon Sep 17 00:00:00 2001 From: Brian Postlethwaite Date: Wed, 25 Jun 2025 14:17:32 +1000 Subject: [PATCH 03/78] Initial draft of injecting a debug tracer. only has minor impact on expression compilation, and no effect on runtime if no debugger injected. --- src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs | 146 ++++++++++++++++++ .../FhirPath/Expressions/Closure.cs | 5 + .../FhirPath/Expressions/EvaluatorVisitor.cs | 48 ++++-- 3 files changed, 183 insertions(+), 16 deletions(-) create mode 100644 src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs diff --git a/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs b/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs new file mode 100644 index 0000000000..e99a133f0f --- /dev/null +++ b/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2015, Firely (info@fire.ly) and contributors + * See the file CONTRIBUTORS for details. + * + * This file is licensed under the BSD 3-Clause license + * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE + */ +using Hl7.Fhir.ElementModel; +using Hl7.Fhir.Utility; +using Hl7.FhirPath.Expressions; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using FP = Hl7.FhirPath.Expressions; + +namespace Hl7.FhirPath +{ + public delegate void DebugTraceDelegate(Expression expr, + IEnumerable focus, + IEnumerable thisValue, + ITypedElement index, + IEnumerable totalValue, + IEnumerable result, + IEnumerable>> variables); + + public class DebugTracer + { + + public static void TraceCall( + Expression expr, + IEnumerable focus, + IEnumerable thisValue, + ITypedElement index, + IEnumerable totalValue, + IEnumerable result, + IEnumerable>> variables) + { + string exprName; + if (expr is IdentifierExpression ie) + return; + + if (expr is ConstantExpression ce) + { + System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},constant"); + exprName = "constant"; + } + else if (expr is ChildExpression child) + { + System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{child.ChildName}"); + exprName = child.ChildName; + } + else if (expr is IndexerExpression indexer) + { + System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},[]"); + exprName = "[]"; + } + else if (expr is UnaryExpression ue) + { + System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{ue.Op}"); + exprName = ue.Op; + } + else if (expr is BinaryExpression be) + { + System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{be.Op}"); + exprName = be.Op; + } + else if (expr is FunctionCallExpression fe) + { + System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{fe.FunctionName}"); + exprName = fe.FunctionName; + } + else if (expr is NewNodeListInitExpression) + { + System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{{}} (empty)"); + exprName = "{}"; + } + else if (expr is AxisExpression ae) + { + if (ae.AxisName == "that") + return; + System.Diagnostics.Trace.WriteLine($"Evaluated: {ae.AxisName} results: {result.Count()}"); + exprName = "$" + ae.AxisName; + } + else if (expr is VariableRefExpression ve) + { + System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},%{ve.Name}"); + exprName = "%" + ve.Name; + } + else + { + exprName = expr.GetType().Name; + Debugger.Break(); + throw new Exception($"Unknown expression type: {expr.GetType().Name}"); + // System.Diagnostics.Trace.WriteLine($"Evaluated: {expr} results: {result.Count()}"); + } + + if (focus != null) + { + foreach (var item in focus) + { + DebugTraceValue($"$focus", item); + } + } + + if (thisValue != null) + { + foreach (var item in thisValue) + { + DebugTraceValue("$this", item); + } + } + + if (index != null) + { + DebugTraceValue("$index", index); + } + + if (totalValue != null) + { + foreach (var item in totalValue) + { + DebugTraceValue($"{exprName} »", item); + } + } + + if (result != null) + { + foreach (var item in result) + { + DebugTraceValue($"{exprName} »", item); + } + } + } + + private static void DebugTraceValue(string exprName, ITypedElement item) + { + if (item == null) + return; // possible with a null focus to kick things off + if (item.Location == "@primitivevalue@" || item.Location == "@QuantityAsPrimitiveValue@") + System.Diagnostics.Trace.WriteLine($" {exprName}:\t{item.Value}\t({item.InstanceType})"); + else + System.Diagnostics.Trace.WriteLine($" {exprName}:\t{item.Value}\t({item.InstanceType})\t{item.Location}"); + } + } +} diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs index 650f7e9175..15923843dc 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs @@ -63,6 +63,11 @@ public static Closure Root(ITypedElement root, EvaluationContext ctx = null) private Dictionary> _namedValues = new Dictionary>(); + internal IEnumerable>> Variables() + { + return _namedValues; + } + public virtual void SetValue(string name, IEnumerable value) { _namedValues.Remove(name); diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs index 4c4fa60ab8..ca07cca1d4 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs @@ -16,24 +16,40 @@ namespace Hl7.FhirPath.Expressions { internal class EvaluatorVisitor : FP.ExpressionVisitor { + private Invokee WrapForDebugTracer(Invokee invokee, Expression expression) + { + if (_debugTrace != null) + { + return (Closure context, IEnumerable arguments) => { + var result = invokee(context, arguments); + var focus = context.GetThat(); + _debugTrace(expression, focus, context.GetThis(), context.GetIndex()?.FirstOrDefault(), context.GetTotal(), result, context.Variables()); + return result; + }; + } + return invokee; + } + public SymbolTable Symbols { get; } + private DebugTraceDelegate _debugTrace; - public EvaluatorVisitor(SymbolTable symbols) + public EvaluatorVisitor(SymbolTable symbols, DebugTraceDelegate debugTrace = null) { Symbols = symbols; + _debugTrace = debugTrace; } public override Invokee VisitConstant(FP.ConstantExpression expression) { - return InvokeeFactory.Return(ElementNode.ForPrimitive(expression.Value)); + return WrapForDebugTracer(InvokeeFactory.Return(ElementNode.ForPrimitive(expression.Value)), expression); } public override Invokee VisitFunctionCall(FP.FunctionCallExpression expression) { - var focus = expression.Focus.ToEvaluator(Symbols); + var focus = expression.Focus.ToEvaluator(Symbols, _debugTrace); var arguments = new List() { focus }; - arguments.AddRange(expression.Arguments.Select(arg => arg.ToEvaluator(Symbols))); + arguments.AddRange(expression.Arguments.Select(arg => arg.ToEvaluator(Symbols, _debugTrace))); // We have no real type information, so just pass object as the type var types = new List() { typeof(object) }; // for the focus; @@ -42,19 +58,19 @@ public override Invokee VisitFunctionCall(FP.FunctionCallExpression expression) // Now locate the function based on the types and name Invokee boundFunction = resolve(Symbols, expression.FunctionName, types); - return InvokeeFactory.Invoke(expression.FunctionName, arguments, boundFunction); + return WrapForDebugTracer(InvokeeFactory.Invoke(expression.FunctionName, arguments, boundFunction), expression); } public override Invokee VisitNewNodeListInit(FP.NewNodeListInitExpression expression) { - return InvokeeFactory.Return(ElementNode.EmptyList); + return WrapForDebugTracer(InvokeeFactory.Return(ElementNode.EmptyList), expression); } public override Invokee VisitVariableRef(FP.VariableRefExpression expression) { // HACK, for now, $this is special, and we handle in run-time, not compile time... if (expression.Name == "builtin.this") - return InvokeeFactory.GetThis; + return WrapForDebugTracer(InvokeeFactory.GetThis, expression); // HACK, for now, $this is special, and we handle in run-time, not compile time... if (expression.Name == "builtin.that") @@ -62,25 +78,25 @@ public override Invokee VisitVariableRef(FP.VariableRefExpression expression) // HACK, for now, $total is special, and we handle in run-time, not compile time... if (expression.Name == "builtin.total") - return InvokeeFactory.GetTotal; + return WrapForDebugTracer(InvokeeFactory.GetTotal, expression); // HACK, for now, $index is special, and we handle in run-time, not compile time... if (expression.Name == "builtin.index") - return InvokeeFactory.GetIndex; + return WrapForDebugTracer(InvokeeFactory.GetIndex, expression); // HACK, for now, %context is special, and we handle in run-time, not compile time... if (expression.Name == "context") - return InvokeeFactory.GetContext; + return WrapForDebugTracer(InvokeeFactory.GetContext, expression); // HACK, for now, %resource is special, and we handle in run-time, not compile time... if (expression.Name == "resource") - return InvokeeFactory.GetResource; + return WrapForDebugTracer(InvokeeFactory.GetResource, expression); // HACK, for now, %rootResource is special, and we handle in run-time, not compile time... if (expression.Name == "rootResource") - return InvokeeFactory.GetRootResource; + return WrapForDebugTracer(InvokeeFactory.GetRootResource, expression); - return chainResolves; + return WrapForDebugTracer(chainResolves, expression); IEnumerable chainResolves(Closure context, IEnumerable invokees) { @@ -123,11 +139,11 @@ private static Invokee resolve(SymbolTable scope, string name, IEnumerable internal static class EvaluatorExpressionExtensions { - public static Invokee ToEvaluator(this FP.Expression expr, SymbolTable scope) + public static Invokee ToEvaluator(this FP.Expression expr, SymbolTable scope, DebugTraceDelegate debugTrace = null) { - var compiler = new EvaluatorVisitor(scope); + // TODO: Brian: Defaulting in the example tracer to show how it works (and good for unit test debugging) + var compiler = new EvaluatorVisitor(scope, debugTrace ?? DebugTracer.TraceCall); return expr.Accept(compiler); } } - } From e8bd31b3948d32ca361a0b77c1f0dcfadbfd2c7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Jul 2025 20:53:41 +0000 Subject: [PATCH 04/78] Initial plan From e34f676f4a615fddf69a98de2be92c4d3675c35c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Jul 2025 20:54:39 +0000 Subject: [PATCH 05/78] Initial plan From 8ec009948a49d219ef2f956838ee8b23515f325c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Jul 2025 21:15:14 +0000 Subject: [PATCH 06/78] Add factory pattern for thread-safe serialization filters Co-authored-by: ewoutkramer <2276305+ewoutkramer@users.noreply.github.com> --- .../BaseFhirJsonPocoSerializer.cs | 5 +- .../FhirJsonPocoSerializerSettings.cs | 7 + .../Serialization/SerializationFilter.cs | 61 +++++++++ .../SummaryFilterThreadSafetyTests.cs | 129 ++++++++++++++++++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs diff --git a/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs b/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs index 0d978d33d9..968888c5e7 100644 --- a/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs +++ b/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs @@ -89,7 +89,10 @@ private void serializeInternal( bool skipValue) { writer.WriteStartObject(); - var filter = Settings.SummaryFilter; + // Use factory if available, otherwise fall back to the static instance for backward compatibility +#pragma warning disable CS0618 // Type or member is obsolete + var filter = Settings.SummaryFilterFactory?.Invoke() ?? Settings.SummaryFilter; +#pragma warning restore CS0618 // Type or member is obsolete if (members is Resource r) writer.WriteString("resourceType", r.TypeName); diff --git a/src/Hl7.Fhir.Base/Serialization/FhirJsonPocoSerializerSettings.cs b/src/Hl7.Fhir.Base/Serialization/FhirJsonPocoSerializerSettings.cs index 5987f242c7..01e9d32d47 100644 --- a/src/Hl7.Fhir.Base/Serialization/FhirJsonPocoSerializerSettings.cs +++ b/src/Hl7.Fhir.Base/Serialization/FhirJsonPocoSerializerSettings.cs @@ -27,7 +27,14 @@ public record FhirJsonPocoSerializerSettings /// /// Specifies the filter to use for summary serialization. /// + [Obsolete("Use SummaryFilterFactory instead to ensure thread-safety when reusing JsonSerializerOptions instances. This property will be removed in a future version.")] public SerializationFilter? SummaryFilter { get; set; } = default; + + /// + /// Specifies a factory function that creates a new filter instance for each serialization operation. + /// This ensures thread-safety when reusing JsonSerializerOptions instances in concurrent scenarios. + /// + public Func? SummaryFilterFactory { get; set; } = default; } } diff --git a/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs b/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs index 5873a2787d..044239f1e0 100644 --- a/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs +++ b/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs @@ -9,6 +9,7 @@ #nullable enable using Hl7.Fhir.Introspection; +using System; namespace Hl7.Fhir.Serialization { @@ -41,11 +42,13 @@ public abstract class SerializationFilter /// /// Construct a new filter that conforms to the `_summary=true` summarized form. /// + [Obsolete("Use CreateSummaryFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] public static SerializationFilter ForSummary() => new BundleFilter(new ElementMetadataFilter() { IncludeInSummary = true }); /// /// Construct a new filter that conforms to the `_summary=text` summarized form. /// + [Obsolete("Use CreateTextFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] public static SerializationFilter ForText() => new BundleFilter(new TopLevelFilter( new ElementMetadataFilter() { @@ -53,6 +56,7 @@ public abstract class SerializationFilter IncludeMandatory = true })); + [Obsolete("Use CreateCountFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] public static SerializationFilter ForCount() => new BundleFilter(new TopLevelFilter( new ElementMetadataFilter() { @@ -63,6 +67,7 @@ public abstract class SerializationFilter /// /// Construct a new filter that conforms to the `_summary=data` summarized form. /// + [Obsolete("Use CreateDataFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] public static SerializationFilter ForData() => new BundleFilter(new TopLevelFilter( new ElementMetadataFilter() { @@ -73,12 +78,68 @@ public abstract class SerializationFilter /// /// Construct a new filter that conforms to the `_elements=...` summarized form. /// + [Obsolete("Use CreateElementsFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] public static SerializationFilter ForElements(string[] elements) => new BundleFilter(new TopLevelFilter( new ElementMetadataFilter() { IncludeNames = elements, IncludeMandatory = true })); + + /// + /// Create a factory function that produces new filter instances conforming to the `_summary=true` summarized form. + /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. + /// + public static Func CreateSummaryFactory() => + () => new BundleFilter(new ElementMetadataFilter() { IncludeInSummary = true }); + + /// + /// Create a factory function that produces new filter instances conforming to the `_summary=text` summarized form. + /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. + /// + public static Func CreateTextFactory() => + () => new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeNames = new[] { "text", "id", "meta" }, + IncludeMandatory = true + })); + + /// + /// Create a factory function that produces new filter instances conforming to the `_summary=count` summarized form. + /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. + /// + public static Func CreateCountFactory() => + () => new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeMandatory = true, + IncludeNames = new[] { "id", "total", "link" } + })); + + /// + /// Create a factory function that produces new filter instances conforming to the `_summary=data` summarized form. + /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. + /// + public static Func CreateDataFactory() => + () => new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeNames = new[] { "text" }, + Invert = true + })); + + /// + /// Create a factory function that produces new filter instances conforming to the `_elements=...` summarized form. + /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. + /// + public static Func CreateElementsFactory(string[] elements) => + () => new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeNames = elements, + IncludeMandatory = true + })); } } diff --git a/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs b/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs new file mode 100644 index 0000000000..e7b72c2e83 --- /dev/null +++ b/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs @@ -0,0 +1,129 @@ +using FluentAssertions; +using Hl7.Fhir.Model; +using Hl7.Fhir.Serialization; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Concurrent; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Hl7.Fhir.Support.Poco.Tests +{ + [TestClass] + public class SummaryFilterThreadSafetyTests + { + [TestMethod] + public void ConcurrentSerializationWithFactory_ShouldBeThreadSafe() + { + // Arrange + var options = new JsonSerializerOptions() + .ForFhir(typeof(Patient).Assembly, new FhirJsonPocoSerializerSettings + { + SummaryFilterFactory = SerializationFilter.CreateElementsFactory(["id", "active"]) + }) + .Pretty(); + + var patient = new Patient + { + Id = "123", + Active = true, + Name = [new() { Family = "Doe", Given = ["John"] }], + MultipleBirth = new FhirBoolean(false), + }; + var bundle = new Bundle + { + Type = Bundle.BundleType.Collection, + Entry = [new() { Resource = patient }] + }; + + ConcurrentBag serialized = []; + + // Act + Parallel.For(0, 100, i => + { + serialized.Add(JsonSerializer.Serialize(bundle, options)); + }); + + // Assert + serialized.Count.Should().Be(100); + + // All results should include the entry field + var resultsWithEntry = serialized.Where(json => json.Contains("\"entry\"")).Count(); + resultsWithEntry.Should().Be(100, "all results should contain the entry field"); + + // No results should contain unfiltered fields + var resultsWithUnfilteredFields = serialized.Where(json => + json.Contains("\"name\"") || json.Contains("\"multipleBirthBoolean\"")).Count(); + resultsWithUnfilteredFields.Should().Be(0, "no results should contain unfiltered fields"); + + // All results should contain the filtered fields + var resultsWithId = serialized.Where(json => json.Contains("\"id\": \"123\"")).Count(); + var resultsWithActive = serialized.Where(json => json.Contains("\"active\": true")).Count(); + resultsWithId.Should().Be(100, "all results should contain the id field"); + resultsWithActive.Should().Be(100, "all results should contain the active field"); + } + + [TestMethod] + public void ConcurrentSerializationWithLegacyFilter_ShouldShowInconsistentResults() + { + // This test documents the issue with the legacy approach + // and demonstrates why the factory approach is needed + + // Arrange +#pragma warning disable CS0618 // Type or member is obsolete + var options = new JsonSerializerOptions() + .ForFhir(typeof(Patient).Assembly, new FhirJsonPocoSerializerSettings + { + SummaryFilter = SerializationFilter.ForElements(["id", "active"]) + }) + .Pretty(); +#pragma warning restore CS0618 // Type or member is obsolete + + var patient = new Patient + { + Id = "123", + Active = true, + Name = [new() { Family = "Doe", Given = ["John"] }], + MultipleBirth = new FhirBoolean(false), + }; + var bundle = new Bundle + { + Type = Bundle.BundleType.Collection, + Entry = [new() { Resource = patient }] + }; + + ConcurrentBag serialized = []; + + // Act + Parallel.For(0, 100, i => + { + serialized.Add(JsonSerializer.Serialize(bundle, options)); + }); + + // Assert + serialized.Count.Should().Be(100); + + // With legacy approach, many results will be missing the entry field due to race conditions + var resultsWithEntry = serialized.Where(json => json.Contains("\"entry\"")).Count(); + resultsWithEntry.Should().BeLessThan(100, "legacy approach should show inconsistent results due to race conditions"); + } + + [TestMethod] + public void AllFactoryMethods_ShouldCreateNewInstances() + { + // Verify that each factory method call creates a new instance + var summaryFactory = SerializationFilter.CreateSummaryFactory(); + var textFactory = SerializationFilter.CreateTextFactory(); + var countFactory = SerializationFilter.CreateCountFactory(); + var dataFactory = SerializationFilter.CreateDataFactory(); + var elementsFactory = SerializationFilter.CreateElementsFactory(["id", "name"]); + + // Each call should return a different instance + summaryFactory().Should().NotBeSameAs(summaryFactory()); + textFactory().Should().NotBeSameAs(textFactory()); + countFactory().Should().NotBeSameAs(countFactory()); + dataFactory().Should().NotBeSameAs(dataFactory()); + elementsFactory().Should().NotBeSameAs(elementsFactory()); + } + } +} \ No newline at end of file From 6864987f16f710fe9f760a9118d285304735b463 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Jul 2025 21:24:52 +0000 Subject: [PATCH 07/78] Implement canonical version matching for partial versions in FHIR validation Co-authored-by: ewoutkramer <2276305+ewoutkramer@users.noreply.github.com> --- .../Source/InMemoryResourceResolver.cs | 69 ++++++- .../Summary/ArtifactSummaryExtensions.cs | 42 +++- .../Summary/ArtifactSummaryExtensions.cs | 38 +++- .../Source/CanonicalVersionMatchingTests.cs | 189 ++++++++++++++++++ .../Source/ResourceResolverTests.cs | 46 +++++ 5 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 src/Hl7.Fhir.Specification.Shared.Tests/Source/CanonicalVersionMatchingTests.cs diff --git a/src/Hl7.Fhir.Base/Specification/Source/InMemoryResourceResolver.cs b/src/Hl7.Fhir.Base/Specification/Source/InMemoryResourceResolver.cs index 79257b6e62..d29a24ac14 100644 --- a/src/Hl7.Fhir.Base/Specification/Source/InMemoryResourceResolver.cs +++ b/src/Hl7.Fhir.Base/Specification/Source/InMemoryResourceResolver.cs @@ -3,6 +3,7 @@ namespace Hl7.Fhir.Specification.Source { using Hl7.Fhir.Model; + using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -97,7 +98,73 @@ private void add(Resource resource) /// public Resource? ResolveByCanonicalUri(string uri) { - return _resources.Where(r => r.Url == uri)?.Select(r => r.Resource).FirstOrDefault(); + var values = uri.Split('|'); + if (values.Length > 2) + throw new ArgumentException("Url is not valid. The pipe occurs more than once."); + + var canonicalUrl = values[0]; + var version = values.Length == 2 ? values[1] : string.Empty; + + // Filter by canonical URL first + var candidateResources = _resources.Where(r => r.Url == canonicalUrl).ToList(); + + if (!candidateResources.Any()) + return null; + + // If no version specified, return the first match + if (string.IsNullOrEmpty(version)) + { + var firstCandidate = candidateResources.FirstOrDefault(); + return firstCandidate.Resource; + } + + // Look for exact version match or partial version match + foreach (var candidate in candidateResources) + { + if (candidate.Resource is IVersionableConformanceResource versionableConformance) + { + if (MatchesVersion(versionableConformance.Version, version)) + return candidate.Resource; + } + } + + return null; + } + + /// + /// Determines if a resource version matches a query version according to FHIR canonical matching rules. + /// Supports both exact matching and partial version matching (e.g., "1.5" matches "1.5.0"). + /// + /// The version of the resource being checked. + /// The version specified in the canonical URL query. + /// True if the resource version matches the query version according to FHIR canonical matching rules. + private static bool MatchesVersion(string? resourceVersion, string queryVersion) + { + // If either version is null or empty, treat as no version specified + if (string.IsNullOrEmpty(resourceVersion) || string.IsNullOrEmpty(queryVersion)) + return string.IsNullOrEmpty(resourceVersion) && string.IsNullOrEmpty(queryVersion); + + // First try exact match for backwards compatibility and performance + if (resourceVersion == queryVersion) + return true; + + // Implement partial version matching according to FHIR canonical matching rules + // The query version should be a prefix of the resource version when split by dots + var resourceParts = resourceVersion!.Split('.'); + var queryParts = queryVersion.Split('.'); + + // Query version cannot have more parts than resource version for partial matching + if (queryParts.Length > resourceParts.Length) + return false; + + // Check if all query version parts match the corresponding resource version parts + for (int i = 0; i < queryParts.Length; i++) + { + if (resourceParts[i] != queryParts[i]) + return false; + } + + return true; } /// diff --git a/src/Hl7.Fhir.Conformance/Specification/Source/Summary/ArtifactSummaryExtensions.cs b/src/Hl7.Fhir.Conformance/Specification/Source/Summary/ArtifactSummaryExtensions.cs index 1fa599de12..98396f8cbc 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Source/Summary/ArtifactSummaryExtensions.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Source/Summary/ArtifactSummaryExtensions.cs @@ -55,7 +55,7 @@ public static IEnumerable FindConformanceResources(this IEnumer var version = values.Length == 2 ? values[1] : string.Empty; return summaries.ConformanceResources(modelInfo).Where(r => r.GetConformanceCanonicalUrl() == values[0] && - (string.IsNullOrEmpty(version) || r.GetConformanceVersion() == version)); + (string.IsNullOrEmpty(version) || MatchesVersion(r.GetConformanceVersion(), version))); } /// Filter instances for resources with the specified valueSet uri. @@ -248,6 +248,46 @@ private static IEnumerable GetConceptMapUrls(ArtifactSummary conceptMapS throw createException(source, modelInfo); } + /// + /// Determines if a resource version matches a query version according to FHIR canonical matching rules. + /// Supports both exact matching and partial version matching (e.g., "1.5" matches "1.5.0"). + /// + /// The version of the resource being checked. + /// The version specified in the canonical URL query. + /// True if the resource version matches the query version according to FHIR canonical matching rules. + private static bool MatchesVersion(string? resourceVersion, string queryVersion) + { + // If either version is null or empty, treat as no version specified + if (string.IsNullOrEmpty(resourceVersion) || string.IsNullOrEmpty(queryVersion)) + return string.IsNullOrEmpty(resourceVersion) && string.IsNullOrEmpty(queryVersion); + + // At this point, both resourceVersion and queryVersion are guaranteed to be non-null and non-empty + // Use null-forgiving operator since we've already checked for null/empty above + var resourceVersionSafe = resourceVersion!; + + // First try exact match for backwards compatibility and performance + if (resourceVersionSafe == queryVersion) + return true; + + // Implement partial version matching according to FHIR canonical matching rules + // The query version should be a prefix of the resource version when split by dots + var resourceParts = resourceVersionSafe.Split('.'); + var queryParts = queryVersion.Split('.'); + + // Query version cannot have more parts than resource version for partial matching + if (queryParts.Length > resourceParts.Length) + return false; + + // Check if all query version parts match the corresponding resource version parts + for (int i = 0; i < queryParts.Length; i++) + { + if (resourceParts[i] != queryParts[i]) + return false; + } + + return true; + } + #endregion } } diff --git a/src/Hl7.Fhir.STU3/Specification/Source/Summary/ArtifactSummaryExtensions.cs b/src/Hl7.Fhir.STU3/Specification/Source/Summary/ArtifactSummaryExtensions.cs index 7daf7f4cc0..344690d665 100644 --- a/src/Hl7.Fhir.STU3/Specification/Source/Summary/ArtifactSummaryExtensions.cs +++ b/src/Hl7.Fhir.STU3/Specification/Source/Summary/ArtifactSummaryExtensions.cs @@ -57,7 +57,7 @@ public static IEnumerable FindConformanceResources(this IEnumer var version = values.Length == 2 ? values[1] : string.Empty; return summaries.ConformanceResources().Where(r => r.GetConformanceCanonicalUrl() == values[0] && - (string.IsNullOrEmpty(version) || r.GetConformanceVersion() == version)); + (string.IsNullOrEmpty(version) || MatchesVersion(r.GetConformanceVersion(), version))); } /// Filter instances for resources with the specified valueSet uri. @@ -248,6 +248,42 @@ static TSource SingleOrDefault(this IEnumerable source, Func + /// Determines if a resource version matches a query version according to FHIR canonical matching rules. + /// Supports both exact matching and partial version matching (e.g., "1.5" matches "1.5.0"). + /// + /// The version of the resource being checked. + /// The version specified in the canonical URL query. + /// True if the resource version matches the query version according to FHIR canonical matching rules. + private static bool MatchesVersion(string resourceVersion, string queryVersion) + { + // If either version is null or empty, treat as no version specified + if (string.IsNullOrEmpty(resourceVersion) || string.IsNullOrEmpty(queryVersion)) + return string.IsNullOrEmpty(resourceVersion) && string.IsNullOrEmpty(queryVersion); + + // First try exact match for backwards compatibility and performance + if (resourceVersion == queryVersion) + return true; + + // Implement partial version matching according to FHIR canonical matching rules + // The query version should be a prefix of the resource version when split by dots + var resourceParts = resourceVersion.Split('.'); + var queryParts = queryVersion.Split('.'); + + // Query version cannot have more parts than resource version for partial matching + if (queryParts.Length > resourceParts.Length) + return false; + + // Check if all query version parts match the corresponding resource version parts + for (int i = 0; i < queryParts.Length; i++) + { + if (resourceParts[i] != queryParts[i]) + return false; + } + + return true; + } + #endregion } } \ No newline at end of file diff --git a/src/Hl7.Fhir.Specification.Shared.Tests/Source/CanonicalVersionMatchingTests.cs b/src/Hl7.Fhir.Specification.Shared.Tests/Source/CanonicalVersionMatchingTests.cs new file mode 100644 index 0000000000..6f4a727aa6 --- /dev/null +++ b/src/Hl7.Fhir.Specification.Shared.Tests/Source/CanonicalVersionMatchingTests.cs @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2024, Firely (info@fire.ly) and contributors + * See the file CONTRIBUTORS for details. + * + * This file is licensed under the BSD 3-Clause license + * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE + */ + +using Hl7.Fhir.Model; +using Hl7.Fhir.Specification.Source; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.Linq; + +namespace Hl7.Fhir.Specification.Tests +{ + [TestClass] + public class CanonicalVersionMatchingTests + { + private InMemoryResourceResolver CreateTestResolver() + { + var resources = new List + { + new StructureDefinition + { + Url = "http://example.org/StructureDefinition/MyProfile", + Version = "1.5.0", + Name = "MyProfile150" + }, + new StructureDefinition + { + Url = "http://example.org/StructureDefinition/MyProfile", + Version = "1.5.1", + Name = "MyProfile151" + }, + new StructureDefinition + { + Url = "http://example.org/StructureDefinition/MyProfile", + Version = "1.6.0", + Name = "MyProfile160" + }, + new StructureDefinition + { + Url = "http://example.org/StructureDefinition/MyProfile", + Version = "2.0.0", + Name = "MyProfile200" + }, + new StructureDefinition + { + Url = "http://example.org/StructureDefinition/OtherProfile", + Version = "1.5.0", + Name = "OtherProfile150" + } + }; + + return new InMemoryResourceResolver(resources); + } + + [TestMethod] + public void ExactVersionMatching_ShouldWork() + { + // Arrange + var resolver = CreateTestResolver(); + + // Act + var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|1.5.0"); + + // Assert + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result, typeof(StructureDefinition)); + var sd = (StructureDefinition)result; + Assert.AreEqual("1.5.0", sd.Version); + Assert.AreEqual("MyProfile150", sd.Name); + } + + [TestMethod] + public void PartialVersionMatching_ShouldMatchFullVersion() + { + // Arrange + var resolver = CreateTestResolver(); + + // Act - Query with partial version "1.5" should match "1.5.0" or "1.5.1" + var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|1.5"); + + // Assert + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result, typeof(StructureDefinition)); + var sd = (StructureDefinition)result; + // Should match one of the 1.5.x versions + Assert.IsTrue(sd.Version.StartsWith("1.5"), $"Expected version starting with '1.5', but got '{sd.Version}'"); + } + + [TestMethod] + public void PartialVersionMatching_ShouldNotMatchDifferentMajorMinor() + { + // Arrange + var resolver = CreateTestResolver(); + + // Act - Query with partial version "1.4" should not match any resource + var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|1.4"); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void PartialVersionMatching_ShouldNotMatchHigherMajorVersion() + { + // Arrange + var resolver = CreateTestResolver(); + + // Act - Query with partial version "1.5" should not match "2.0.0" + var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|2"); + + // Assert + Assert.IsNotNull(result); + var sd = (StructureDefinition)result; + Assert.AreEqual("2.0.0", sd.Version); + } + + [TestMethod] + public void NoVersionSpecified_ShouldMatchAnyVersion() + { + // Arrange + var resolver = CreateTestResolver(); + + // Act - Query without version should match any version + var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile"); + + // Assert + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result, typeof(StructureDefinition)); + // Should match one of the available versions + } + + [TestMethod] + public void BackwardsCompatibility_ExistingExactMatching_ShouldStillWork() + { + // Arrange + var resolver = CreateTestResolver(); + + // Act - Use exact version matching as before + var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|1.5.1"); + + // Assert - Should still get exact match + Assert.IsNotNull(result); + var sd = (StructureDefinition)result; + Assert.AreEqual("1.5.1", sd.Version); + Assert.AreEqual("MyProfile151", sd.Name); + } + + [TestMethod] + public void NonExistentResource_ShouldReturnNull() + { + // Arrange + var resolver = CreateTestResolver(); + + // Act + var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/NonExistent|1.0"); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void EmptyVersion_ShouldMatchUnversionedResources() + { + // Arrange + var resources = new List + { + new StructureDefinition + { + Url = "http://example.org/StructureDefinition/UnversionedProfile", + Name = "UnversionedProfile" + // Version is null/empty + } + }; + var resolver = new InMemoryResourceResolver(resources); + + // Act + var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/UnversionedProfile"); + + // Assert + Assert.IsNotNull(result); + var sd = (StructureDefinition)result; + Assert.AreEqual("UnversionedProfile", sd.Name); + } + } +} \ No newline at end of file diff --git a/src/Hl7.Fhir.Specification.Shared.Tests/Source/ResourceResolverTests.cs b/src/Hl7.Fhir.Specification.Shared.Tests/Source/ResourceResolverTests.cs index 78a5a2d9b8..2088d632e0 100644 --- a/src/Hl7.Fhir.Specification.Shared.Tests/Source/ResourceResolverTests.cs +++ b/src/Hl7.Fhir.Specification.Shared.Tests/Source/ResourceResolverTests.cs @@ -13,6 +13,7 @@ using Hl7.Fhir.Support; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -363,5 +364,50 @@ public async Tasks.Task TestCanonicalUrlConflicts() Assert.IsTrue(conflictException); } + [TestMethod] + public void PartialVersionMatching_ShouldWork() + { + // Arrange - Create test resources with different versions + var resources = new List + { + new StructureDefinition + { + Url = "http://example.org/StructureDefinition/TestProfile", + Version = "1.5.0", + Name = "TestProfile150" + }, + new StructureDefinition + { + Url = "http://example.org/StructureDefinition/TestProfile", + Version = "1.5.1", + Name = "TestProfile151" + }, + new StructureDefinition + { + Url = "http://example.org/StructureDefinition/TestProfile", + Version = "1.6.0", + Name = "TestProfile160" + } + }; + + var resolver = new InMemoryResourceResolver(resources); + + // Act & Assert - Test exact version matching (should still work) + var exactResult = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/TestProfile|1.5.0"); + Assert.IsNotNull(exactResult); + var exactSd = (StructureDefinition)exactResult; + Assert.AreEqual("1.5.0", exactSd.Version); + + // Act & Assert - Test partial version matching (new functionality) + var partialResult = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/TestProfile|1.5"); + Assert.IsNotNull(partialResult, "Partial version matching should return a result"); + var partialSd = (StructureDefinition)partialResult; + Assert.IsTrue(partialSd.Version.StartsWith("1.5"), $"Expected version starting with '1.5', but got '{partialSd.Version}'"); + + // Act & Assert - Test that wrong partial version returns null + var wrongResult = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/TestProfile|1.4"); + Assert.IsNull(wrongResult, "Non-matching partial version should return null"); + } + } } \ No newline at end of file From c6c5a91c72104726cc7665c26e742c2e1940b731 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Jul 2025 21:34:51 +0000 Subject: [PATCH 08/78] Complete thread-safe serialization filter implementation Co-authored-by: ewoutkramer <2276305+ewoutkramer@users.noreply.github.com> --- .../Serialization/SerializationFilter.cs | 86 ++++++++++++------- .../SummaryFilterThreadSafetyTests.cs | 62 ++----------- 2 files changed, 65 insertions(+), 83 deletions(-) diff --git a/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs b/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs index 044239f1e0..150d0c12fe 100644 --- a/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs +++ b/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs @@ -10,6 +10,7 @@ using Hl7.Fhir.Introspection; using System; +using System.Threading; namespace Hl7.Fhir.Serialization { @@ -90,56 +91,81 @@ public abstract class SerializationFilter /// Create a factory function that produces new filter instances conforming to the `_summary=true` summarized form. /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. /// - public static Func CreateSummaryFactory() => - () => new BundleFilter(new ElementMetadataFilter() { IncludeInSummary = true }); + public static Func CreateSummaryFactory() + { + var threadLocalFilter = new ThreadLocal(() => + new BundleFilter(new ElementMetadataFilter() { IncludeInSummary = true })); + + return () => threadLocalFilter.Value!; + } /// /// Create a factory function that produces new filter instances conforming to the `_summary=text` summarized form. /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. /// - public static Func CreateTextFactory() => - () => new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeNames = new[] { "text", "id", "meta" }, - IncludeMandatory = true - })); + public static Func CreateTextFactory() + { + var threadLocalFilter = new ThreadLocal(() => + new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeNames = new[] { "text", "id", "meta" }, + IncludeMandatory = true + }))); + + return () => threadLocalFilter.Value!; + } /// /// Create a factory function that produces new filter instances conforming to the `_summary=count` summarized form. /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. /// - public static Func CreateCountFactory() => - () => new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeMandatory = true, - IncludeNames = new[] { "id", "total", "link" } - })); + public static Func CreateCountFactory() + { + var threadLocalFilter = new ThreadLocal(() => + new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeMandatory = true, + IncludeNames = new[] { "id", "total", "link" } + }))); + + return () => threadLocalFilter.Value!; + } /// /// Create a factory function that produces new filter instances conforming to the `_summary=data` summarized form. /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. /// - public static Func CreateDataFactory() => - () => new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeNames = new[] { "text" }, - Invert = true - })); + public static Func CreateDataFactory() + { + var threadLocalFilter = new ThreadLocal(() => + new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeNames = new[] { "text" }, + Invert = true + }))); + + return () => threadLocalFilter.Value!; + } /// /// Create a factory function that produces new filter instances conforming to the `_elements=...` summarized form. /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. /// - public static Func CreateElementsFactory(string[] elements) => - () => new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeNames = elements, - IncludeMandatory = true - })); + public static Func CreateElementsFactory(string[] elements) + { + var threadLocalFilter = new ThreadLocal(() => + new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeNames = elements, + IncludeMandatory = true + }))); + + return () => threadLocalFilter.Value!; + } } } diff --git a/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs b/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs index e7b72c2e83..7c8c3d5941 100644 --- a/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs +++ b/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs @@ -64,66 +64,22 @@ public void ConcurrentSerializationWithFactory_ShouldBeThreadSafe() } [TestMethod] - public void ConcurrentSerializationWithLegacyFilter_ShouldShowInconsistentResults() + public void AllFactoryMethods_ShouldUseSameInstancePerThread() { - // This test documents the issue with the legacy approach - // and demonstrates why the factory approach is needed - - // Arrange -#pragma warning disable CS0618 // Type or member is obsolete - var options = new JsonSerializerOptions() - .ForFhir(typeof(Patient).Assembly, new FhirJsonPocoSerializerSettings - { - SummaryFilter = SerializationFilter.ForElements(["id", "active"]) - }) - .Pretty(); -#pragma warning restore CS0618 // Type or member is obsolete - - var patient = new Patient - { - Id = "123", - Active = true, - Name = [new() { Family = "Doe", Given = ["John"] }], - MultipleBirth = new FhirBoolean(false), - }; - var bundle = new Bundle - { - Type = Bundle.BundleType.Collection, - Entry = [new() { Resource = patient }] - }; - - ConcurrentBag serialized = []; - - // Act - Parallel.For(0, 100, i => - { - serialized.Add(JsonSerializer.Serialize(bundle, options)); - }); - - // Assert - serialized.Count.Should().Be(100); - - // With legacy approach, many results will be missing the entry field due to race conditions - var resultsWithEntry = serialized.Where(json => json.Contains("\"entry\"")).Count(); - resultsWithEntry.Should().BeLessThan(100, "legacy approach should show inconsistent results due to race conditions"); - } - - [TestMethod] - public void AllFactoryMethods_ShouldCreateNewInstances() - { - // Verify that each factory method call creates a new instance + // Verify that each factory method returns the same instance per thread + // (this ensures state consistency within a serialization operation) var summaryFactory = SerializationFilter.CreateSummaryFactory(); var textFactory = SerializationFilter.CreateTextFactory(); var countFactory = SerializationFilter.CreateCountFactory(); var dataFactory = SerializationFilter.CreateDataFactory(); var elementsFactory = SerializationFilter.CreateElementsFactory(["id", "name"]); - // Each call should return a different instance - summaryFactory().Should().NotBeSameAs(summaryFactory()); - textFactory().Should().NotBeSameAs(textFactory()); - countFactory().Should().NotBeSameAs(countFactory()); - dataFactory().Should().NotBeSameAs(dataFactory()); - elementsFactory().Should().NotBeSameAs(elementsFactory()); + // Each call on the same thread should return the same instance + summaryFactory().Should().BeSameAs(summaryFactory()); + textFactory().Should().BeSameAs(textFactory()); + countFactory().Should().BeSameAs(countFactory()); + dataFactory().Should().BeSameAs(dataFactory()); + elementsFactory().Should().BeSameAs(elementsFactory()); } } } \ No newline at end of file From 30c713130da4b23cb6bcaefda988fb922e9e0a5a Mon Sep 17 00:00:00 2001 From: mmsmits Date: Thu, 17 Jul 2025 11:30:10 +0200 Subject: [PATCH 09/78] bump version to 5.12.2 --- src/firely-net-sdk.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/firely-net-sdk.props b/src/firely-net-sdk.props index 2496373dc5..d37e2e5ebf 100644 --- a/src/firely-net-sdk.props +++ b/src/firely-net-sdk.props @@ -6,7 +6,7 @@ - 5.12.1 + 5.12.2 Firely (info@fire.ly) and contributors Firely (https://fire.ly) @@ -44,7 +44,7 @@ Debug;Release;FullDebug true true - 5.12.0 + 5.12.1 From b05954f615259948547acacd1f69ddfd401e8a4d Mon Sep 17 00:00:00 2001 From: Brian Postlethwaite Date: Fri, 18 Jul 2025 10:54:17 +1000 Subject: [PATCH 10/78] Update src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs Only inject the debugger break when in debug mode. This code should never be hit, so excluding the break is good. Intended to wake the developer if it ever hits here, as that means a new Expression type has been added that doesn't derived from one of the existing ones. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs b/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs index e99a133f0f..7340685514 100644 --- a/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs +++ b/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs @@ -90,7 +90,9 @@ public static void TraceCall( else { exprName = expr.GetType().Name; +#if DEBUG Debugger.Break(); +#endif throw new Exception($"Unknown expression type: {expr.GetType().Name}"); // System.Diagnostics.Trace.WriteLine($"Evaluated: {expr} results: {result.Count()}"); } From cf9458b812444d5d88b58e0117f2152982e19549 Mon Sep 17 00:00:00 2001 From: brian_pos Date: Fri, 18 Jul 2025 14:52:07 +1000 Subject: [PATCH 11/78] Complete the external surface for how to "inject" the tracing delegate into the compiled expression --- src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs | 4 ++-- .../FhirPath/Expressions/EvaluatorVisitor.cs | 13 +++++----- .../FhirPath/FhirPathCompiler.cs | 24 ++++++++++++++----- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs b/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs index 7340685514..df3f67207c 100644 --- a/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs +++ b/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs @@ -1,7 +1,7 @@ -/* +/* * Copyright (c) 2015, Firely (info@fire.ly) and contributors * See the file CONTRIBUTORS for details. - * + * * This file is licensed under the BSD 3-Clause license * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs index ca07cca1d4..f16fc8bbe7 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs @@ -1,7 +1,7 @@ -/* +/* * Copyright (c) 2015, Firely (info@fire.ly) and contributors * See the file CONTRIBUTORS for details. - * + * * This file is licensed under the BSD 3-Clause license * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ @@ -97,13 +97,13 @@ public override Invokee VisitVariableRef(FP.VariableRefExpression expression) return WrapForDebugTracer(InvokeeFactory.GetRootResource, expression); return WrapForDebugTracer(chainResolves, expression); - + IEnumerable chainResolves(Closure context, IEnumerable invokees) { return context.ResolveValue(expression.Name) ?? resolve(Symbols, expression.Name, Enumerable.Empty())(context, []); } } - + private static Invokee resolve(SymbolTable scope, string name, IEnumerable argumentTypes) { // For now, we don't have the types or the parameters statically, so we just match on name @@ -129,7 +129,7 @@ private static Invokee resolve(SymbolTable scope, string name, IEnumerable } else { - // No function could be found, but there IS a function with the given name, + // No function could be found, but there IS a function with the given name, // report an error about the fact that the function is known, but could not be bound throw Error.Argument("Unknown symbol '{0}'".FormatWith(name)); } @@ -141,8 +141,7 @@ internal static class EvaluatorExpressionExtensions { public static Invokee ToEvaluator(this FP.Expression expr, SymbolTable scope, DebugTraceDelegate debugTrace = null) { - // TODO: Brian: Defaulting in the example tracer to show how it works (and good for unit test debugging) - var compiler = new EvaluatorVisitor(scope, debugTrace ?? DebugTracer.TraceCall); + var compiler = new EvaluatorVisitor(scope, debugTrace); return expr.Accept(compiler); } } diff --git a/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs b/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs index 89310fab8e..572be0c78f 100644 --- a/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs +++ b/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs @@ -1,7 +1,7 @@ -/* +/* * Copyright (c) 2015, Firely (info@fire.ly) and contributors * See the file CONTRIBUTORS for details. - * + * * This file is licensed under the BSD 3-Clause license * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ @@ -48,9 +48,15 @@ public Expression Parse(string expression) return parse.WasSuccessful ? parse.Value : throw new FormatException("Compilation failed: " + parse.ToString()); } - public CompiledExpression Compile(Expression expression) + /// + /// Compiles a parsed FHIRPath expression into a delegate that can be used to evaluate the expression + /// + /// the parsed fhirpath expression to compile + /// An optional delegate to wire into the compilation that traces the processing steps + /// + public CompiledExpression Compile(Expression expression, DebugTraceDelegate debugTrace = null) { - Invokee inv = expression.ToEvaluator(Symbols); + Invokee inv = expression.ToEvaluator(Symbols, debugTrace); return (ITypedElement focus, EvaluationContext ctx) => { @@ -59,9 +65,15 @@ public CompiledExpression Compile(Expression expression) }; } - public CompiledExpression Compile(string expression) + /// + /// Compiles a FHIRPath expression string into a delegate that can be used to evaluate the expression + /// + /// the fhirpath expression to parse then compile + /// An optional delegate to wire into the compilation that traces the processing steps + /// + public CompiledExpression Compile(string expression, DebugTraceDelegate debugTrace = null) { - return Compile(Parse(expression)); + return Compile(Parse(expression), debugTrace); } } } From 8cf189b5a036f9789bf85929d636ec93b5af00ad Mon Sep 17 00:00:00 2001 From: brian_pos Date: Fri, 18 Jul 2025 15:09:03 +1000 Subject: [PATCH 12/78] Include the position information for the standard extension/valueset variable processing --- src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs b/src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs index b6f00e91e7..d4b1bf9796 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs @@ -1,7 +1,7 @@ -/* +/* * Copyright (c) 2015, Firely (info@fire.ly) and contributors * See the file CONTRIBUTORS for details. - * + * * This file is licensed under the BSD 3-Clause license * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ @@ -152,11 +152,11 @@ select l.CaptureWhitespaceAndComments(wsLeading, wsTrailing) public static Expression BuildVariableRefExpression(SubToken name) { if (name.Value.StartsWith("ext-")) - return new FunctionCallExpression(AxisExpression.That, "builtin.coreexturl", null, null, TypeSpecifier.String, new ConstantExpression(name.Value.Substring(4)).UsePositionFrom(name.Location)); + return new FunctionCallExpression(AxisExpression.That, "builtin.coreexturl", null, null, TypeSpecifier.String, new ConstantExpression(name.Value.Substring(4)).UsePositionFrom(name.Location)).UsePositionFrom(name.Location); #pragma warning disable IDE0046 // Convert to conditional expression else if (name.Value.StartsWith("vs-")) #pragma warning restore IDE0046 // Convert to conditional expression - return new FunctionCallExpression(AxisExpression.That, "builtin.corevsurl", null, null, TypeSpecifier.String, new ConstantExpression(name.Value.Substring(3)).UsePositionFrom(name.Location)); + return new FunctionCallExpression(AxisExpression.That, "builtin.corevsurl", null, null, TypeSpecifier.String, new ConstantExpression(name.Value.Substring(3)).UsePositionFrom(name.Location)).UsePositionFrom(name.Location); else return new VariableRefExpression(name.Value).UsePositionFrom(name.Location); } @@ -237,7 +237,7 @@ from indexer in InvocationExpression private static Parser WrapSubTokenParameter(Parser parser) { - return + return from wsLeading in WhitespaceOrComments() from p in parser.SubTokenWithLeadingWS(wsLeading) select p; @@ -314,7 +314,7 @@ from wsTrailing in WhitespaceOrComments() select op.WithTrailingWS(wsTrailing); // Whitespace or comments - private static Parser> WhitespaceOrComments() => + private static Parser> WhitespaceOrComments() => Parse.WhiteSpace.Many().Select(w => new WhitespaceSubToken(new string(w.ToArray()))).Positioned() .XOr(Lexer.Comment.Select(v => new CommentSubToken(v, false)).Positioned()) .XOr(Lexer.CommentBlock.Select(v => new CommentSubToken(v, true)).Positioned()) From bea44d0b40b0d2dd16278594f8769c48a9700aa1 Mon Sep 17 00:00:00 2001 From: brian_pos Date: Fri, 18 Jul 2025 17:45:33 +1000 Subject: [PATCH 13/78] Use an interface that has the function rather than a delegate. --- src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs | 37 +++++++------------ .../FhirPath/Expressions/EvaluatorVisitor.cs | 8 ++-- .../FhirPath/FhirPathCompiler.cs | 4 +- src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs | 27 ++++++++++++++ 4 files changed, 47 insertions(+), 29 deletions(-) create mode 100644 src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs diff --git a/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs b/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs index df3f67207c..d09eb3dc64 100644 --- a/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs +++ b/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs @@ -6,28 +6,19 @@ * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ using Hl7.Fhir.ElementModel; -using Hl7.Fhir.Utility; using Hl7.FhirPath.Expressions; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using FP = Hl7.FhirPath.Expressions; namespace Hl7.FhirPath { - public delegate void DebugTraceDelegate(Expression expr, - IEnumerable focus, - IEnumerable thisValue, - ITypedElement index, - IEnumerable totalValue, - IEnumerable result, - IEnumerable>> variables); - public class DebugTracer + public class DiagnosticsDebugTracer : IDebugTracer { - public static void TraceCall( + public void TraceCall( Expression expr, IEnumerable focus, IEnumerable thisValue, @@ -42,49 +33,49 @@ public static void TraceCall( if (expr is ConstantExpression ce) { - System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},constant"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},constant"); exprName = "constant"; } else if (expr is ChildExpression child) { - System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{child.ChildName}"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{child.ChildName}"); exprName = child.ChildName; } else if (expr is IndexerExpression indexer) { - System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},[]"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},[]"); exprName = "[]"; } else if (expr is UnaryExpression ue) { - System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{ue.Op}"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{ue.Op}"); exprName = ue.Op; } else if (expr is BinaryExpression be) { - System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{be.Op}"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{be.Op}"); exprName = be.Op; } else if (expr is FunctionCallExpression fe) { - System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{fe.FunctionName}"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{fe.FunctionName}"); exprName = fe.FunctionName; } else if (expr is NewNodeListInitExpression) { - System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{{}} (empty)"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{{}} (empty)"); exprName = "{}"; } else if (expr is AxisExpression ae) { if (ae.AxisName == "that") return; - System.Diagnostics.Trace.WriteLine($"Evaluated: {ae.AxisName} results: {result.Count()}"); + Trace.WriteLine($"Evaluated: {ae.AxisName} results: {result.Count()}"); exprName = "$" + ae.AxisName; } else if (expr is VariableRefExpression ve) { - System.Diagnostics.Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},%{ve.Name}"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},%{ve.Name}"); exprName = "%" + ve.Name; } else @@ -94,7 +85,7 @@ public static void TraceCall( Debugger.Break(); #endif throw new Exception($"Unknown expression type: {expr.GetType().Name}"); - // System.Diagnostics.Trace.WriteLine($"Evaluated: {expr} results: {result.Count()}"); + // Trace.WriteLine($"Evaluated: {expr} results: {result.Count()}"); } if (focus != null) @@ -140,9 +131,9 @@ private static void DebugTraceValue(string exprName, ITypedElement item) if (item == null) return; // possible with a null focus to kick things off if (item.Location == "@primitivevalue@" || item.Location == "@QuantityAsPrimitiveValue@") - System.Diagnostics.Trace.WriteLine($" {exprName}:\t{item.Value}\t({item.InstanceType})"); + Trace.WriteLine($" {exprName}:\t{item.Value}\t({item.InstanceType})"); else - System.Diagnostics.Trace.WriteLine($" {exprName}:\t{item.Value}\t({item.InstanceType})\t{item.Location}"); + Trace.WriteLine($" {exprName}:\t{item.Value}\t({item.InstanceType})\t{item.Location}"); } } } diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs index f16fc8bbe7..ed3d847291 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs @@ -23,7 +23,7 @@ private Invokee WrapForDebugTracer(Invokee invokee, Expression expression) return (Closure context, IEnumerable arguments) => { var result = invokee(context, arguments); var focus = context.GetThat(); - _debugTrace(expression, focus, context.GetThis(), context.GetIndex()?.FirstOrDefault(), context.GetTotal(), result, context.Variables()); + _debugTrace?.TraceCall(expression, focus, context.GetThis(), context.GetIndex()?.FirstOrDefault(), context.GetTotal(), result, context.Variables()); return result; }; } @@ -31,9 +31,9 @@ private Invokee WrapForDebugTracer(Invokee invokee, Expression expression) } public SymbolTable Symbols { get; } - private DebugTraceDelegate _debugTrace; + private IDebugTracer _debugTrace; - public EvaluatorVisitor(SymbolTable symbols, DebugTraceDelegate debugTrace = null) + public EvaluatorVisitor(SymbolTable symbols, IDebugTracer debugTrace = null) { Symbols = symbols; _debugTrace = debugTrace; @@ -139,7 +139,7 @@ private static Invokee resolve(SymbolTable scope, string name, IEnumerable internal static class EvaluatorExpressionExtensions { - public static Invokee ToEvaluator(this FP.Expression expr, SymbolTable scope, DebugTraceDelegate debugTrace = null) + public static Invokee ToEvaluator(this FP.Expression expr, SymbolTable scope, IDebugTracer debugTrace = null) { var compiler = new EvaluatorVisitor(scope, debugTrace); return expr.Accept(compiler); diff --git a/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs b/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs index 572be0c78f..c4ff43d81f 100644 --- a/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs +++ b/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs @@ -54,7 +54,7 @@ public Expression Parse(string expression) /// the parsed fhirpath expression to compile /// An optional delegate to wire into the compilation that traces the processing steps /// - public CompiledExpression Compile(Expression expression, DebugTraceDelegate debugTrace = null) + public CompiledExpression Compile(Expression expression, IDebugTracer debugTrace = null) { Invokee inv = expression.ToEvaluator(Symbols, debugTrace); @@ -71,7 +71,7 @@ public CompiledExpression Compile(Expression expression, DebugTraceDelegate debu /// the fhirpath expression to parse then compile /// An optional delegate to wire into the compilation that traces the processing steps /// - public CompiledExpression Compile(string expression, DebugTraceDelegate debugTrace = null) + public CompiledExpression Compile(string expression, IDebugTracer debugTrace = null) { return Compile(Parse(expression), debugTrace); } diff --git a/src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs b/src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs new file mode 100644 index 0000000000..990ff9165d --- /dev/null +++ b/src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2015, Firely (info@fire.ly) and contributors + * See the file CONTRIBUTORS for details. + * + * This file is licensed under the BSD 3-Clause license + * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE + */ +using Hl7.Fhir.ElementModel; +using Hl7.FhirPath.Expressions; +using System.Collections.Generic; + +namespace Hl7.FhirPath +{ + /// + /// An interface for tracing FHIRPath expression results during evaluation. + /// + public interface IDebugTracer + { + void TraceCall(Expression expr, + IEnumerable focus, + IEnumerable thisValue, + ITypedElement index, + IEnumerable totalValue, + IEnumerable result, + IEnumerable>> variables); + } +} From ba521f3c4ee9521a24c85020c7530c080d75955b Mon Sep 17 00:00:00 2001 From: brian_pos Date: Fri, 18 Jul 2025 17:46:03 +1000 Subject: [PATCH 14/78] rename debugtracer file --- .../FhirPath/{DebugTracer.cs => DiagnosticsDebugTracer.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/Hl7.Fhir.Base/FhirPath/{DebugTracer.cs => DiagnosticsDebugTracer.cs} (100%) diff --git a/src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs b/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs similarity index 100% rename from src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs rename to src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs From c0530a4d25dcc00efc7a09b559f9a5cb6f29d53d Mon Sep 17 00:00:00 2001 From: brian_pos Date: Fri, 18 Jul 2025 17:48:55 +1000 Subject: [PATCH 15/78] Include a unit test for the debug tracer --- src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs diff --git a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs new file mode 100644 index 0000000000..be22c044ad --- /dev/null +++ b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2015, Firely (info@fire.ly) and contributors + * See the file CONTRIBUTORS for details. + * + * This file is licensed under the BSD 3-Clause license + * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE + */ + +// To introduce the DSTU2 FHIR specification +//extern alias dstu2; + +using Hl7.Fhir.ElementModel; +using Hl7.Fhir.FhirPath; +using Hl7.FhirPath.Expressions; +using Hl7.FhirPath.R4.Tests; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Hl7.FhirPath.Tests +{ + + [TestClass] + public class DebugTracerTest + { + static PatientFixture fixture; + static FhirPathCompiler compiler; + + [ClassInitialize] + public static void Initialize(TestContext ctx) + { + fixture = new PatientFixture(); + compiler = new FhirPathCompiler(); + } + + private class TestDebugTracer: IDebugTracer + { + public List traceOutput = new List(); + public void TraceCall( + Expression expr, + IEnumerable focus, + IEnumerable thisValue, + ITypedElement index, + IEnumerable totalValue, + IEnumerable result, + IEnumerable>> variables) + { + var exprName = TraceExpressionNodeName(expr); + if (exprName == null) + return; // this is a node that we aren't interested in tracing (Identifier and $that) + var pi = expr.Location as FhirPathExpressionLocationInfo; + string output = $"{pi.RawPosition},{pi.Length},{exprName}:" + + $" focus={focus?.Count() ?? 0} result={result?.Count() ?? 0}"; + traceOutput.Add(output); + } + + public string TraceExpressionNodeName(Expression expr) + { + if (expr is IdentifierExpression ie) + return null; + + if (expr is ConstantExpression ce) + { + return "constant"; + } + else if (expr is ChildExpression child) + { + return child.ChildName; + } + else if (expr is IndexerExpression indexer) + { + return "[]"; + } + else if (expr is UnaryExpression ue) + { + return ue.Op; + } + else if (expr is BinaryExpression be) + { + return be.Op; + } + else if (expr is FunctionCallExpression fe) + { + return fe.FunctionName; + } + else if (expr is NewNodeListInitExpression) + { + return "{}"; + } + else if (expr is AxisExpression ae) + { + if (ae.AxisName == "that") + return null; + return "$" + ae.AxisName; + } + else if (expr is VariableRefExpression ve) + { + return "%" + ve.Name; + } + else + { +#if DEBUG + Debugger.Break(); +#endif + throw new Exception($"Unknown expression type: {expr.GetType().Name}"); + return expr.GetType().Name; + } + } + + public void DumpDiagnostics() + { + foreach (var item in traceOutput) + { + System.Diagnostics.Trace.WriteLine(item); + } + } + } + + + + [TestMethod] + public void testDebugTrace_PropertyWalking() + { + var expression = "Patient.birthDate.toString().substring(0, 4)"; + var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); + var tracer = new TestDebugTracer(); + var expr = compiler.Compile(expression, tracer); + var results = expr(input, new FhirEvaluationContext()).ToFhirValues().ToList(); + System.Diagnostics.Trace.WriteLine("Expression: " + expression); + tracer.DumpDiagnostics(); + + Assert.AreEqual(1, results.Count()); + Assert.AreEqual("1974", results[0].ToString()); + + Assert.AreEqual(6, tracer.traceOutput.Count()); + Assert.AreEqual("0,7,Patient: focus=1 result=1", tracer.traceOutput[0]); + Assert.AreEqual("8,9,birthDate: focus=1 result=1", tracer.traceOutput[1]); + Assert.AreEqual("18,8,toString: focus=1 result=1", tracer.traceOutput[2]); + Assert.AreEqual("39,1,constant: focus=1 result=1", tracer.traceOutput[3]); + Assert.AreEqual("42,1,constant: focus=1 result=1", tracer.traceOutput[4]); + Assert.AreEqual("29,9,substring: focus=1 result=1", tracer.traceOutput[5]); + } + + [TestMethod] + public void testDebugTrace_WhereClause() + { + var expression = "name.where(use='official' or use='usual').given"; + + var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); + var tracer = new TestDebugTracer(); + var expr = compiler.Compile(expression, tracer); + var results = expr(input, new FhirEvaluationContext()).ToFhirValues().ToList(); + System.Diagnostics.Trace.WriteLine("Expression: " + expression); + tracer.DumpDiagnostics(); + + Assert.AreEqual(3, results.Count()); + //Assert.AreEqual("Peter", ((Element)results[0]).getValue().toString()); + //Assert.AreEqual("James", ((Element)results.get(1)).getValue().toString()); + //Assert.AreEqual("Jim", ((Element)results.get(2)).getValue().toString()); + + //Assert.AreEqual("Patient.name[0].given[0]", ((Element)results[0]).getPath()); + //Assert.AreEqual("Patient.name[0].given[1]", ((Element)results.get(1)).getPath()); + //Assert.AreEqual("Patient.name[1].given[0]", ((Element)results.get(2)).getPath()); + + Assert.AreEqual(20, tracer.traceOutput.Count()); + Assert.AreEqual("0,4,name: focus=1 result=3", tracer.traceOutput[0]); + Assert.AreEqual("11,3,use: focus=1 result=1", tracer.traceOutput[1]); + Assert.AreEqual("15,10,constant: focus=1 result=1", tracer.traceOutput[2]); + Assert.AreEqual("14,1,=: focus=1 result=1", tracer.traceOutput[3]); + + Assert.AreEqual("5,5,where: focus=3 result=2", tracer.traceOutput[18]); + Assert.AreEqual("42,5,given: focus=2 result=3", tracer.traceOutput[19]); + } + + [TestMethod] + public void testDebugTrace_ConstantValues() + { + var expression = "'42'"; + + var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); + var tracer = new TestDebugTracer(); + var expr = compiler.Compile(expression, tracer); + var results = expr(input, new FhirEvaluationContext()).ToFhirValues().ToList(); + System.Diagnostics.Trace.WriteLine("Expression: " + expression); + tracer.DumpDiagnostics(); + + Assert.AreEqual(1, results.Count()); + Assert.AreEqual("42", results[0].ToString()); + + Assert.AreEqual(1, tracer.traceOutput.Count()); + Assert.AreEqual("0,4,constant: focus=1 result=1", tracer.traceOutput[0]); + } + + [TestMethod] + public void testDebugTrace_GroupedOr() + { + var expression = "id='official' or id='example'"; + + var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); + var tracer = new TestDebugTracer(); + var expr = compiler.Compile(expression, tracer); + var results = expr(input, new FhirEvaluationContext()).ToFhirValues().ToList(); + System.Diagnostics.Trace.WriteLine("Expression: " + expression); + tracer.DumpDiagnostics(); + + Assert.AreEqual(1, results.Count()); + Assert.AreEqual("true", results[0].ToString()); + + Assert.AreEqual(7, tracer.traceOutput.Count()); + Assert.AreEqual("0,2,id: focus=1 result=1", tracer.traceOutput[0]); + Assert.AreEqual("3,10,constant: focus=1 result=1", tracer.traceOutput[1]); + Assert.AreEqual("2,1,=: focus=1 result=1", tracer.traceOutput[2]); + Assert.AreEqual("17,2,id: focus=1 result=1", tracer.traceOutput[3]); + Assert.AreEqual("20,9,constant: focus=1 result=1", tracer.traceOutput[4]); + Assert.AreEqual("19,1,=: focus=1 result=1", tracer.traceOutput[5]); + Assert.AreEqual("14,2,or: focus=1 result=1", tracer.traceOutput[6]); + } + } +} \ No newline at end of file From a84bc97922e99f5cb2eaac00ce4c1eb77c268329 Mon Sep 17 00:00:00 2001 From: brian_pos Date: Fri, 18 Jul 2025 17:50:27 +1000 Subject: [PATCH 16/78] Tweak some other fhirpath unit tests to use the diagnostics tracer to show in the debug output what steps the evaluation took. --- .../PocoTests/FhirPathEvaluatorTest.cs | 29 ++++++---- .../Tests/BasicFunctionTests.cs | 54 +++++++++++-------- 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs b/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs index 5b620b5da0..26bec1eb4f 100644 --- a/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs +++ b/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs @@ -1,7 +1,7 @@ -/* +/* * Copyright (c) 2015, Firely (info@fire.ly) and contributors * See the file CONTRIBUTORS for details. - * + * * This file is licensed under the BSD 3-Clause license * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ @@ -86,14 +86,23 @@ public void IsBoolean(string expr, bool result) new XElement("output", new XAttribute("type", "boolean"), new XText(result ? "true" : "false"))); Xdoc.Elements().First().Add(testXml); - Assert.IsTrue(TestInput.IsBoolean(expr, result)); + Assert.IsTrue(IsBoolean(TestInput, expr, result)); } + public bool IsBoolean(Base baseInput, string expression, bool value, EvaluationContext? ctx = null) + { + var input = baseInput.ToTypedElement().ToScopedNode(); + + // Don't use the expression cache as we need to inject the debug tracer + var compiler = new FhirPathCompiler(); + var evaluator = compiler.Compile(expression, new DiagnosticsDebugTracer()); + return evaluator.IsBoolean(value, input, ctx ?? new EvaluationContext()); + } public void IsTrue(string expr, Base input) { - Assert.IsTrue(input.IsBoolean(expr, true)); + Assert.IsTrue(IsBoolean(input, expr, true)); } } @@ -479,7 +488,7 @@ public void use_of_a_variable_in_separate_contexts() [TestMethod] public void use_of_a_variable_in_separate_contexts_defined_in_2_but_used_in_1() { - // this example defines the same variable name in 2 different contexts, + // this example defines the same variable name in 2 different contexts, // but only uses it in the second. This ensures that the first context doesn't remain when using it in another context var expr = "defineVariable('n1', name.first()).where(active.not()) | defineVariable('n1', name.skip(1).first()).select(%n1.given)"; var r = fixture.PatientExample.Select(expr).ToList(); @@ -522,7 +531,7 @@ public void composite_variable_use() } - + [TestMethod] public void use_of_a_variable_outside_context_throws_error() { @@ -554,14 +563,14 @@ public void use_undefined_variable_throws_error() ex.Message.Should().Contain("Unknown symbol 'fam'"); } } - + [TestMethod] public void redefining_variable_throws_error() { var expr = "defineVariable('v1').defineVariable('v1').select(%v1)"; Assert.ThrowsException(() => fixture.PatientExample.Select(expr).ToList()); } - + [TestMethod] public void sequence_of_variable_definitions_tweak() @@ -588,7 +597,7 @@ public void sequence_of_variable_definitions_original() // .toStrictEqual([true, "JimJim"]); } - + [TestMethod] public void multi_tree_vars_valid() { @@ -599,7 +608,7 @@ public void multi_tree_vars_valid() Assert.AreEqual("r1-v2", r.Skip(1).First().ToString()); // .toStrictEqual(["r1-v1", "r1-v2"]); } - + [TestMethod] public void defineVariable_with_compile_success() { diff --git a/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs b/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs index 8016f1e7b4..ab079d26a4 100644 --- a/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs +++ b/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs @@ -1,7 +1,7 @@ -/* +/* * Copyright (c) 2015, Firely (info@fire.ly) and contributors * See the file CONTRIBUTORS for details. - * + * * This file is licensed under the BSD 3-Clause license * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ @@ -23,14 +23,26 @@ public class BasicFunctionsTest { private static void isB(string expr, object value = null) { - ITypedElement dummy = ElementNode.ForPrimitive(value ?? true); - Assert.IsTrue(dummy.IsBoolean(expr, true)); + ITypedElement dummy = ElementNode.ForPrimitive(value ?? true).ToScopedNode(); + var compiler = new FhirPathCompiler(); + var evaluator = compiler.Compile(expr, new DiagnosticsDebugTracer()); + Assert.IsTrue(evaluator.IsBoolean(true, dummy, new EvaluationContext())); } private static object scalar(string expr) { - ITypedElement dummy = ElementNode.ForPrimitive(true); - return dummy.Scalar(expr); + ITypedElement dummy = ElementNode.ForPrimitive(true).ToScopedNode(); + var compiler = new FhirPathCompiler(); + var evaluator = compiler.Compile(expr, new DiagnosticsDebugTracer()); + return evaluator.Scalar(dummy, new EvaluationContext()); + } + + private static object scalar(ITypedElement dummy, string expr) + { + dummy = dummy.ToScopedNode(); + var compiler = new FhirPathCompiler(); + var evaluator = compiler.Compile(expr, new DiagnosticsDebugTracer()); + return evaluator.Scalar(dummy, new EvaluationContext()); } [TestMethod] @@ -41,7 +53,7 @@ public void TestDynaBinding() SourceNode.Valued("child", "Hello world!"), SourceNode.Valued("child", "4")).ToTypedElement(); #pragma warning restore CS0618 // Type or member is internal - Assert.AreEqual("ello", input.Scalar(@"$this.child[0].substring(1,%context.child[1].toInteger())")); + Assert.AreEqual("ello", scalar(input, @"$this.child[0].substring(1,%context.child[1].toInteger())")); } [TestMethod] @@ -217,23 +229,23 @@ public void StringConcatenationAndEmpty() { ITypedElement dummy = ElementNode.ForPrimitive(true); - Assert.AreEqual("ABCDEF", dummy.Scalar("'ABC' + '' + 'DEF'")); - Assert.AreEqual("DEF", dummy.Scalar("'' + 'DEF'")); - Assert.AreEqual("DEF", dummy.Scalar("'DEF' + ''")); + Assert.AreEqual("ABCDEF", scalar(dummy, "'ABC' + '' + 'DEF'")); + Assert.AreEqual("DEF", scalar(dummy, "'' + 'DEF'")); + Assert.AreEqual("DEF", scalar(dummy, "'DEF' + ''")); - Assert.IsNull(dummy.Scalar("{} + 'DEF'")); - Assert.IsNull(dummy.Scalar("'ABC' + {} + 'DEF'")); - Assert.IsNull(dummy.Scalar("'ABC' + {}")); + Assert.IsNull(scalar(dummy, "{} + 'DEF'")); + Assert.IsNull(scalar(dummy, "'ABC' + {} + 'DEF'")); + Assert.IsNull(scalar(dummy, "'ABC' + {}")); - Assert.AreEqual("ABCDEF", dummy.Scalar("'ABC' & '' & 'DEF'")); - Assert.AreEqual("DEF", dummy.Scalar("'' & 'DEF'")); - Assert.AreEqual("DEF", dummy.Scalar("'DEF' & ''")); + Assert.AreEqual("ABCDEF", scalar(dummy, "'ABC' & '' & 'DEF'")); + Assert.AreEqual("DEF", scalar(dummy, "'' & 'DEF'")); + Assert.AreEqual("DEF", scalar(dummy, "'DEF' & ''")); - Assert.AreEqual("DEF", dummy.Scalar("{} & 'DEF'")); - Assert.AreEqual("ABCDEF", dummy.Scalar("'ABC' & {} & 'DEF'")); - Assert.AreEqual("ABC", dummy.Scalar("'ABC' & {}")); + Assert.AreEqual("DEF", scalar(dummy, "{} & 'DEF'")); + Assert.AreEqual("ABCDEF", scalar(dummy, "'ABC' & {} & 'DEF'")); + Assert.AreEqual("ABC", scalar(dummy, "'ABC' & {}")); - Assert.IsNull(dummy.Scalar("'ABC' & {} & 'DEF' + {}")); + Assert.IsNull(scalar(dummy, "'ABC' & {} & 'DEF' + {}")); } [TestMethod] @@ -258,7 +270,7 @@ public void TestStringSplit() Assert.IsNotNull(result); CollectionAssert.AreEqual(new[] { "", "ONE", "", "TWO", "", "", "THREE", "", "" }, result.Select(r => r.Value.ToString()).ToArray()); } - + [DataTestMethod] [DataRow("(1 | 2 | 3).indexOf(3)", 2)] [DataRow("((1 | 2 | 3).combine(2)).indexOf(2, 2)", 3)] From ca432f4c326d5b04883f5bd3b47d24dbf7b2e7db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Jul 2025 09:15:21 +0000 Subject: [PATCH 17/78] Refactor obsolete filter methods to call factory methods and execute them immediately Co-authored-by: ewoutkramer <2276305+ewoutkramer@users.noreply.github.com> --- .../Serialization/SerializationFilter.cs | 30 ++++--------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs b/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs index 150d0c12fe..e7274c4a30 100644 --- a/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs +++ b/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs @@ -44,48 +44,28 @@ public abstract class SerializationFilter /// Construct a new filter that conforms to the `_summary=true` summarized form. /// [Obsolete("Use CreateSummaryFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] - public static SerializationFilter ForSummary() => new BundleFilter(new ElementMetadataFilter() { IncludeInSummary = true }); + public static SerializationFilter ForSummary() => CreateSummaryFactory()(); /// /// Construct a new filter that conforms to the `_summary=text` summarized form. /// [Obsolete("Use CreateTextFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] - public static SerializationFilter ForText() => new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeNames = new[] { "text", "id", "meta" }, - IncludeMandatory = true - })); + public static SerializationFilter ForText() => CreateTextFactory()(); [Obsolete("Use CreateCountFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] - public static SerializationFilter ForCount() => new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeMandatory = true, - IncludeNames = new[] { "id", "total", "link" } - })); + public static SerializationFilter ForCount() => CreateCountFactory()(); /// /// Construct a new filter that conforms to the `_summary=data` summarized form. /// [Obsolete("Use CreateDataFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] - public static SerializationFilter ForData() => new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeNames = new[] { "text" }, - Invert = true - })); + public static SerializationFilter ForData() => CreateDataFactory()(); /// /// Construct a new filter that conforms to the `_elements=...` summarized form. /// [Obsolete("Use CreateElementsFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] - public static SerializationFilter ForElements(string[] elements) => new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeNames = elements, - IncludeMandatory = true - })); + public static SerializationFilter ForElements(string[] elements) => CreateElementsFactory(elements)(); /// /// Create a factory function that produces new filter instances conforming to the `_summary=true` summarized form. From 4b2837890f92d723e70f0f06765edc2046a3e1a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:07:30 +0000 Subject: [PATCH 18/78] Fix factory methods to create fresh filter instances per serialization run to prevent state reuse across operations Co-authored-by: ewoutkramer <2276305+ewoutkramer@users.noreply.github.com> --- .../BaseFhirJsonPocoSerializer.cs | 24 ++++--- .../Serialization/SerializationFilter.cs | 66 +++++++------------ .../SummaryFilterThreadSafetyTests.cs | 18 ++--- 3 files changed, 49 insertions(+), 59 deletions(-) diff --git a/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs b/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs index 968888c5e7..682e45f80b 100644 --- a/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs +++ b/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs @@ -64,7 +64,7 @@ public BaseFhirJsonPocoSerializer(FhirRelease release, FhirJsonPocoSerializerSet /// Serializes the given dictionary with FHIR data into Json. /// public void Serialize(IReadOnlyDictionary members, Utf8JsonWriter writer) => - serializeInternal(members, writer, skipValue: false); + serializeInternal(members, writer, skipValue: false, filter: null); /// /// Serializes the given dictionary with FHIR data into a Json string. @@ -73,7 +73,7 @@ public string SerializeToString(IReadOnlyDictionary members) { var stream = new MemoryStream(); var writer = new Utf8JsonWriter(stream); - serializeInternal(members, writer, skipValue: false); + serializeInternal(members, writer, skipValue: false, filter: null); writer.Flush(); return Encoding.UTF8.GetString(stream.ToArray()); } @@ -86,13 +86,19 @@ public string SerializeToString(IReadOnlyDictionary members) private void serializeInternal( IReadOnlyDictionary members, Utf8JsonWriter writer, - bool skipValue) + bool skipValue, + SerializationFilter? filter = null) { writer.WriteStartObject(); - // Use factory if available, otherwise fall back to the static instance for backward compatibility + + // Get filter only once at the top level, then pass it through recursive calls + if (filter == null) + { + // Use factory if available, otherwise fall back to the static instance for backward compatibility #pragma warning disable CS0618 // Type or member is obsolete - var filter = Settings.SummaryFilterFactory?.Invoke() ?? Settings.SummaryFilter; + filter = Settings.SummaryFilterFactory?.Invoke() ?? Settings.SummaryFilter; #pragma warning restore CS0618 // Type or member is obsolete + } if (members is Resource r) writer.WriteString("resourceType", r.TypeName); @@ -135,12 +141,12 @@ private void serializeInternal( writer.WriteStartArray(); foreach (var value in coll) - serializeMemberValue(value, writer, requiredType); + serializeMemberValue(value, writer, filter, requiredType); writer.WriteEndArray(); } else - serializeMemberValue(member.Value, writer, requiredType); + serializeMemberValue(member.Value, writer, filter, requiredType); } filter?.LeaveMember(member.Key, member.Value, propertyMapping); @@ -162,10 +168,10 @@ private static string addSuffixToElementName(string elementName, object elementV return typeName is null ? elementName : elementName + char.ToUpperInvariant(typeName[0]) + typeName.Substring(1); } - private void serializeMemberValue(object value, Utf8JsonWriter writer, Type? requiredType = null) + private void serializeMemberValue(object value, Utf8JsonWriter writer, SerializationFilter? filter, Type? requiredType = null) { if (value is IReadOnlyDictionary complex) - serializeInternal(complex, writer, skipValue: false); + serializeInternal(complex, writer, skipValue: false, filter: filter); else SerializePrimitiveValue(value, writer, requiredType); } diff --git a/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs b/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs index e7274c4a30..070f9bd283 100644 --- a/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs +++ b/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs @@ -10,7 +10,6 @@ using Hl7.Fhir.Introspection; using System; -using System.Threading; namespace Hl7.Fhir.Serialization { @@ -73,10 +72,7 @@ public abstract class SerializationFilter /// public static Func CreateSummaryFactory() { - var threadLocalFilter = new ThreadLocal(() => - new BundleFilter(new ElementMetadataFilter() { IncludeInSummary = true })); - - return () => threadLocalFilter.Value!; + return () => new BundleFilter(new ElementMetadataFilter() { IncludeInSummary = true }); } /// @@ -85,15 +81,12 @@ public static Func CreateSummaryFactory() /// public static Func CreateTextFactory() { - var threadLocalFilter = new ThreadLocal(() => - new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeNames = new[] { "text", "id", "meta" }, - IncludeMandatory = true - }))); - - return () => threadLocalFilter.Value!; + return () => new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeNames = new[] { "text", "id", "meta" }, + IncludeMandatory = true + })); } /// @@ -102,15 +95,12 @@ public static Func CreateTextFactory() /// public static Func CreateCountFactory() { - var threadLocalFilter = new ThreadLocal(() => - new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeMandatory = true, - IncludeNames = new[] { "id", "total", "link" } - }))); - - return () => threadLocalFilter.Value!; + return () => new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeMandatory = true, + IncludeNames = new[] { "id", "total", "link" } + })); } /// @@ -119,15 +109,12 @@ public static Func CreateCountFactory() /// public static Func CreateDataFactory() { - var threadLocalFilter = new ThreadLocal(() => - new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeNames = new[] { "text" }, - Invert = true - }))); - - return () => threadLocalFilter.Value!; + return () => new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeNames = new[] { "text" }, + Invert = true + })); } /// @@ -136,15 +123,12 @@ public static Func CreateDataFactory() /// public static Func CreateElementsFactory(string[] elements) { - var threadLocalFilter = new ThreadLocal(() => - new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeNames = elements, - IncludeMandatory = true - }))); - - return () => threadLocalFilter.Value!; + return () => new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeNames = elements, + IncludeMandatory = true + })); } } } diff --git a/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs b/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs index 7c8c3d5941..153b9f6625 100644 --- a/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs +++ b/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs @@ -64,22 +64,22 @@ public void ConcurrentSerializationWithFactory_ShouldBeThreadSafe() } [TestMethod] - public void AllFactoryMethods_ShouldUseSameInstancePerThread() + public void AllFactoryMethods_ShouldCreateFreshInstancesPerCall() { - // Verify that each factory method returns the same instance per thread - // (this ensures state consistency within a serialization operation) + // Verify that each factory method creates a new instance per call + // (this ensures no state is shared between serialization operations) var summaryFactory = SerializationFilter.CreateSummaryFactory(); var textFactory = SerializationFilter.CreateTextFactory(); var countFactory = SerializationFilter.CreateCountFactory(); var dataFactory = SerializationFilter.CreateDataFactory(); var elementsFactory = SerializationFilter.CreateElementsFactory(["id", "name"]); - // Each call on the same thread should return the same instance - summaryFactory().Should().BeSameAs(summaryFactory()); - textFactory().Should().BeSameAs(textFactory()); - countFactory().Should().BeSameAs(countFactory()); - dataFactory().Should().BeSameAs(dataFactory()); - elementsFactory().Should().BeSameAs(elementsFactory()); + // Each call should return a different instance + summaryFactory().Should().NotBeSameAs(summaryFactory()); + textFactory().Should().NotBeSameAs(textFactory()); + countFactory().Should().NotBeSameAs(countFactory()); + dataFactory().Should().NotBeSameAs(dataFactory()); + elementsFactory().Should().NotBeSameAs(elementsFactory()); } } } \ No newline at end of file From 8624c25338f357f6c4668de71c3cb8709a5e929d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:53:58 +0000 Subject: [PATCH 19/78] Add thread-safe filter factory support to XML serialization methods - Add new overloads to BaseFhirXmlPocoSerializer.Serialize() and SerializeToString() that take Func factory parameter - Mark existing methods taking SerializationFilter as obsolete with clear migration guidance - Update internal calls in PocoSerializationEngine_Xml to use new thread-safe methods - Add comprehensive tests verifying factory methods work correctly and produce identical output to obsolete methods - Ensure filter factories create fresh instances per call for thread safety Addresses XML serialization thread-safety concerns similar to JSON serialization fixes Co-authored-by: ewoutkramer <2276305+ewoutkramer@users.noreply.github.com> --- .../BaseFhirXmlPocoSerializer.cs | 37 ++++++++++++-- .../engine/PocoSerializationEngine_Xml.cs | 4 +- .../FhirXmlSerializationTests.cs | 51 +++++++++++++++++++ 3 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/Hl7.Fhir.Base/Serialization/BaseFhirXmlPocoSerializer.cs b/src/Hl7.Fhir.Base/Serialization/BaseFhirXmlPocoSerializer.cs index dc0bca302d..859c2ce44b 100644 --- a/src/Hl7.Fhir.Base/Serialization/BaseFhirXmlPocoSerializer.cs +++ b/src/Hl7.Fhir.Base/Serialization/BaseFhirXmlPocoSerializer.cs @@ -42,9 +42,12 @@ public BaseFhirXmlPocoSerializer(FhirRelease release) } /// - /// Serializes the given dictionary with FHIR data into Json. + /// Serializes the given dictionary with FHIR data into XML. /// - public void Serialize(IReadOnlyDictionary members, XmlWriter writer, SerializationFilter? summary = default) + /// The dictionary containing FHIR data to serialize. + /// The XmlWriter to write the serialized data to. + /// A factory function that creates a new filter instance for each serialization operation. This ensures thread-safety when reusing serializer instances in concurrent scenarios. + public void Serialize(IReadOnlyDictionary members, XmlWriter writer, Func? summaryFilterFactory) { writer.WriteStartDocument(); @@ -57,17 +60,41 @@ public void Serialize(IReadOnlyDictionary members, XmlWriter wri writer.WriteStartElement(rootElementName, XmlNs.FHIR); } - serializeInternal(members, writer, summary); + var filter = summaryFilterFactory?.Invoke(); + serializeInternal(members, writer, filter); if (simulateRoot) writer.WriteEndElement(); writer.WriteEndDocument(); } /// - /// Serializes the given dictionary with FHIR data into UTF8 encoded Json. + /// Serializes the given dictionary with FHIR data into XML. + /// + /// The dictionary containing FHIR data to serialize. + /// The XmlWriter to write the serialized data to. + /// The serialization filter to apply. NOTE: For thread-safety when reusing serializer instances, pass a fresh filter instance for each serialization operation. + [Obsolete("Use the overload that takes Func summaryFilterFactory instead to ensure thread-safety when reusing serializer instances in concurrent scenarios. This method will be removed in a future version.")] + public void Serialize(IReadOnlyDictionary members, XmlWriter writer, SerializationFilter? summary = default) + { + Serialize(members, writer, summary != null ? () => summary : (Func?)null); + } + + /// + /// Serializes the given dictionary with FHIR data into UTF8 encoded XML. + /// + /// The dictionary containing FHIR data to serialize. + /// A factory function that creates a new filter instance for each serialization operation. This ensures thread-safety when reusing serializer instances in concurrent scenarios. + public string SerializeToString(IReadOnlyDictionary members, Func? summaryFilterFactory) => + SerializationUtil.WriteXmlToString(w => Serialize(members, w, summaryFilterFactory)); + + /// + /// Serializes the given dictionary with FHIR data into UTF8 encoded XML. /// + /// The dictionary containing FHIR data to serialize. + /// The serialization filter to apply. NOTE: For thread-safety when reusing serializer instances, pass a fresh filter instance for each serialization operation. + [Obsolete("Use the overload that takes Func summaryFilterFactory instead to ensure thread-safety when reusing serializer instances in concurrent scenarios. This method will be removed in a future version.")] public string SerializeToString(IReadOnlyDictionary members, SerializationFilter? summary = default) => - SerializationUtil.WriteXmlToString(w => Serialize(members, w, summary)); + SerializeToString(members, summary != null ? () => summary : (Func?)null); /// /// Serializes the given dictionary with FHIR data into Json, optionally skipping the "value" element. diff --git a/src/Hl7.Fhir.Base/Serialization/engine/PocoSerializationEngine_Xml.cs b/src/Hl7.Fhir.Base/Serialization/engine/PocoSerializationEngine_Xml.cs index 9adcf86812..018c647429 100644 --- a/src/Hl7.Fhir.Base/Serialization/engine/PocoSerializationEngine_Xml.cs +++ b/src/Hl7.Fhir.Base/Serialization/engine/PocoSerializationEngine_Xml.cs @@ -34,7 +34,7 @@ public Resource DeserializeFromXml(string data) } /// - public string SerializeToXml(Resource instance) => getXmlSerializer().SerializeToString(instance); + public string SerializeToXml(Resource instance) => getXmlSerializer().SerializeToString(instance, (Func?)null); /// /// Deserializes a resource from an XML reader @@ -70,5 +70,5 @@ public Base DeserializeElementFromXml(Type targetType, XmlReader reader) /// /// An instance of Base or any of its children /// The XML writer - public void SerializeToXmlWriter(Base instance, XmlWriter writer) => getXmlSerializer().Serialize(instance, writer); + public void SerializeToXmlWriter(Base instance, XmlWriter writer) => getXmlSerializer().Serialize(instance, writer, (Func?)null); } \ No newline at end of file diff --git a/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/FhirXmlSerializationTests.cs b/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/FhirXmlSerializationTests.cs index 4841fda564..7306f0d7a6 100644 --- a/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/FhirXmlSerializationTests.cs +++ b/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/FhirXmlSerializationTests.cs @@ -52,5 +52,56 @@ public void SerializesInvalidData() contactArray.Count().Should().Be(1); contactArray.First().Elements().Should().BeEmpty(); } + + [TestMethod] + public void CanUseFilterFactory() + { + var patient = new Patient + { + Id = "test-patient", + Active = true, + Name = new() { new HumanName { Given = new[] { "John" }, Family = "Doe" } }, + Gender = AdministrativeGender.Male + }; + + var serializer = new BaseFhirXmlPocoSerializer(Specification.FhirRelease.STU3); + + // Test the new factory-based method + var elementsFactory = SerializationFilter.CreateElementsFactory(new[] { "id", "active" }); + var xmlWithFactory = serializer.SerializeToString(patient, elementsFactory); + + // Test the obsolete method for comparison +#pragma warning disable CS0618 // Type or member is obsolete + var filter = SerializationFilter.ForElements(new[] { "id", "active" }); + var xmlWithFilter = serializer.SerializeToString(patient, filter); +#pragma warning restore CS0618 // Type or member is obsolete + + // Both methods should produce identical output + xmlWithFactory.Should().Be(xmlWithFilter); + + // Verify that filtering actually works (should only contain id and active) + var xdoc = XDocument.Parse(xmlWithFactory); + var patientElement = xdoc.Root; + + // Should contain id and active elements + patientElement.Elements(XName.Get("id", XmlNs.FHIR)).Should().HaveCount(1); + patientElement.Elements(XName.Get("active", XmlNs.FHIR)).Should().HaveCount(1); + + // Should NOT contain name or gender (they were filtered out) + patientElement.Elements(XName.Get("name", XmlNs.FHIR)).Should().BeEmpty(); + patientElement.Elements(XName.Get("gender", XmlNs.FHIR)).Should().BeEmpty(); + } + + [TestMethod] + public void FilterFactoryCreatesNewInstancesEachTime() + { + var elementsFactory = SerializationFilter.CreateElementsFactory(new[] { "id", "active" }); + + // Each call should return a new instance + var filter1 = elementsFactory(); + var filter2 = elementsFactory(); + + filter1.Should().NotBeSameAs(filter2); + } } } \ No newline at end of file From f6d6596f6e6e95a58905258d8bd4a9c87aa800ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:43:08 +0000 Subject: [PATCH 20/78] Initial plan From ed67b8d6cb5f49d4e130a1cecf59ca67911fff27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:51:36 +0000 Subject: [PATCH 21/78] Refactor: Move MatchesVersion logic to Canonical class as public method Unified duplicate MatchesVersion implementations across ArtifactSummaryExtensions and InMemoryResourceResolver by moving the logic to a public static method on the Canonical class. This eliminates code duplication and provides a centralized, reusable canonical version matching utility. - Added Canonical.MatchesVersion() as public static method - Updated all three implementations to use Canonical.MatchesVersion() - Removed duplicate private MatchesVersion methods - Maintains full backward compatibility Co-authored-by: ewoutkramer <2276305+ewoutkramer@users.noreply.github.com> --- src/Hl7.Fhir.Base/Model/Canonical.cs | 36 ++++++++++++++++ .../Source/InMemoryResourceResolver.cs | 38 +---------------- .../Summary/ArtifactSummaryExtensions.cs | 42 +------------------ .../Summary/ArtifactSummaryExtensions.cs | 38 +---------------- 4 files changed, 39 insertions(+), 115 deletions(-) diff --git a/src/Hl7.Fhir.Base/Model/Canonical.cs b/src/Hl7.Fhir.Base/Model/Canonical.cs index 00ae90999f..de24ccfc34 100644 --- a/src/Hl7.Fhir.Base/Model/Canonical.cs +++ b/src/Hl7.Fhir.Base/Model/Canonical.cs @@ -132,6 +132,42 @@ public void Deconstruct(out string? uri, out string? version, out string? fragme /// public bool HasAnchor => Fragment is not null; + /// + /// Determines if a resource version matches a query version according to FHIR canonical matching rules. + /// Supports both exact matching and partial version matching (e.g., "1.5" matches "1.5.0"). + /// + /// The version of the resource being checked. + /// The version specified in the canonical URL query. + /// True if the resource version matches the query version according to FHIR canonical matching rules. + public static bool MatchesVersion(string? resourceVersion, string queryVersion) + { + // If either version is null or empty, treat as no version specified + if (string.IsNullOrEmpty(resourceVersion) || string.IsNullOrEmpty(queryVersion)) + return string.IsNullOrEmpty(resourceVersion) && string.IsNullOrEmpty(queryVersion); + + // First try exact match for backwards compatibility and performance + if (resourceVersion == queryVersion) + return true; + + // Implement partial version matching according to FHIR canonical matching rules + // The query version should be a prefix of the resource version when split by dots + var resourceParts = resourceVersion!.Split('.'); + var queryParts = queryVersion.Split('.'); + + // Query version cannot have more parts than resource version for partial matching + if (queryParts.Length > resourceParts.Length) + return false; + + // Check if all query version parts match the corresponding resource version parts + for (int i = 0; i < queryParts.Length; i++) + { + if (resourceParts[i] != queryParts[i]) + return false; + } + + return true; + } + private static (string? url, string? version, string? fragment) splitCanonical(string canonical) { var (rest, a) = splitOff(canonical, '#'); diff --git a/src/Hl7.Fhir.Base/Specification/Source/InMemoryResourceResolver.cs b/src/Hl7.Fhir.Base/Specification/Source/InMemoryResourceResolver.cs index d29a24ac14..be1f87c87e 100644 --- a/src/Hl7.Fhir.Base/Specification/Source/InMemoryResourceResolver.cs +++ b/src/Hl7.Fhir.Base/Specification/Source/InMemoryResourceResolver.cs @@ -123,7 +123,7 @@ private void add(Resource resource) { if (candidate.Resource is IVersionableConformanceResource versionableConformance) { - if (MatchesVersion(versionableConformance.Version, version)) + if (Canonical.MatchesVersion(versionableConformance.Version, version)) return candidate.Resource; } } @@ -131,42 +131,6 @@ private void add(Resource resource) return null; } - /// - /// Determines if a resource version matches a query version according to FHIR canonical matching rules. - /// Supports both exact matching and partial version matching (e.g., "1.5" matches "1.5.0"). - /// - /// The version of the resource being checked. - /// The version specified in the canonical URL query. - /// True if the resource version matches the query version according to FHIR canonical matching rules. - private static bool MatchesVersion(string? resourceVersion, string queryVersion) - { - // If either version is null or empty, treat as no version specified - if (string.IsNullOrEmpty(resourceVersion) || string.IsNullOrEmpty(queryVersion)) - return string.IsNullOrEmpty(resourceVersion) && string.IsNullOrEmpty(queryVersion); - - // First try exact match for backwards compatibility and performance - if (resourceVersion == queryVersion) - return true; - - // Implement partial version matching according to FHIR canonical matching rules - // The query version should be a prefix of the resource version when split by dots - var resourceParts = resourceVersion!.Split('.'); - var queryParts = queryVersion.Split('.'); - - // Query version cannot have more parts than resource version for partial matching - if (queryParts.Length > resourceParts.Length) - return false; - - // Check if all query version parts match the corresponding resource version parts - for (int i = 0; i < queryParts.Length; i++) - { - if (resourceParts[i] != queryParts[i]) - return false; - } - - return true; - } - /// public Task ResolveByCanonicalUriAsync(string uri) { diff --git a/src/Hl7.Fhir.Conformance/Specification/Source/Summary/ArtifactSummaryExtensions.cs b/src/Hl7.Fhir.Conformance/Specification/Source/Summary/ArtifactSummaryExtensions.cs index 98396f8cbc..4c2c2487da 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Source/Summary/ArtifactSummaryExtensions.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Source/Summary/ArtifactSummaryExtensions.cs @@ -55,7 +55,7 @@ public static IEnumerable FindConformanceResources(this IEnumer var version = values.Length == 2 ? values[1] : string.Empty; return summaries.ConformanceResources(modelInfo).Where(r => r.GetConformanceCanonicalUrl() == values[0] && - (string.IsNullOrEmpty(version) || MatchesVersion(r.GetConformanceVersion(), version))); + (string.IsNullOrEmpty(version) || Canonical.MatchesVersion(r.GetConformanceVersion(), version))); } /// Filter instances for resources with the specified valueSet uri. @@ -248,46 +248,6 @@ private static IEnumerable GetConceptMapUrls(ArtifactSummary conceptMapS throw createException(source, modelInfo); } - /// - /// Determines if a resource version matches a query version according to FHIR canonical matching rules. - /// Supports both exact matching and partial version matching (e.g., "1.5" matches "1.5.0"). - /// - /// The version of the resource being checked. - /// The version specified in the canonical URL query. - /// True if the resource version matches the query version according to FHIR canonical matching rules. - private static bool MatchesVersion(string? resourceVersion, string queryVersion) - { - // If either version is null or empty, treat as no version specified - if (string.IsNullOrEmpty(resourceVersion) || string.IsNullOrEmpty(queryVersion)) - return string.IsNullOrEmpty(resourceVersion) && string.IsNullOrEmpty(queryVersion); - - // At this point, both resourceVersion and queryVersion are guaranteed to be non-null and non-empty - // Use null-forgiving operator since we've already checked for null/empty above - var resourceVersionSafe = resourceVersion!; - - // First try exact match for backwards compatibility and performance - if (resourceVersionSafe == queryVersion) - return true; - - // Implement partial version matching according to FHIR canonical matching rules - // The query version should be a prefix of the resource version when split by dots - var resourceParts = resourceVersionSafe.Split('.'); - var queryParts = queryVersion.Split('.'); - - // Query version cannot have more parts than resource version for partial matching - if (queryParts.Length > resourceParts.Length) - return false; - - // Check if all query version parts match the corresponding resource version parts - for (int i = 0; i < queryParts.Length; i++) - { - if (resourceParts[i] != queryParts[i]) - return false; - } - - return true; - } - #endregion } } diff --git a/src/Hl7.Fhir.STU3/Specification/Source/Summary/ArtifactSummaryExtensions.cs b/src/Hl7.Fhir.STU3/Specification/Source/Summary/ArtifactSummaryExtensions.cs index 344690d665..3eafd955c4 100644 --- a/src/Hl7.Fhir.STU3/Specification/Source/Summary/ArtifactSummaryExtensions.cs +++ b/src/Hl7.Fhir.STU3/Specification/Source/Summary/ArtifactSummaryExtensions.cs @@ -57,7 +57,7 @@ public static IEnumerable FindConformanceResources(this IEnumer var version = values.Length == 2 ? values[1] : string.Empty; return summaries.ConformanceResources().Where(r => r.GetConformanceCanonicalUrl() == values[0] && - (string.IsNullOrEmpty(version) || MatchesVersion(r.GetConformanceVersion(), version))); + (string.IsNullOrEmpty(version) || Canonical.MatchesVersion(r.GetConformanceVersion(), version))); } /// Filter instances for resources with the specified valueSet uri. @@ -248,42 +248,6 @@ static TSource SingleOrDefault(this IEnumerable source, Func - /// Determines if a resource version matches a query version according to FHIR canonical matching rules. - /// Supports both exact matching and partial version matching (e.g., "1.5" matches "1.5.0"). - /// - /// The version of the resource being checked. - /// The version specified in the canonical URL query. - /// True if the resource version matches the query version according to FHIR canonical matching rules. - private static bool MatchesVersion(string resourceVersion, string queryVersion) - { - // If either version is null or empty, treat as no version specified - if (string.IsNullOrEmpty(resourceVersion) || string.IsNullOrEmpty(queryVersion)) - return string.IsNullOrEmpty(resourceVersion) && string.IsNullOrEmpty(queryVersion); - - // First try exact match for backwards compatibility and performance - if (resourceVersion == queryVersion) - return true; - - // Implement partial version matching according to FHIR canonical matching rules - // The query version should be a prefix of the resource version when split by dots - var resourceParts = resourceVersion.Split('.'); - var queryParts = queryVersion.Split('.'); - - // Query version cannot have more parts than resource version for partial matching - if (queryParts.Length > resourceParts.Length) - return false; - - // Check if all query version parts match the corresponding resource version parts - for (int i = 0; i < queryParts.Length; i++) - { - if (resourceParts[i] != queryParts[i]) - return false; - } - - return true; - } - #endregion } } \ No newline at end of file From 2d2017a16b0b7ce5279a3b0c7a9deff76afff1e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Jul 2025 13:04:40 +0000 Subject: [PATCH 22/78] Fix NullReferenceException in primitive types GetHashCode() methods when Value is null Co-authored-by: ewoutkramer <2276305+ewoutkramer@users.noreply.github.com> --- src/Hl7.Fhir.Base/Model/Date-comparators.cs | 2 +- .../Model/FhirDateTime-comparators.cs | 2 +- .../Model/Instant-comparators.cs | 2 +- src/Hl7.Fhir.Base/Model/Time-comparators.cs | 2 +- src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs | 106 +++++++++++++++++- .../Validation/ValidatePatient.cs | 50 +++++++++ 6 files changed, 158 insertions(+), 6 deletions(-) diff --git a/src/Hl7.Fhir.Base/Model/Date-comparators.cs b/src/Hl7.Fhir.Base/Model/Date-comparators.cs index e221a27b68..84ed5c8121 100644 --- a/src/Hl7.Fhir.Base/Model/Date-comparators.cs +++ b/src/Hl7.Fhir.Base/Model/Date-comparators.cs @@ -110,6 +110,6 @@ public override bool Equals(object obj) return false; } - public override int GetHashCode() => Value.GetHashCode(); + public override int GetHashCode() => Value?.GetHashCode() ?? 0; } } diff --git a/src/Hl7.Fhir.Base/Model/FhirDateTime-comparators.cs b/src/Hl7.Fhir.Base/Model/FhirDateTime-comparators.cs index 4a86368875..45901b40a1 100644 --- a/src/Hl7.Fhir.Base/Model/FhirDateTime-comparators.cs +++ b/src/Hl7.Fhir.Base/Model/FhirDateTime-comparators.cs @@ -109,6 +109,6 @@ public override bool Equals(object obj) return false; } - public override int GetHashCode() => Value.GetHashCode(); + public override int GetHashCode() => Value?.GetHashCode() ?? 0; } } diff --git a/src/Hl7.Fhir.Base/Model/Instant-comparators.cs b/src/Hl7.Fhir.Base/Model/Instant-comparators.cs index f26232b34e..5cb82649dd 100644 --- a/src/Hl7.Fhir.Base/Model/Instant-comparators.cs +++ b/src/Hl7.Fhir.Base/Model/Instant-comparators.cs @@ -115,6 +115,6 @@ public override bool Equals(object obj) return false; } - public override int GetHashCode() => Value.GetHashCode(); + public override int GetHashCode() => Value?.GetHashCode() ?? 0; } } diff --git a/src/Hl7.Fhir.Base/Model/Time-comparators.cs b/src/Hl7.Fhir.Base/Model/Time-comparators.cs index afc069dcaf..210b41f427 100644 --- a/src/Hl7.Fhir.Base/Model/Time-comparators.cs +++ b/src/Hl7.Fhir.Base/Model/Time-comparators.cs @@ -118,6 +118,6 @@ public override bool Equals(object obj) return false; } - public override int GetHashCode() => Value.GetHashCode(); + public override int GetHashCode() => Value?.GetHashCode() ?? 0; } } diff --git a/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs b/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs index 10332a2e85..642e130a02 100644 --- a/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs +++ b/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs @@ -9,7 +9,11 @@ using FluentAssertions; using Hl7.Fhir.Model; using Hl7.Fhir.Utility; +using Hl7.Fhir.Validation; using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System; namespace Hl7.Fhir.Tests.Model { @@ -48,12 +52,110 @@ public void TestCheckMinorVersionCompatibiliy() Assert.IsFalse(ModelInfo.CheckMinorVersionCompatibility("3")); } - //If failed: change the description of the "STN" in the Currency enum of Money.cs from "SC#o TomC) and PrC-ncipe dobra" to "São Tomé and Príncipe dobra". + //If failed: change the description of the "STN" in the Currency enum of Money.cs from "SC#o TomC) and PrC-ncipe dobra" to "S�o Tom� and Pr�ncipe dobra". [TestMethod] public void TestCorrectCurrencyDescription() { var currency = Money.Currencies.STN; - currency.GetDocumentation().Should().Be("São Tomé and Príncipe dobra"); + currency.GetDocumentation().Should().Be("S�o Tom� and Pr�ncipe dobra"); + } + + [TestMethod] + public void ValidatePatientWithDataAbsentExtension() + { + // Test for issue #3171 - Patient.Validate(true) throws NullReferenceException + // when BirthDate has data-absent-reason extension but no value + var patient = new Patient() + { + BirthDateElement = new Date() + { + Extension = new List() + { + new Extension + { + Url = "http://hl7.org/fhir/StructureDefinition/data-absent-reason", + Value = new Code + { + Value = "unknown" + } + } + } + } + }; + + // This should not throw an exception + try + { + patient.Validate(true); + Assert.IsTrue(true, "Validation completed without throwing an exception"); + } + catch (NullReferenceException ex) + { + Assert.Fail($"Validation threw NullReferenceException: {ex.Message}"); + } + + // Also test with TryValidate + ICollection results = new List(); + try + { + DotNetAttributeValidation.TryValidate(patient, results, true); + Assert.IsTrue(true, "TryValidate completed without throwing an exception"); + } + catch (NullReferenceException ex) + { + Assert.Fail($"TryValidate threw NullReferenceException: {ex.Message}"); + } + } + + [TestMethod] + public void DateGetHashCodeWithNullValue() + { + // Direct test for Date.GetHashCode() with null value - reproduces issue #3171 + var date = new Date(); + // Verify that Value is null + Assert.IsNull(date.Value); + + // This should not throw NullReferenceException + try + { + int hashCode = date.GetHashCode(); + Assert.IsTrue(true, "GetHashCode completed without throwing an exception"); + } + catch (NullReferenceException ex) + { + Assert.Fail($"GetHashCode threw NullReferenceException: {ex.Message}"); + } + } + + [TestMethod] + public void AllPrimitiveTypesGetHashCodeWithNullValue() + { + // Test all primitive types to ensure they handle null values correctly + var date = new Date(); + var dateTime = new FhirDateTime(); + var instant = new Instant(); + var time = new Time(); + + // All should have null values + Assert.IsNull(date.Value); + Assert.IsNull(dateTime.Value); + Assert.IsNull(instant.Value); + Assert.IsNull(time.Value); + + // None should throw exceptions when GetHashCode is called + try + { + int hashCode1 = date.GetHashCode(); + int hashCode2 = dateTime.GetHashCode(); + int hashCode3 = instant.GetHashCode(); + int hashCode4 = time.GetHashCode(); + + Assert.IsTrue(true, "All GetHashCode calls completed without throwing exceptions"); + } + catch (NullReferenceException ex) + { + Assert.Fail($"One of the GetHashCode calls threw NullReferenceException: {ex.Message}"); + } } } } diff --git a/src/Hl7.Fhir.Shared.Tests/Validation/ValidatePatient.cs b/src/Hl7.Fhir.Shared.Tests/Validation/ValidatePatient.cs index 37ee920056..7f6fd3b482 100644 --- a/src/Hl7.Fhir.Shared.Tests/Validation/ValidatePatient.cs +++ b/src/Hl7.Fhir.Shared.Tests/Validation/ValidatePatient.cs @@ -47,5 +47,55 @@ public void ValidateDemoPatient() Assert.IsFalse(DotNetAttributeValidation.TryValidate(patient, results, true)); Assert.IsTrue(results.Count > 0); } + + [TestMethod] + public void ValidatePatientWithDataAbsentExtension() + { + // Test for issue #3171 - Patient.Validate(true) throws NullReferenceException + // when BirthDate has data-absent-reason extension but no value + var patient = new Patient() + { + BirthDateElement = new Date() + { + Extension = new List() + { + new Extension + { + Url = "http://hl7.org/fhir/StructureDefinition/data-absent-reason", + Value = new Code + { + Value = "unknown" + } + } + } + } + }; + + // This should not throw an exception + try + { + patient.Validate(true); + // If we get here, the validation succeeded without throwing an exception + Assert.IsTrue(true, "Validation completed without throwing an exception"); + } + catch (System.NullReferenceException ex) + { + Assert.Fail($"Validation threw NullReferenceException: {ex.Message}"); + } + + // Also test with TryValidate + ICollection results = new List(); + try + { + bool isValid = DotNetAttributeValidation.TryValidate(patient, results, true); + // The validation may or may not pass (depends on other validation rules), + // but it should not throw an exception + Assert.IsTrue(true, "TryValidate completed without throwing an exception"); + } + catch (System.NullReferenceException ex) + { + Assert.Fail($"TryValidate threw NullReferenceException: {ex.Message}"); + } + } } } From 20c8d81dc70fdd5115d0e23f499143c749a95d6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Jul 2025 13:45:55 +0000 Subject: [PATCH 23/78] Include exact test case from original issue #3171 in validation test Co-authored-by: ewoutkramer <2276305+ewoutkramer@users.noreply.github.com> --- src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs | 33 +++++++++-------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs b/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs index 642e130a02..b9204af234 100644 --- a/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs +++ b/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs @@ -65,6 +65,8 @@ public void ValidatePatientWithDataAbsentExtension() { // Test for issue #3171 - Patient.Validate(true) throws NullReferenceException // when BirthDate has data-absent-reason extension but no value + // This reproduces the exact scenario from the original issue #3171 report + var patient = new Patient() { BirthDateElement = new Date() @@ -83,28 +85,19 @@ public void ValidatePatientWithDataAbsentExtension() } }; - // This should not throw an exception - try - { - patient.Validate(true); - Assert.IsTrue(true, "Validation completed without throwing an exception"); - } - catch (NullReferenceException ex) - { - Assert.Fail($"Validation threw NullReferenceException: {ex.Message}"); - } + // This exact line was failing with "Object reference not set to an instance of an object" + // in netstandard2.0 and earlier .NET versions, due to GetHashCode() being called + // on primitive types with null values during validation + patient.Validate(true); // Should not throw NullReferenceException anymore - // Also test with TryValidate + // Ensure patient.Validate(false) still works as it did before + patient.Validate(false); // This was working before the fix + + // Also test with TryValidate to ensure both validation paths work ICollection results = new List(); - try - { - DotNetAttributeValidation.TryValidate(patient, results, true); - Assert.IsTrue(true, "TryValidate completed without throwing an exception"); - } - catch (NullReferenceException ex) - { - Assert.Fail($"TryValidate threw NullReferenceException: {ex.Message}"); - } + bool isValid = DotNetAttributeValidation.TryValidate(patient, results, true); + // The validation may or may not pass (depends on other validation rules), + // but it should not throw an exception } [TestMethod] From e3d0a2414eb67b3a9184ea39b4f7f58af87a6d9e Mon Sep 17 00:00:00 2001 From: Alexander Zautke Date: Tue, 22 Jul 2025 20:22:36 +0200 Subject: [PATCH 24/78] Fix encoding issues --- src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs b/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs index b9204af234..1d164b2132 100644 --- a/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs +++ b/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs @@ -52,12 +52,12 @@ public void TestCheckMinorVersionCompatibiliy() Assert.IsFalse(ModelInfo.CheckMinorVersionCompatibility("3")); } - //If failed: change the description of the "STN" in the Currency enum of Money.cs from "SC#o TomC) and PrC-ncipe dobra" to "S�o Tom� and Pr�ncipe dobra". + //If failed: change the description of the "STN" in the Currency enum of Money.cs from "SC#o TomC) and PrC-ncipe dobra" to "São Tomé and Príncipe dobra". [TestMethod] public void TestCorrectCurrencyDescription() { var currency = Money.Currencies.STN; - currency.GetDocumentation().Should().Be("S�o Tom� and Pr�ncipe dobra"); + currency.GetDocumentation().Should().Be("São Tomé and Príncipe dobra"); } [TestMethod] From 7dd77e9c0fc8779cdcdc12cd580b442dc56a11fb Mon Sep 17 00:00:00 2001 From: brian_pos Date: Wed, 23 Jul 2025 06:16:26 +1000 Subject: [PATCH 25/78] Change to a few overload to the function rather than adding an optional argument to the existing function. (from a usage side, looks the same) --- .../FhirPath/FhirPathCompiler.cs | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs b/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs index c4ff43d81f..def47d735a 100644 --- a/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs +++ b/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs @@ -52,11 +52,10 @@ public Expression Parse(string expression) /// Compiles a parsed FHIRPath expression into a delegate that can be used to evaluate the expression /// /// the parsed fhirpath expression to compile - /// An optional delegate to wire into the compilation that traces the processing steps /// - public CompiledExpression Compile(Expression expression, IDebugTracer debugTrace = null) + public CompiledExpression Compile(Expression expression) { - Invokee inv = expression.ToEvaluator(Symbols, debugTrace); + Invokee inv = expression.ToEvaluator(Symbols); return (ITypedElement focus, EvaluationContext ctx) => { @@ -65,13 +64,40 @@ public CompiledExpression Compile(Expression expression, IDebugTracer debugTrace }; } + /// + /// Compiles a parsed FHIRPath expression into a delegate that can be used to evaluate the expression + /// + /// the parsed fhirpath expression to compile + /// An optional delegate to wire into the compilation that traces the processing steps + /// + public CompiledExpression Compile(Expression expression, IDebugTracer debugTrace) + { + Invokee inv = expression.ToEvaluator(Symbols, debugTrace); + + return (ITypedElement focus, EvaluationContext ctx) => + { + var closure = Closure.Root(focus, ctx); + return inv(closure, InvokeeFactory.EmptyArgs); + }; + } + + /// + /// Compiles a FHIRPath expression string into a delegate that can be used to evaluate the expression + /// + /// the fhirpath expression to parse then compile + /// + public CompiledExpression Compile(string expression) + { + return Compile(Parse(expression)); + } + /// /// Compiles a FHIRPath expression string into a delegate that can be used to evaluate the expression /// /// the fhirpath expression to parse then compile /// An optional delegate to wire into the compilation that traces the processing steps /// - public CompiledExpression Compile(string expression, IDebugTracer debugTrace = null) + public CompiledExpression Compile(string expression, IDebugTracer debugTrace) { return Compile(Parse(expression), debugTrace); } From e5754af0b7770d3dd59e173f1123c23854fae465 Mon Sep 17 00:00:00 2001 From: brian_pos Date: Wed, 23 Jul 2025 14:16:10 +1000 Subject: [PATCH 26/78] Update the unit test with assertions that match the test data --- src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs index be22c044ad..51f261cadc 100644 --- a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs +++ b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs @@ -151,27 +151,34 @@ public void testDebugTrace_WhereClause() var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); var tracer = new TestDebugTracer(); var expr = compiler.Compile(expression, tracer); - var results = expr(input, new FhirEvaluationContext()).ToFhirValues().ToList(); + var results = expr(input, new FhirEvaluationContext()).ToList(); System.Diagnostics.Trace.WriteLine("Expression: " + expression); tracer.DumpDiagnostics(); Assert.AreEqual(3, results.Count()); - //Assert.AreEqual("Peter", ((Element)results[0]).getValue().toString()); - //Assert.AreEqual("James", ((Element)results.get(1)).getValue().toString()); - //Assert.AreEqual("Jim", ((Element)results.get(2)).getValue().toString()); + Assert.AreEqual("Peter", results[0].Value.ToString()); + Assert.AreEqual("James", results[1].Value.ToString()); + Assert.AreEqual("Jim", results[2].Value.ToString()); - //Assert.AreEqual("Patient.name[0].given[0]", ((Element)results[0]).getPath()); - //Assert.AreEqual("Patient.name[0].given[1]", ((Element)results.get(1)).getPath()); - //Assert.AreEqual("Patient.name[1].given[0]", ((Element)results.get(2)).getPath()); + Assert.AreEqual("Patient.name[0].given[0]", results[0].Location); + Assert.AreEqual("Patient.name[0].given[1]", results[1].Location); + Assert.AreEqual("Patient.name[1].given[0]", results[2].Location); - Assert.AreEqual(20, tracer.traceOutput.Count()); - Assert.AreEqual("0,4,name: focus=1 result=3", tracer.traceOutput[0]); + Assert.AreEqual(14, tracer.traceOutput.Count()); + Assert.AreEqual("0,4,name: focus=1 result=2", tracer.traceOutput[0]); Assert.AreEqual("11,3,use: focus=1 result=1", tracer.traceOutput[1]); Assert.AreEqual("15,10,constant: focus=1 result=1", tracer.traceOutput[2]); Assert.AreEqual("14,1,=: focus=1 result=1", tracer.traceOutput[3]); - - Assert.AreEqual("5,5,where: focus=3 result=2", tracer.traceOutput[18]); - Assert.AreEqual("42,5,given: focus=2 result=3", tracer.traceOutput[19]); + Assert.AreEqual("26,2,or: focus=1 result=1", tracer.traceOutput[4]); + Assert.AreEqual("11,3,use: focus=1 result=1", tracer.traceOutput[5]); + Assert.AreEqual("15,10,constant: focus=1 result=1", tracer.traceOutput[6]); + Assert.AreEqual("14,1,=: focus=1 result=1", tracer.traceOutput[7]); + Assert.AreEqual("29,3,use: focus=1 result=1", tracer.traceOutput[8]); + Assert.AreEqual("33,7,constant: focus=1 result=1", tracer.traceOutput[9]); + Assert.AreEqual("32,1,=: focus=1 result=1", tracer.traceOutput[10]); + Assert.AreEqual("26,2,or: focus=1 result=1", tracer.traceOutput[11]); + Assert.AreEqual("5,5,where: focus=1 result=2", tracer.traceOutput[12]); + Assert.AreEqual("42,5,given: focus=1 result=3", tracer.traceOutput[13]); } [TestMethod] From 28ecc360bc0438b6a12d5344b7718736442da226 Mon Sep 17 00:00:00 2001 From: brian_pos Date: Wed, 23 Jul 2025 16:42:50 +1000 Subject: [PATCH 27/78] Capture the focus used in the invokee to report to the debug tracer WrapWithPropNullForFocus - refactored the code in Wrap into the function - this caused the focus to be evaluted twice --- .../FhirPath/Expressions/DynaDispatcher.cs | 16 +- .../FhirPath/Expressions/EvaluatorVisitor.cs | 20 +- .../FhirPath/Expressions/Invokee.cs | 173 +++++++++++++----- .../FhirPath/Expressions/SymbolTableInit.cs | 104 ++++++----- .../FhirPath/FhirPathCompiler.cs | 4 +- src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs | 4 +- 6 files changed, 214 insertions(+), 107 deletions(-) diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/DynaDispatcher.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/DynaDispatcher.cs index 5548c9a249..eacae4d4ca 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/DynaDispatcher.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/DynaDispatcher.cs @@ -1,7 +1,7 @@ -/* +/* * Copyright (c) 2015, Firely (info@fire.ly) and contributors * See the file CONTRIBUTORS for details. - * + * * This file is licensed under the BSD 3-Clause license * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ @@ -11,6 +11,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using FocusCollection = System.Collections.Generic.IEnumerable; namespace Hl7.FhirPath.Expressions { @@ -25,17 +26,17 @@ public DynaDispatcher(string name, SymbolTable scope) private readonly string _name; private readonly SymbolTable _scope; - public IEnumerable Dispatcher(Closure context, IEnumerable args) + public FocusCollection Dispatcher(Closure context, IEnumerable args, out FocusCollection focus) { - var actualArgs = new List>(); + var actualArgs = new List(); - var focus = args.First()(context, InvokeeFactory.EmptyArgs); + focus = args.First()(context, InvokeeFactory.EmptyArgs, out _); if (!focus.Any()) return ElementNode.EmptyList; actualArgs.Add(focus); var newCtx = context.Nest(focus); - actualArgs.AddRange(args.Skip(1).Select(a => a(newCtx, InvokeeFactory.EmptyArgs))); + actualArgs.AddRange(args.Skip(1).Select(a => a(newCtx, InvokeeFactory.EmptyArgs, out _))); if (actualArgs.Any(aa => !aa.Any())) return ElementNode.EmptyList; var entry = _scope.DynamicGet(_name, actualArgs); @@ -46,9 +47,8 @@ public IEnumerable Dispatcher(Closure context, IEnumerable; namespace Hl7.FhirPath.Expressions { @@ -20,9 +21,8 @@ private Invokee WrapForDebugTracer(Invokee invokee, Expression expression) { if (_debugTrace != null) { - return (Closure context, IEnumerable arguments) => { - var result = invokee(context, arguments); - var focus = context.GetThat(); + return (Closure context, IEnumerable arguments, out FocusCollection focus) => { + var result = invokee(context, arguments, out focus); _debugTrace?.TraceCall(expression, focus, context.GetThis(), context.GetIndex()?.FirstOrDefault(), context.GetTotal(), result, context.Variables()); return result; }; @@ -98,9 +98,19 @@ public override Invokee VisitVariableRef(FP.VariableRefExpression expression) return WrapForDebugTracer(chainResolves, expression); - IEnumerable chainResolves(Closure context, IEnumerable invokees) + FocusCollection chainResolves(Closure context, IEnumerable invokees, out FocusCollection focus) { - return context.ResolveValue(expression.Name) ?? resolve(Symbols, expression.Name, Enumerable.Empty())(context, []); + var value = context.ResolveValue(expression.Name); + if (value != null) + { + // this was in the context, so the scope was $this (the context) + focus = context.GetThis(); + return value; + } + else + { + return resolve(Symbols, expression.Name, Enumerable.Empty())(context, [], out focus); + } } } diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs index d492d0db12..1e01d914bc 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs @@ -18,30 +18,49 @@ namespace Hl7.FhirPath.Expressions; -internal delegate FocusCollection Invokee(Closure context, IEnumerable arguments); +internal delegate FocusCollection Invokee(Closure context, IEnumerable arguments, out FocusCollection focus); internal static class InvokeeFactory { public static readonly IEnumerable EmptyArgs = []; - public static FocusCollection GetThis(Closure context, IEnumerable _) => context.GetThis(); + public static FocusCollection GetThis(Closure context, IEnumerable _, out FocusCollection focus) => focus = context.GetThis(); - public static FocusCollection GetTotal(Closure context, IEnumerable _) => context.GetTotal(); + public static FocusCollection GetTotal(Closure context, IEnumerable _, out FocusCollection focus) + { + focus = context.GetThis(); + return context.GetTotal(); + } - public static FocusCollection GetContext(Closure context, IEnumerable _) => - context.GetOriginalContext(); + public static FocusCollection GetContext(Closure context, IEnumerable _, out FocusCollection focus) + { + focus = context.GetThis(); + return context.GetOriginalContext(); + } - public static FocusCollection GetResource(Closure context, IEnumerable _) => - context.GetResource(); + public static FocusCollection GetResource(Closure context, IEnumerable _, out FocusCollection focus) + { + focus = context.GetThis(); + return context.GetResource(); + } - public static FocusCollection GetRootResource(Closure context, IEnumerable arguments) => - context.GetRootResource(); + public static FocusCollection GetRootResource(Closure context, IEnumerable arguments, out FocusCollection focus) + { + focus = context.GetThis(); + return context.GetRootResource(); + } - public static FocusCollection GetThat(Closure context, IEnumerable _) => - context.GetThat(); + public static FocusCollection GetThat(Closure context, IEnumerable _, out FocusCollection focus) + { + focus = context.GetThis(); + return context.GetThat(); + } - public static FocusCollection GetIndex(Closure context, IEnumerable args) => - context.GetIndex(); + public static FocusCollection GetIndex(Closure context, IEnumerable args, out FocusCollection focus) + { + focus = context.GetThis(); + return context.GetIndex(); + } private static readonly Predicate PROPAGATE_WHEN_EMPTY = focus => !focus.Any(); private static readonly Predicate PROPAGATE_NEVER = _ => false; @@ -70,50 +89,110 @@ true when isPrimitiveDotNetType(argType) => PROPAGATE_EMPTY_PRIMITIVE, public static Invokee Wrap(Func func) { - return (_, _) => Typecasts.CastTo(func()); + return (Closure ctx, IEnumerable _, out FocusCollection focus) => + { + focus = ctx.GetThis(); + return Typecasts.CastTo(func()); + }; } public static Invokee Wrap(Func func, bool propNull) { - return (ctx, args) => + return (Closure ctx, IEnumerable args, out FocusCollection focus) => { if (typeof(A) != typeof(EvaluationContext)) { - var focus = args.First()(ctx, EmptyArgs); - if (getPropagator(propNull, typeof(A))(focus)) return ElementNode.EmptyList; + focus = args.First()(ctx, EmptyArgs, out _); + if (getPropagator(propNull, typeof(A))(focus)) + return ElementNode.EmptyList; return Typecasts.CastTo(func(Typecasts.CastTo(focus))); } + else + { + focus = ctx.GetThis(); + } A lastPar = (A)(object)ctx.EvaluationContext; return Typecasts.CastTo(func(lastPar)); }; } + /// /// Wraps a function that is only supposed to propagate null in the focus, not in the other arguments. /// internal static Invokee WrapWithPropNullForFocus(Func func) { - return (ctx, args) => + return (Closure ctx, IEnumerable args, out FocusCollection focus) => { - // propagate only null for focus - var focus = args.First()(ctx, EmptyArgs); - if (getPropagator(true,typeof(A))(focus)) return ElementNode.EmptyList; + // Get the original focus first before any processing + var originalFocus = args.First()(ctx, EmptyArgs, out _); + + // Preserve the original focus for the debug tracer + focus = originalFocus; + + // Check for null propagation condition + if (getPropagator(true, typeof(A))(originalFocus)) + { + return ElementNode.EmptyList; + } - return Wrap(func, false)(ctx, args); + // For the actual function execution, we need a new Invokee that handles the arguments + // but doesn't modify the focus for debug tracing + if (typeof(B) != typeof(EvaluationContext)) + { + var argA = args.Skip(1).First()(ctx, EmptyArgs, out _); + if (getPropagator(false, typeof(B))(argA)) return ElementNode.EmptyList; + + if (typeof(C) != typeof(EvaluationContext)) + { + var argB = args.Skip(2).First()(ctx, EmptyArgs, out _); + if (getPropagator(false, typeof(C))(argB)) return ElementNode.EmptyList; + + return Typecasts.CastTo(func(Typecasts.CastTo(originalFocus), + Typecasts.CastTo(argA), + Typecasts.CastTo(argB))); + } + else + { + C lastPar = (C)(object)ctx.EvaluationContext; + return Typecasts.CastTo(func(Typecasts.CastTo(originalFocus), + Typecasts.CastTo(argA), lastPar)); + } + } + else + { + B argA = (B)(object)ctx.EvaluationContext; + + if (typeof(C) != typeof(EvaluationContext)) + { + var argB = args.Skip(2).First()(ctx, EmptyArgs, out _); + if (getPropagator(false, typeof(C))(argB)) return ElementNode.EmptyList; + + return Typecasts.CastTo(func(Typecasts.CastTo(originalFocus), + argA, + Typecasts.CastTo(argB))); + } + else + { + C lastPar = (C)(object)ctx.EvaluationContext; + return Typecasts.CastTo(func(Typecasts.CastTo(originalFocus), + argA, lastPar)); + } + } }; } public static Invokee Wrap(Func func, bool propNull) { - return (ctx, args) => + return (Closure ctx, IEnumerable args, out FocusCollection focus) => { - var focus = args.First()(ctx, EmptyArgs); + focus = args.First()(ctx, EmptyArgs, out _); if (getPropagator(propNull, typeof(A))(focus)) return ElementNode.EmptyList; if (typeof(B) != typeof(EvaluationContext)) { - var argA = args.Skip(1).First()(ctx, EmptyArgs); + var argA = args.Skip(1).First()(ctx, EmptyArgs, out _); if (getPropagator(propNull, typeof(B))(argA)) return ElementNode.EmptyList; return Typecasts.CastTo(func(Typecasts.CastTo(focus), Typecasts.CastTo(argA))); @@ -128,17 +207,17 @@ public static Invokee Wrap(Func func, bool propNull) public static Invokee Wrap(Func func, bool propNull) { - return (ctx, args) => + return (Closure ctx, IEnumerable args, out FocusCollection focus) => { - var focus = args.First()(ctx, EmptyArgs); + focus = args.First()(ctx, EmptyArgs, out _); if (getPropagator(propNull,typeof(A))(focus)) return ElementNode.EmptyList; - var argA = args.Skip(1).First()(ctx, EmptyArgs); + var argA = args.Skip(1).First()(ctx, EmptyArgs, out _); if (getPropagator(propNull, typeof(B))(argA)) return ElementNode.EmptyList; if (typeof(C) != typeof(EvaluationContext)) { - var argB = args.Skip(2).First()(ctx, EmptyArgs); + var argB = args.Skip(2).First()(ctx, EmptyArgs, out _); if (getPropagator(propNull, typeof(C))(argB)) return ElementNode.EmptyList; return Typecasts.CastTo(func(Typecasts.CastTo(focus), Typecasts.CastTo(argA), @@ -155,19 +234,19 @@ public static Invokee Wrap(Func func, bool propNull) public static Invokee Wrap(Func func, bool propNull) { - return (ctx, args) => + return (Closure ctx, IEnumerable args, out FocusCollection focus) => { - var focus = args.First()(ctx, EmptyArgs); + focus = args.First()(ctx, EmptyArgs, out _); if (getPropagator(propNull, typeof(A))(focus)) return ElementNode.EmptyList; - var argA = args.Skip(1).First()(ctx, EmptyArgs); + var argA = args.Skip(1).First()(ctx, EmptyArgs, out _); if (getPropagator(propNull, typeof(B))(argA)) return ElementNode.EmptyList; - var argB = args.Skip(2).First()(ctx, EmptyArgs); + var argB = args.Skip(2).First()(ctx, EmptyArgs, out _); if (getPropagator(propNull, typeof(C))(argB)) return ElementNode.EmptyList; if (typeof(D) != typeof(EvaluationContext)) { - var argC = args.Skip(3).First()(ctx, EmptyArgs); + var argC = args.Skip(3).First()(ctx, EmptyArgs, out _); if (getPropagator(propNull, typeof(D))(argC)) return ElementNode.EmptyList; return Typecasts.CastTo(func(Typecasts.CastTo(focus), @@ -186,31 +265,41 @@ public static Invokee Wrap(Func func, bool propNul public static Invokee WrapLogic(Func, Func, bool?> func) { - return (ctx, args) => + return (Closure ctx, IEnumerable args, out FocusCollection focus) => { // Ignore focus - // NOT GOOD, arguments need to be evaluated in the context of the focus to give "$that" meaning. + // Arguments to functions (except where etc) are not processed on the focus, they are processed on $this. + focus = ctx.GetThis(); var left = args.Skip(1).First(); var right = args.Skip(2).First(); // Return function that actually executes the Invokee at the last moment return Typecasts.CastTo( - func(() => left(ctx, EmptyArgs).BooleanEval(), () => right(ctx, EmptyArgs).BooleanEval())); + func(() => left(ctx, EmptyArgs, out _).BooleanEval(), + () => right(ctx, EmptyArgs, out _).BooleanEval())); }; } - public static Invokee Return(ITypedElement value) => (_, _) => [value]; + public static Invokee Return(ITypedElement value) => (Closure ctx, IEnumerable _, out FocusCollection focus) => + { + focus = ctx.GetThis(); + return [value]; + }; - public static Invokee Return(FocusCollection value) => (_, _) => value; + public static Invokee Return(FocusCollection value) => (Closure ctx, IEnumerable _, out FocusCollection focus) => + { + focus = ctx.GetThis(); + return value; + }; public static Invokee Invoke(string functionName, IEnumerable arguments, Invokee invokee) { - return (ctx, _) => + return (Closure ctx, IEnumerable _, out FocusCollection focus) => { try { var wrappedArguments = arguments.Skip(1).Select(wrapWithNextContext); - return invokee(ctx, [arguments.First(),.. wrappedArguments]); + return invokee(ctx, [arguments.First(),.. wrappedArguments], out focus); } catch (Exception e) { @@ -221,7 +310,7 @@ public static Invokee Invoke(string functionName, IEnumerable arguments static Invokee wrapWithNextContext(Invokee unwrappedArgument) { - return (ctx, args) => unwrappedArgument(ctx.Nest(ctx.GetThis()), args); + return (Closure ctx, IEnumerable args, out FocusCollection focus) => unwrappedArgument(ctx.Nest(ctx.GetThis()), args, out focus); } string formatFunctionName(string name) diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs index 4b2ba6f94f..64b4b989e2 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs @@ -1,7 +1,7 @@ -/* +/* * Copyright (c) 2015, Firely (info@fire.ly) and contributors * See the file CONTRIBUTORS for details. - * + * * This file is licensed under the BSD 3-Clause license * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ @@ -17,6 +17,7 @@ using System.Linq; using System.Text.RegularExpressions; using P = Hl7.Fhir.ElementModel.Types; +using FocusCollection = System.Collections.Generic.IEnumerable; namespace Hl7.FhirPath.Expressions; @@ -243,13 +244,13 @@ internal static void AddBuiltinChildren(this SymbolTable table) table.Add(new CallSignature("builtin.children", typeof(IEnumerable), typeof(IEnumerable), - typeof(string)), ( - ctx, invokees) => + typeof(string)), (Closure ctx, IEnumerable invokees, out FocusCollection focus) => { var iks = invokees.ToArray(); - var focus = iks[0].Invoke(ctx, InvokeeFactory.EmptyArgs); - var name = (string?)iks[1].Invoke(ctx, InvokeeFactory.EmptyArgs).First().Value; - var result= focus.Navigate(name); + var focusCollection = iks[0](ctx, InvokeeFactory.EmptyArgs, out _); + focus = focusCollection; + var name = (string?)iks[1](ctx, InvokeeFactory.EmptyArgs, out _).First().Value; + var result = focusCollection.Navigate(name); return result; }); @@ -265,78 +266,80 @@ private static string getCoreValueSetUrl(string id) return "http://hl7.org/fhir/ValueSet/" + id; } - private static IEnumerable runAggregate(Closure ctx, IEnumerable arguments) + private static IEnumerable runAggregate(Closure ctx, IEnumerable arguments, out FocusCollection focus) { - var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); + var focusCollection = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); + focus = focusCollection; var incrExpre = arguments.Skip(1).First(); IEnumerable initialValue = ElementNode.EmptyList; if (arguments.Count() > 2) { var initialValueExpr = arguments.Skip(2).First(); - initialValue = initialValueExpr(ctx, InvokeeFactory.EmptyArgs); + initialValue = initialValueExpr(ctx, InvokeeFactory.EmptyArgs, out _); } var totalContext = ctx.Nest(); totalContext.SetTotal(initialValue); - foreach (ITypedElement element in focus) + foreach (ITypedElement element in focusCollection) { var newFocus = ElementNode.CreateList(element); var newContext = totalContext.Nest(newFocus); newContext.SetThis(newFocus); newContext.SetTotal(totalContext.GetTotal()); - var newTotalResult = incrExpre(newContext, InvokeeFactory.EmptyArgs); + var newTotalResult = incrExpre(newContext, InvokeeFactory.EmptyArgs, out _); totalContext.SetTotal(newTotalResult); } return totalContext.GetTotal(); } - private static IEnumerable Trace(Closure ctx, IEnumerable arguments) + private static IEnumerable Trace(Closure ctx, IEnumerable arguments, out FocusCollection focus) { - var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); - var name = arguments.Skip(1).First()(ctx, InvokeeFactory.EmptyArgs).FirstOrDefault()?.Value as string; + focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); + var name = arguments.Skip(1).First()(ctx, InvokeeFactory.EmptyArgs, out _).FirstOrDefault()?.Value as string; List selectArgs = [arguments.First(), .. arguments.Skip(2)]; - var selectResults = runSelect(ctx, selectArgs); + var selectResults = runSelect(ctx, selectArgs, out _); ctx?.EvaluationContext?.Tracer?.Invoke(name, selectResults); return focus; } - private static IEnumerable DefineVariable(Closure ctx, IEnumerable arguments) + private static IEnumerable DefineVariable(Closure ctx, IEnumerable arguments, out FocusCollection focus) { Invokee[] enumerable = arguments as Invokee[] ?? arguments.ToArray(); - var focus = enumerable[0](ctx, InvokeeFactory.EmptyArgs); - var name = enumerable[1](ctx, InvokeeFactory.EmptyArgs).FirstOrDefault()?.Value as string; + var focusCollection = enumerable[0](ctx, InvokeeFactory.EmptyArgs, out _); + focus = focusCollection; + var name = enumerable[1](ctx, InvokeeFactory.EmptyArgs, out _).FirstOrDefault()?.Value as string; if(ctx.ResolveValue(name) is not null) throw new InvalidOperationException($"Variable {name} is already defined in this scope"); if (enumerable.Length == 2) { - ctx.SetValue(name, focus); + ctx.SetValue(name, focusCollection); } else { - var newContext = ctx.Nest(focus); - newContext.SetThis(focus); - var result = enumerable[2](newContext, InvokeeFactory.EmptyArgs); + var newContext = ctx.Nest(focusCollection); + newContext.SetThis(focusCollection); + var result = enumerable[2](newContext, InvokeeFactory.EmptyArgs, out _); ctx.SetValue(name, result); } - return focus; + return focusCollection; } - private static IEnumerable runIif(Closure ctx, IEnumerable arguments) + private static IEnumerable runIif(Closure ctx, IEnumerable arguments, out FocusCollection focus) { // iif(criterion: expression, true-result: collection [, otherwise-result: collection]) : collection // note: short-circuit behavior is expected in this function - var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); + focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); var newContext = ctx.Nest(focus); newContext.SetThis(focus); - var expression = arguments.Skip(1).First()(newContext, InvokeeFactory.EmptyArgs); + var expression = arguments.Skip(1).First()(newContext, InvokeeFactory.EmptyArgs, out _); var trueResult = arguments.Skip(2).First(); var otherResult = arguments.Skip(3).FirstOrDefault(); @@ -344,13 +347,14 @@ private static IEnumerable runIif(Closure ctx, IEnumerable runWhere(Closure ctx, IEnumerable arguments) + private static IEnumerable runWhere(Closure ctx, IEnumerable arguments, out FocusCollection focus) { - var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); + var focusCollection = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); + focus = focusCollection; var lambda = arguments.Skip(1).First(); return CachedEnumerable.Create(runForeach()); @@ -359,7 +363,7 @@ IEnumerable runForeach() { var index = 0; - foreach (ITypedElement element in focus) + foreach (ITypedElement element in focusCollection) { var newFocus = ElementNode.CreateList(element); var newContext = ctx.Nest(newFocus); @@ -367,15 +371,16 @@ IEnumerable runForeach() newContext.SetIndex(ElementNode.CreateList(index)); index++; - if (lambda(newContext, InvokeeFactory.EmptyArgs).BooleanEval() == true) + if (lambda(newContext, InvokeeFactory.EmptyArgs, out _).BooleanEval() == true) yield return element; } } } - private static IEnumerable runSelect(Closure ctx, IEnumerable arguments) + private static IEnumerable runSelect(Closure ctx, IEnumerable arguments, out FocusCollection focus) { - var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); + var focusCollection = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); + focus = focusCollection; var lambda = arguments.Skip(1).First(); return CachedEnumerable.Create(runForeach()); @@ -384,7 +389,7 @@ IEnumerable runForeach() { var index = 0; - foreach (ITypedElement element in focus) + foreach (ITypedElement element in focusCollection) { var newFocus = ElementNode.CreateList(element); var newContext = ctx.Nest(newFocus); @@ -392,16 +397,17 @@ IEnumerable runForeach() newContext.SetIndex(ElementNode.CreateList(index)); index++; - var result = lambda(newContext, InvokeeFactory.EmptyArgs); + var result = lambda(newContext, InvokeeFactory.EmptyArgs, out _); foreach (var resultElement in result) // implement SelectMany() yield return resultElement; } } } - private static IEnumerable runRepeat(Closure ctx, IEnumerable arguments) + private static IEnumerable runRepeat(Closure ctx, IEnumerable arguments, out FocusCollection focus) { - var newNodes = arguments.First()(ctx, InvokeeFactory.EmptyArgs).ToList(); + var newNodes = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _).ToList(); + focus = newNodes.ToArray(); var lambda = arguments.Skip(1).First(); var fullResult = new List(); @@ -420,7 +426,7 @@ private static IEnumerable runRepeat(Closure ctx, IEnumerable runRepeat(Closure ctx, IEnumerable runAll(Closure ctx, IEnumerable arguments) + private static IEnumerable runAll(Closure ctx, IEnumerable arguments, out FocusCollection focus) { - var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); + var focusCollection = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); + focus = focusCollection; var lambda = arguments.Skip(1).First(); var index = 0; - foreach (ITypedElement element in focus) + foreach (ITypedElement element in focusCollection) { var newFocus = ElementNode.CreateList(element); var newContext = ctx.Nest(newFocus); @@ -446,7 +453,7 @@ private static IEnumerable runAll(Closure ctx, IEnumerable runAll(Closure ctx, IEnumerable runAny(Closure ctx, IEnumerable arguments) + private static IEnumerable runAny(Closure ctx, IEnumerable arguments, out FocusCollection focus) { - var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); + var focusCollection = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); + focus = focusCollection; var lambda = arguments.Skip(1).First(); var index = 0; - foreach (ITypedElement element in focus) + foreach (ITypedElement element in focusCollection) { var newFocus = ElementNode.CreateList(element); var newContext = ctx.Nest(newFocus); @@ -468,7 +476,7 @@ private static IEnumerable runAny(Closure ctx, IEnumerable { var closure = Closure.Root(focus, ctx); - return inv(closure, InvokeeFactory.EmptyArgs); + return inv(closure, InvokeeFactory.EmptyArgs, out _); }; } @@ -77,7 +77,7 @@ public CompiledExpression Compile(Expression expression, IDebugTracer debugTrace return (ITypedElement focus, EvaluationContext ctx) => { var closure = Closure.Root(focus, ctx); - return inv(closure, InvokeeFactory.EmptyArgs); + return inv(closure, InvokeeFactory.EmptyArgs, out _); }; } diff --git a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs index 51f261cadc..36959be1a4 100644 --- a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs +++ b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs @@ -177,8 +177,8 @@ public void testDebugTrace_WhereClause() Assert.AreEqual("33,7,constant: focus=1 result=1", tracer.traceOutput[9]); Assert.AreEqual("32,1,=: focus=1 result=1", tracer.traceOutput[10]); Assert.AreEqual("26,2,or: focus=1 result=1", tracer.traceOutput[11]); - Assert.AreEqual("5,5,where: focus=1 result=2", tracer.traceOutput[12]); - Assert.AreEqual("42,5,given: focus=1 result=3", tracer.traceOutput[13]); + Assert.AreEqual("5,5,where: focus=2 result=2", tracer.traceOutput[12]); + Assert.AreEqual("42,5,given: focus=2 result=3", tracer.traceOutput[13]); } [TestMethod] From a982edc16be35426059ed8f58ebbd047343ff57a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:12:58 +0000 Subject: [PATCH 28/78] Refactor InMemoryResourceResolver to use Canonical class for URI parsing Co-authored-by: mmsmits <8956842+mmsmits@users.noreply.github.com> --- .../Specification/Source/InMemoryResourceResolver.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Hl7.Fhir.Base/Specification/Source/InMemoryResourceResolver.cs b/src/Hl7.Fhir.Base/Specification/Source/InMemoryResourceResolver.cs index be1f87c87e..4f5984390a 100644 --- a/src/Hl7.Fhir.Base/Specification/Source/InMemoryResourceResolver.cs +++ b/src/Hl7.Fhir.Base/Specification/Source/InMemoryResourceResolver.cs @@ -98,12 +98,9 @@ private void add(Resource resource) /// public Resource? ResolveByCanonicalUri(string uri) { - var values = uri.Split('|'); - if (values.Length > 2) - throw new ArgumentException("Url is not valid. The pipe occurs more than once."); - - var canonicalUrl = values[0]; - var version = values.Length == 2 ? values[1] : string.Empty; + var canonical = new Canonical(uri); + var canonicalUrl = canonical.Uri; + var version = canonical.Version ?? string.Empty; // Filter by canonical URL first var candidateResources = _resources.Where(r => r.Url == canonicalUrl).ToList(); From 88fd5aaa2e854e3ed3a752da483ad7f8976f584d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 23 Jul 2025 19:59:03 +0000 Subject: [PATCH 29/78] Remove unnecessary explicit null parameter in serializeInternal calls Co-authored-by: alexzautke <548617+alexzautke@users.noreply.github.com> --- src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs b/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs index 682e45f80b..fd70240810 100644 --- a/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs +++ b/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs @@ -64,7 +64,7 @@ public BaseFhirJsonPocoSerializer(FhirRelease release, FhirJsonPocoSerializerSet /// Serializes the given dictionary with FHIR data into Json. /// public void Serialize(IReadOnlyDictionary members, Utf8JsonWriter writer) => - serializeInternal(members, writer, skipValue: false, filter: null); + serializeInternal(members, writer, skipValue: false); /// /// Serializes the given dictionary with FHIR data into a Json string. @@ -73,7 +73,7 @@ public string SerializeToString(IReadOnlyDictionary members) { var stream = new MemoryStream(); var writer = new Utf8JsonWriter(stream); - serializeInternal(members, writer, skipValue: false, filter: null); + serializeInternal(members, writer, skipValue: false); writer.Flush(); return Encoding.UTF8.GetString(stream.ToArray()); } From 07c71d1e1ccf9093a085f2b670be484d3dfeb5fb Mon Sep 17 00:00:00 2001 From: brian_pos Date: Thu, 24 Jul 2025 07:30:17 +1000 Subject: [PATCH 30/78] Minor tweaks to cleanup co-pilots review --- src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs | 4 ++-- src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs index 1e01d914bc..09fc79608a 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs @@ -29,7 +29,7 @@ internal static class InvokeeFactory public static FocusCollection GetTotal(Closure context, IEnumerable _, out FocusCollection focus) { focus = context.GetThis(); - return context.GetTotal(); + return context.GetTotal(); } public static FocusCollection GetContext(Closure context, IEnumerable _, out FocusCollection focus) @@ -268,7 +268,7 @@ public static Invokee WrapLogic(Func, Func, bool?> func) return (Closure ctx, IEnumerable args, out FocusCollection focus) => { // Ignore focus - // Arguments to functions (except where etc) are not processed on the focus, they are processed on $this. + // Arguments to functions (except iterative functions like `where` and `select` that update the value of $this) are not processed on the focus, they are processed on $this. focus = ctx.GetThis(); var left = args.Skip(1).First(); var right = args.Skip(2).First(); diff --git a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs index 36959be1a4..3b2bc7108c 100644 --- a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs +++ b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs @@ -105,7 +105,6 @@ public string TraceExpressionNodeName(Expression expr) Debugger.Break(); #endif throw new Exception($"Unknown expression type: {expr.GetType().Name}"); - return expr.GetType().Name; } } From 81435f43a8e962529e879caddeeeb0b2fa8c108d Mon Sep 17 00:00:00 2001 From: brian_pos Date: Fri, 25 Jul 2025 08:29:15 +1000 Subject: [PATCH 31/78] refactor if else into switches --- .../FhirPath/DiagnosticsDebugTracer.cs | 121 +++++++++--------- src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs | 71 ++++------ 2 files changed, 90 insertions(+), 102 deletions(-) diff --git a/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs b/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs index d09eb3dc64..fd0282725c 100644 --- a/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs +++ b/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs @@ -5,6 +5,9 @@ * This file is licensed under the BSD 3-Clause license * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ + +#nullable enable + using Hl7.Fhir.ElementModel; using Hl7.FhirPath.Expressions; using System; @@ -20,72 +23,74 @@ public class DiagnosticsDebugTracer : IDebugTracer public void TraceCall( Expression expr, - IEnumerable focus, - IEnumerable thisValue, - ITypedElement index, + IEnumerable? focus, + IEnumerable? thisValue, + ITypedElement? index, IEnumerable totalValue, IEnumerable result, IEnumerable>> variables) { string exprName; - if (expr is IdentifierExpression ie) - return; - - if (expr is ConstantExpression ce) - { - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},constant"); - exprName = "constant"; - } - else if (expr is ChildExpression child) - { - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{child.ChildName}"); - exprName = child.ChildName; - } - else if (expr is IndexerExpression indexer) - { - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},[]"); - exprName = "[]"; - } - else if (expr is UnaryExpression ue) - { - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{ue.Op}"); - exprName = ue.Op; - } - else if (expr is BinaryExpression be) + + switch (expr) { - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{be.Op}"); - exprName = be.Op; - } - else if (expr is FunctionCallExpression fe) - { - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{fe.FunctionName}"); - exprName = fe.FunctionName; - } - else if (expr is NewNodeListInitExpression) - { - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{{}} (empty)"); - exprName = "{}"; - } - else if (expr is AxisExpression ae) - { - if (ae.AxisName == "that") + case IdentifierExpression _: return; - Trace.WriteLine($"Evaluated: {ae.AxisName} results: {result.Count()}"); - exprName = "$" + ae.AxisName; - } - else if (expr is VariableRefExpression ve) - { - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},%{ve.Name}"); - exprName = "%" + ve.Name; - } - else - { - exprName = expr.GetType().Name; + + case ConstantExpression ce: + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},constant"); + exprName = "constant"; + break; + + case ChildExpression child: + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{child.ChildName}"); + exprName = child.ChildName; + break; + + case IndexerExpression _: + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},[]"); + exprName = "[]"; + break; + + case UnaryExpression ue: + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{ue.Op}"); + exprName = ue.Op; + break; + + case BinaryExpression be: + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{be.Op}"); + exprName = be.Op; + break; + + case FunctionCallExpression fe: + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{fe.FunctionName}"); + exprName = fe.FunctionName; + break; + + case NewNodeListInitExpression _: + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{{}} (empty)"); + exprName = "{}"; + break; + + case AxisExpression ae: + if (ae.AxisName == "that") + return; + Trace.WriteLine($"Evaluated: {ae.AxisName} results: {result.Count()}"); + exprName = "$" + ae.AxisName; + break; + + case VariableRefExpression ve: + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},%{ve.Name}"); + exprName = "%" + ve.Name; + break; + + default: + exprName = expr.GetType().Name; #if DEBUG - Debugger.Break(); + Debugger.Break(); #endif - throw new Exception($"Unknown expression type: {expr.GetType().Name}"); - // Trace.WriteLine($"Evaluated: {expr} results: {result.Count()}"); + throw new Exception($"Unknown expression type: {expr.GetType().Name}"); + // Trace.WriteLine($"Evaluated: {expr} results: {result.Count()}"); } if (focus != null) @@ -126,7 +131,7 @@ public void TraceCall( } } - private static void DebugTraceValue(string exprName, ITypedElement item) + private static void DebugTraceValue(string exprName, ITypedElement? item) { if (item == null) return; // possible with a null focus to kick things off diff --git a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs index 3b2bc7108c..52874b9be7 100644 --- a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs +++ b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs @@ -58,54 +58,37 @@ public void TraceCall( public string TraceExpressionNodeName(Expression expr) { - if (expr is IdentifierExpression ie) - return null; - - if (expr is ConstantExpression ce) - { - return "constant"; - } - else if (expr is ChildExpression child) - { - return child.ChildName; - } - else if (expr is IndexerExpression indexer) + switch (expr) { - return "[]"; + case IdentifierExpression _: + return null; // we don't trace IdentifierExpressions, they are just names + case ConstantExpression ce: + return "constant"; + case ChildExpression child: + return child.ChildName; + case IndexerExpression indexer: + return "[]"; + case UnaryExpression ue: + return ue.Op; + case BinaryExpression be: + return be.Op; + case FunctionCallExpression fe: + return fe.FunctionName; + case NewNodeListInitExpression: + return "{}"; + case AxisExpression ae: + { + if (ae.AxisName == "that") + return null; + return "$" + ae.AxisName; + } + case VariableRefExpression ve: + return "%" + ve.Name; } - else if (expr is UnaryExpression ue) - { - return ue.Op; - } - else if (expr is BinaryExpression be) - { - return be.Op; - } - else if (expr is FunctionCallExpression fe) - { - return fe.FunctionName; - } - else if (expr is NewNodeListInitExpression) - { - return "{}"; - } - else if (expr is AxisExpression ae) - { - if (ae.AxisName == "that") - return null; - return "$" + ae.AxisName; - } - else if (expr is VariableRefExpression ve) - { - return "%" + ve.Name; - } - else - { #if DEBUG - Debugger.Break(); + Debugger.Break(); #endif - throw new Exception($"Unknown expression type: {expr.GetType().Name}"); - } + throw new Exception($"Unknown expression type: {expr.GetType().Name}"); } public void DumpDiagnostics() From 59d51abb2f5a4d164ea016d650b54947642d51c2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 19:08:13 +0000 Subject: [PATCH 32/78] Bump BenchmarkDotNet and Fhir.Metrics Bumps BenchmarkDotNet from 0.14.0 to 0.15.2 Bumps Fhir.Metrics from 1.3.0 to 1.3.1 --- updated-dependencies: - dependency-name: BenchmarkDotNet dependency-version: 0.15.2 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: Fhir.Metrics dependency-version: 1.3.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- src/Benchmarks/Benchmarks.csproj | 2 +- src/Hl7.Fhir.Base/Hl7.Fhir.Base.csproj | 2 +- src/firely-net-sdk.props | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Benchmarks/Benchmarks.csproj b/src/Benchmarks/Benchmarks.csproj index 58e6ac8123..416da47695 100644 --- a/src/Benchmarks/Benchmarks.csproj +++ b/src/Benchmarks/Benchmarks.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Hl7.Fhir.Base/Hl7.Fhir.Base.csproj b/src/Hl7.Fhir.Base/Hl7.Fhir.Base.csproj index e8830e0036..68ce0f7ad3 100644 --- a/src/Hl7.Fhir.Base/Hl7.Fhir.Base.csproj +++ b/src/Hl7.Fhir.Base/Hl7.Fhir.Base.csproj @@ -16,7 +16,7 @@ - + diff --git a/src/firely-net-sdk.props b/src/firely-net-sdk.props index d37e2e5ebf..11e788bb69 100644 --- a/src/firely-net-sdk.props +++ b/src/firely-net-sdk.props @@ -29,12 +29,12 @@ - - + + - + From d1b5c902321688a163b17948a9122e2f2b8fe395 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Jul 2025 19:41:53 +0000 Subject: [PATCH 33/78] Initial plan From a24f91808e5711b84d25fe4a4816213773763b6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Jul 2025 19:50:48 +0000 Subject: [PATCH 34/78] Initial analysis of build issue Co-authored-by: alexzautke <548617+alexzautke@users.noreply.github.com> --- src/Benchmarks/Benchmarks.csproj | 2 +- src/Hl7.Fhir.Base/Hl7.Fhir.Base.csproj | 2 +- src/firely-net-sdk.props | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Benchmarks/Benchmarks.csproj b/src/Benchmarks/Benchmarks.csproj index 58e6ac8123..416da47695 100644 --- a/src/Benchmarks/Benchmarks.csproj +++ b/src/Benchmarks/Benchmarks.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Hl7.Fhir.Base/Hl7.Fhir.Base.csproj b/src/Hl7.Fhir.Base/Hl7.Fhir.Base.csproj index e8830e0036..68ce0f7ad3 100644 --- a/src/Hl7.Fhir.Base/Hl7.Fhir.Base.csproj +++ b/src/Hl7.Fhir.Base/Hl7.Fhir.Base.csproj @@ -16,7 +16,7 @@ - + diff --git a/src/firely-net-sdk.props b/src/firely-net-sdk.props index d37e2e5ebf..11e788bb69 100644 --- a/src/firely-net-sdk.props +++ b/src/firely-net-sdk.props @@ -29,12 +29,12 @@ - - + + - + From 85572119d1854fde6ad7ba7ab783207afc89069d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Jul 2025 19:53:43 +0000 Subject: [PATCH 35/78] Fix nullability mismatch in Ucum.cs after Fhir.Metrics update Co-authored-by: alexzautke <548617+alexzautke@users.noreply.github.com> --- src/Hl7.Fhir.Base/ElementModel/Types/Ucum.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Hl7.Fhir.Base/ElementModel/Types/Ucum.cs b/src/Hl7.Fhir.Base/ElementModel/Types/Ucum.cs index f16280ddcb..0b61741375 100644 --- a/src/Hl7.Fhir.Base/ElementModel/Types/Ucum.cs +++ b/src/Hl7.Fhir.Base/ElementModel/Types/Ucum.cs @@ -100,7 +100,7 @@ internal static bool TryDivide(this Quantity quantity, Quantity divisor, [NotNul return true; } - private static Quantity quantityFromTuple((string value, string unit, string codesystem) quantity) + private static Quantity quantityFromTuple((string value, string unit, string? codesystem) quantity) { return new Quantity( decimal.Parse(quantity.value, NumberStyles.Any, CultureInfo.InvariantCulture), From 0ec42ca5123c6217c73dc5b3405b5774a6e987eb Mon Sep 17 00:00:00 2001 From: brian_pos Date: Fri, 15 Aug 2025 15:36:45 +1000 Subject: [PATCH 36/78] Introduce a closure Id (tracked based on closures created within an execution context - eases debugging) Move the debugTracer object to the evaluationContext, add a flag to indicate that the compiler should create the hooks to call the tracer. Add the debug context Id to the trace function call. --- .../FhirPath/DiagnosticsDebugTracer.cs | 43 ++++++++-------- .../FhirPath/EvaluationContext.cs | 13 +++-- .../FhirPath/Expressions/Closure.cs | 49 +++++++++++-------- .../FhirPath/Expressions/EvaluatorVisitor.cs | 27 +++++++--- .../FhirPath/FhirPathCompiler.cs | 12 ++--- src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs | 1 + src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs | 15 +++--- .../PocoTests/FhirPathEvaluatorTest.cs | 4 +- .../Tests/BasicFunctionTests.cs | 12 ++--- 9 files changed, 102 insertions(+), 74 deletions(-) diff --git a/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs b/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs index fd0282725c..27ec3e49cd 100644 --- a/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs +++ b/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs @@ -23,6 +23,7 @@ public class DiagnosticsDebugTracer : IDebugTracer public void TraceCall( Expression expr, + int contextId, IEnumerable? focus, IEnumerable? thisValue, ITypedElement? index, @@ -31,65 +32,65 @@ public void TraceCall( IEnumerable>> variables) { string exprName; - + switch (expr) { case IdentifierExpression _: return; - + case ConstantExpression ce: - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},constant"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},constant (ctx.id: {contextId})"); exprName = "constant"; break; - + case ChildExpression child: - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{child.ChildName}"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{child.ChildName} (ctx.id: {contextId})"); exprName = child.ChildName; break; - + case IndexerExpression _: - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},[]"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},[] (ctx.id: {contextId})"); exprName = "[]"; break; - + case UnaryExpression ue: - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{ue.Op}"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{ue.Op} (ctx.id: {contextId})"); exprName = ue.Op; break; - + case BinaryExpression be: - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{be.Op}"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{be.Op} (ctx.id: {contextId})"); exprName = be.Op; break; - + case FunctionCallExpression fe: - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{fe.FunctionName}"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{fe.FunctionName} (ctx.id: {contextId})"); exprName = fe.FunctionName; break; - + case NewNodeListInitExpression _: - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{{}} (empty)"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{{}} (empty) (ctx.id: {contextId})"); exprName = "{}"; break; - + case AxisExpression ae: if (ae.AxisName == "that") return; - Trace.WriteLine($"Evaluated: {ae.AxisName} results: {result.Count()}"); + Trace.WriteLine($"Evaluated: {ae.AxisName} results: {result.Count()} (ctx.id: {contextId})"); exprName = "$" + ae.AxisName; break; - + case VariableRefExpression ve: - Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},%{ve.Name}"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},%{ve.Name} (ctx.id: {contextId})"); exprName = "%" + ve.Name; break; - + default: exprName = expr.GetType().Name; #if DEBUG Debugger.Break(); #endif - throw new Exception($"Unknown expression type: {expr.GetType().Name}"); + throw new Exception($"Unknown expression type: {expr.GetType().Name} (ctx.id: {contextId})"); // Trace.WriteLine($"Evaluated: {expr} results: {result.Count()}"); } diff --git a/src/Hl7.Fhir.Base/FhirPath/EvaluationContext.cs b/src/Hl7.Fhir.Base/FhirPath/EvaluationContext.cs index 63fb38c261..2ac01b6633 100644 --- a/src/Hl7.Fhir.Base/FhirPath/EvaluationContext.cs +++ b/src/Hl7.Fhir.Base/FhirPath/EvaluationContext.cs @@ -11,7 +11,9 @@ public class EvaluationContext [Obsolete("This method does not initialize any members and will be removed in a future version. Use the empty constructor instead.")] public static EvaluationContext CreateDefault() => new(); - + private int ClosuresCreated { get; set; } = 0; + internal int IncrementClosuresCreatedCount() => ClosuresCreated++; + public EvaluationContext() { // no defaults yet @@ -35,13 +37,13 @@ public EvaluationContext(ITypedElement? resource, ITypedElement? rootResource) Resource = resource; RootResource = rootResource ?? resource; } - + [Obsolete("%resource and %rootResource are inferred from scoped nodes by the evaluator. If you do not have access to a scoped node, or if you wish to explicitly override this behaviour, use the EvaluationContext.WithResourceOverrides() method. Environment can be set explicitly after construction of the base context")] public EvaluationContext(ITypedElement? resource, ITypedElement? rootResource, IDictionary> environment) : this(resource, rootResource) { Environment = environment; } - + /// /// The data represented by %rootResource. /// @@ -61,6 +63,11 @@ public EvaluationContext(ITypedElement? resource, ITypedElement? rootResource, I /// A delegate that handles the output for the trace() function. /// public Action>? Tracer { get; set; } + + /// + /// Gets or sets the tracer used for capturing debug information during evaluation + /// + public IDebugTracer? DebugTracer { get; set; } } public static class EvaluationContextExtensions diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs index 15923843dc..17bdb98fc2 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs @@ -1,7 +1,7 @@ -/* +/* * Copyright (c) 2015, Firely (info@fire.ly) and contributors * See the file CONTRIBUTORS for details. - * + * * This file is licensed under the BSD 3-Clause license * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ @@ -17,7 +17,20 @@ namespace Hl7.FhirPath.Expressions { internal class Closure { - public Closure() + internal int Id { get; private set; } + + public Closure(EvaluationContext ctx) + { + EvaluationContext = ctx; + Id = ctx.IncrementClosuresCreatedCount(); + } + + public Closure(Closure parent, EvaluationContext ctx) + { + Parent = parent; + EvaluationContext = ctx; + Id = ctx.IncrementClosuresCreatedCount(); + } { } @@ -26,22 +39,22 @@ public Closure() public static Closure Root(ITypedElement root, EvaluationContext ctx = null) { var newContext = ctx ?? new EvaluationContext(); - + var node = root as ScopedNode; - + newContext.Resource ??= node != null // if the value has been manually set, we do nothing. Otherwise, if the root is a scoped node: ? getResourceFromNode(node) // we infer the resource from the scoped node : (root?.Definition?.IsResource is true // if we do not have a scoped node, we see if this is even a resource to begin with ? root // if it is, we use the root as the resource : null // if not, this breaks the spec in every way (but we will still continue, hopefully we do not need %resource or %rootResource) - ); - + ); + // Same thing, but we copy the resource into the root resource if we cannot infer it from the node. - newContext.RootResource ??= node != null - ? getRootResourceFromNode(node) - : newContext.Resource; - - var newClosure = new Closure() { EvaluationContext = ctx ?? new EvaluationContext() }; + newContext.RootResource ??= node != null + ? getRootResourceFromNode(node) + : newContext.Resource; + + var newClosure = new Closure(ctx ?? new EvaluationContext()); var input = new[] { root }; @@ -49,12 +62,12 @@ public static Closure Root(ITypedElement root, EvaluationContext ctx = null) { newClosure.SetValue(assignment.Key, assignment.Value); } - + newClosure.SetThis(input); newClosure.SetThat(input); newClosure.SetIndex(ElementNode.CreateList(0)); newClosure.SetOriginalContext(input); - + if (newContext.Resource != null) newClosure.SetResource(new[] { newContext.Resource }); if (newContext.RootResource != null) newClosure.SetRootResource(new[] { newContext.RootResource }); @@ -79,11 +92,7 @@ public virtual void SetValue(string name, IEnumerable value) public virtual Closure Nest() { - return new Closure() - { - Parent = this, - EvaluationContext = this.EvaluationContext - }; + return new Closure(this, EvaluationContext); } @@ -105,7 +114,7 @@ public virtual IEnumerable ResolveValue(string name) } private static ScopedNode getResourceFromNode(ScopedNode node) => node.AtResource ? node : node.ParentResource; - + private static ScopedNode getRootResourceFromNode(ScopedNode node) { var resource = getResourceFromNode(node); diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs index 9a77ea293b..40b473f907 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs @@ -19,11 +19,11 @@ internal class EvaluatorVisitor : FP.ExpressionVisitor { private Invokee WrapForDebugTracer(Invokee invokee, Expression expression) { - if (_debugTrace != null) + if (_injectDebugHook) { return (Closure context, IEnumerable arguments, out FocusCollection focus) => { var result = invokee(context, arguments, out focus); - _debugTrace?.TraceCall(expression, focus, context.GetThis(), context.GetIndex()?.FirstOrDefault(), context.GetTotal(), result, context.Variables()); + context.EvaluationContext.DebugTracer?.TraceCall(expression, context.Id, focus, context.GetThis(), context.GetIndex()?.FirstOrDefault(), context.GetTotal(), result, context.Variables()); return result; }; } @@ -31,14 +31,19 @@ private Invokee WrapForDebugTracer(Invokee invokee, Expression expression) } public SymbolTable Symbols { get; } - private IDebugTracer _debugTrace; + private bool _injectDebugHook; public EvaluatorVisitor(SymbolTable symbols, IDebugTracer debugTrace = null) { Symbols = symbols; - _debugTrace = debugTrace; + _injectDebugHook = true; } + public EvaluatorVisitor(SymbolTable symbols, bool injectDebugHook) + { + Symbols = symbols; + _injectDebugHook = injectDebugHook; + } public override Invokee VisitConstant(FP.ConstantExpression expression) { @@ -47,9 +52,9 @@ public override Invokee VisitConstant(FP.ConstantExpression expression) public override Invokee VisitFunctionCall(FP.FunctionCallExpression expression) { - var focus = expression.Focus.ToEvaluator(Symbols, _debugTrace); + var focus = expression.Focus.ToEvaluator(Symbols, _injectDebugHook); var arguments = new List() { focus }; - arguments.AddRange(expression.Arguments.Select(arg => arg.ToEvaluator(Symbols, _debugTrace))); + arguments.AddRange(expression.Arguments.Select(arg => arg.ToEvaluator(Symbols, _injectDebugHook))); // We have no real type information, so just pass object as the type var types = new List() { typeof(object) }; // for the focus; @@ -149,9 +154,15 @@ private static Invokee resolve(SymbolTable scope, string name, IEnumerable internal static class EvaluatorExpressionExtensions { - public static Invokee ToEvaluator(this FP.Expression expr, SymbolTable scope, IDebugTracer debugTrace = null) + public static Invokee ToEvaluator(this FP.Expression expr, SymbolTable scope) + { + var compiler = new EvaluatorVisitor(scope); + return expr.Accept(compiler); + } + + public static Invokee ToEvaluator(this FP.Expression expr, SymbolTable scope, bool injectDebugTraceHooks) { - var compiler = new EvaluatorVisitor(scope, debugTrace); + var compiler = new EvaluatorVisitor(scope, injectDebugTraceHooks); return expr.Accept(compiler); } } diff --git a/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs b/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs index 145cf880d8..f2e288596d 100644 --- a/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs +++ b/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs @@ -68,11 +68,11 @@ public CompiledExpression Compile(Expression expression) /// Compiles a parsed FHIRPath expression into a delegate that can be used to evaluate the expression /// /// the parsed fhirpath expression to compile - /// An optional delegate to wire into the compilation that traces the processing steps + /// Inject the required hooks into the compiled evaluator to support debug tracing via the EvaluationContext /// - public CompiledExpression Compile(Expression expression, IDebugTracer debugTrace) + public CompiledExpression Compile(Expression expression, bool injectDebugTraceHooks) { - Invokee inv = expression.ToEvaluator(Symbols, debugTrace); + Invokee inv = expression.ToEvaluator(Symbols, injectDebugTraceHooks); return (ITypedElement focus, EvaluationContext ctx) => { @@ -95,11 +95,11 @@ public CompiledExpression Compile(string expression) /// Compiles a FHIRPath expression string into a delegate that can be used to evaluate the expression /// /// the fhirpath expression to parse then compile - /// An optional delegate to wire into the compilation that traces the processing steps + /// Inject the required hooks into the compiled evaluator to support debug tracing via the EvaluationContext /// - public CompiledExpression Compile(string expression, IDebugTracer debugTrace) + public CompiledExpression Compile(string expression, bool injectDebugTraceHooks) { - return Compile(Parse(expression), debugTrace); + return Compile(Parse(expression), injectDebugTraceHooks); } } } diff --git a/src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs b/src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs index 990ff9165d..62953a33c1 100644 --- a/src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs +++ b/src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs @@ -17,6 +17,7 @@ namespace Hl7.FhirPath public interface IDebugTracer { void TraceCall(Expression expr, + int contextId, IEnumerable focus, IEnumerable thisValue, ITypedElement index, diff --git a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs index 52874b9be7..e455ed6cf1 100644 --- a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs +++ b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs @@ -40,6 +40,7 @@ private class TestDebugTracer: IDebugTracer public List traceOutput = new List(); public void TraceCall( Expression expr, + int contextId, IEnumerable focus, IEnumerable thisValue, ITypedElement index, @@ -100,8 +101,6 @@ public void DumpDiagnostics() } } - - [TestMethod] public void testDebugTrace_PropertyWalking() { @@ -132,8 +131,8 @@ public void testDebugTrace_WhereClause() var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); var tracer = new TestDebugTracer(); - var expr = compiler.Compile(expression, tracer); - var results = expr(input, new FhirEvaluationContext()).ToList(); + var expr = compiler.Compile(expression, true); + var results = expr(input, new FhirEvaluationContext() { DebugTracer = tracer }).ToList(); System.Diagnostics.Trace.WriteLine("Expression: " + expression); tracer.DumpDiagnostics(); @@ -170,8 +169,8 @@ public void testDebugTrace_ConstantValues() var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); var tracer = new TestDebugTracer(); - var expr = compiler.Compile(expression, tracer); - var results = expr(input, new FhirEvaluationContext()).ToFhirValues().ToList(); + var expr = compiler.Compile(expression, true); + var results = expr(input, new FhirEvaluationContext() { DebugTracer = tracer }).ToFhirValues().ToList(); System.Diagnostics.Trace.WriteLine("Expression: " + expression); tracer.DumpDiagnostics(); @@ -189,8 +188,8 @@ public void testDebugTrace_GroupedOr() var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); var tracer = new TestDebugTracer(); - var expr = compiler.Compile(expression, tracer); - var results = expr(input, new FhirEvaluationContext()).ToFhirValues().ToList(); + var expr = compiler.Compile(expression, true); + var results = expr(input, new FhirEvaluationContext() { DebugTracer = tracer }).ToFhirValues().ToList(); System.Diagnostics.Trace.WriteLine("Expression: " + expression); tracer.DumpDiagnostics(); diff --git a/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs b/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs index 26bec1eb4f..9dece4f1ea 100644 --- a/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs +++ b/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs @@ -95,8 +95,8 @@ public bool IsBoolean(Base baseInput, string expression, bool value, EvaluationC // Don't use the expression cache as we need to inject the debug tracer var compiler = new FhirPathCompiler(); - var evaluator = compiler.Compile(expression, new DiagnosticsDebugTracer()); - return evaluator.IsBoolean(value, input, ctx ?? new EvaluationContext()); + var evaluator = compiler.Compile(expression, true); + return evaluator.IsBoolean(value, input, ctx ?? new EvaluationContext() { DebugTracer = new DiagnosticsDebugTracer() }); } diff --git a/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs b/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs index ab079d26a4..594f6cdd30 100644 --- a/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs +++ b/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs @@ -25,24 +25,24 @@ private static void isB(string expr, object value = null) { ITypedElement dummy = ElementNode.ForPrimitive(value ?? true).ToScopedNode(); var compiler = new FhirPathCompiler(); - var evaluator = compiler.Compile(expr, new DiagnosticsDebugTracer()); - Assert.IsTrue(evaluator.IsBoolean(true, dummy, new EvaluationContext())); + var evaluator = compiler.Compile(expr, true); + Assert.IsTrue(evaluator.IsBoolean(true, dummy, new EvaluationContext() { DebugTracer = new DiagnosticsDebugTracer() })); } private static object scalar(string expr) { ITypedElement dummy = ElementNode.ForPrimitive(true).ToScopedNode(); var compiler = new FhirPathCompiler(); - var evaluator = compiler.Compile(expr, new DiagnosticsDebugTracer()); - return evaluator.Scalar(dummy, new EvaluationContext()); + var evaluator = compiler.Compile(expr, true); + return evaluator.Scalar(dummy, new EvaluationContext() { DebugTracer = new DiagnosticsDebugTracer() }); } private static object scalar(ITypedElement dummy, string expr) { dummy = dummy.ToScopedNode(); var compiler = new FhirPathCompiler(); - var evaluator = compiler.Compile(expr, new DiagnosticsDebugTracer()); - return evaluator.Scalar(dummy, new EvaluationContext()); + var evaluator = compiler.Compile(expr, true); + return evaluator.Scalar(dummy, new EvaluationContext() { DebugTracer = new DiagnosticsDebugTracer() }); } [TestMethod] From 7a18bd79775f89840244651bdebbf6c0d75c6a81 Mon Sep 17 00:00:00 2001 From: brian_pos Date: Fri, 15 Aug 2025 15:59:03 +1000 Subject: [PATCH 37/78] Partial commit issue - resolve compilation issue --- src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs index 17bdb98fc2..837f9a6b9f 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs @@ -31,8 +31,6 @@ public Closure(Closure parent, EvaluationContext ctx) EvaluationContext = ctx; Id = ctx.IncrementClosuresCreatedCount(); } - { - } public EvaluationContext EvaluationContext { get; private set; } From 9bc70098e7e947c827fb261794ac3a14d35b45bb Mon Sep 17 00:00:00 2001 From: brian_pos Date: Fri, 15 Aug 2025 16:11:38 +1000 Subject: [PATCH 38/78] Unit test updated --- src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs index e455ed6cf1..3fb2df1cd3 100644 --- a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs +++ b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs @@ -107,8 +107,8 @@ public void testDebugTrace_PropertyWalking() var expression = "Patient.birthDate.toString().substring(0, 4)"; var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); var tracer = new TestDebugTracer(); - var expr = compiler.Compile(expression, tracer); - var results = expr(input, new FhirEvaluationContext()).ToFhirValues().ToList(); + var expr = compiler.Compile(expression, true); + var results = expr(input, new FhirEvaluationContext() { DebugTracer = tracer }).ToFhirValues().ToList(); System.Diagnostics.Trace.WriteLine("Expression: " + expression); tracer.DumpDiagnostics(); From 9a3744ecb95a052a4171794d07ea7b6e3884fd4e Mon Sep 17 00:00:00 2001 From: brian_pos Date: Sat, 16 Aug 2025 09:47:31 +1000 Subject: [PATCH 39/78] Since the tests process multiple expressions, dump the specific expression to the diagnostics output so that the other logging is tied to the expression, makes post processing failed tests easier without needing to re-run the tests locally. --- .../PocoTests/FhirPathEvaluatorTest.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs b/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs index 9dece4f1ea..0abcb4a138 100644 --- a/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs +++ b/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs @@ -96,6 +96,12 @@ public bool IsBoolean(Base baseInput, string expression, bool value, EvaluationC // Don't use the expression cache as we need to inject the debug tracer var compiler = new FhirPathCompiler(); var evaluator = compiler.Compile(expression, true); + + System.Diagnostics.Trace.WriteLine(""); + System.Diagnostics.Trace.WriteLine("------------------------------------"); + System.Diagnostics.Trace.WriteLine(expression); + System.Diagnostics.Trace.WriteLine("------------------------------------"); + return evaluator.IsBoolean(value, input, ctx ?? new EvaluationContext() { DebugTracer = new DiagnosticsDebugTracer() }); } From 1d2c6fcc2d96d775361f90bd4778267673854939 Mon Sep 17 00:00:00 2001 From: brian_pos Date: Mon, 18 Aug 2025 14:38:09 +1000 Subject: [PATCH 40/78] Stash the focus into the context, and restore it after processing the invokee and logging. Expose some of the debug tracer calls to ease sharing with the tests (and possibly other engine outputs). --- .../FhirPath/DiagnosticsDebugTracer.cs | 38 +++++++++----- .../FhirPath/Expressions/Closure.cs | 22 +++++++++ .../FhirPath/Expressions/DynaDispatcher.cs | 1 + .../FhirPath/Expressions/EvaluatorVisitor.cs | 6 ++- .../FhirPath/Expressions/Invokee.cs | 49 +++++++++++++++++-- .../FhirPath/Expressions/SymbolTableInit.cs | 21 +++++++- src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs | 27 ++++++++++ 7 files changed, 146 insertions(+), 18 deletions(-) diff --git a/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs b/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs index 27ec3e49cd..ef6016f98f 100644 --- a/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs +++ b/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs @@ -20,7 +20,6 @@ namespace Hl7.FhirPath public class DiagnosticsDebugTracer : IDebugTracer { - public void TraceCall( Expression expr, int contextId, @@ -30,6 +29,19 @@ public void TraceCall( IEnumerable totalValue, IEnumerable result, IEnumerable>> variables) + { + DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, variables); + } + + public static void DebugTraceCall( + Expression expr, + int contextId, + IEnumerable? focus, + IEnumerable? thisValue, + ITypedElement? index, + IEnumerable totalValue, + IEnumerable result, + IEnumerable>> variables) { string exprName; @@ -76,7 +88,7 @@ public void TraceCall( case AxisExpression ae: if (ae.AxisName == "that") return; - Trace.WriteLine($"Evaluated: {ae.AxisName} results: {result.Count()} (ctx.id: {contextId})"); + Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},${ae.AxisName} (ctx.id: {contextId})"); exprName = "$" + ae.AxisName; break; @@ -94,19 +106,19 @@ public void TraceCall( // Trace.WriteLine($"Evaluated: {expr} results: {result.Count()}"); } - if (focus != null) + if (result != null) { - foreach (var item in focus) + foreach (var item in result) { - DebugTraceValue($"$focus", item); + DebugTraceValue($"{exprName} »", item); } } - if (thisValue != null) + if (focus != null) { - foreach (var item in thisValue) + foreach (var item in focus) { - DebugTraceValue("$this", item); + DebugTraceValue($"$focus", item); } } @@ -115,17 +127,17 @@ public void TraceCall( DebugTraceValue("$index", index); } - if (totalValue != null) + if (thisValue != null) { - foreach (var item in totalValue) + foreach (var item in thisValue) { - DebugTraceValue($"{exprName} »", item); + DebugTraceValue("$this", item); } } - if (result != null) + if (totalValue != null) { - foreach (var item in result) + foreach (var item in totalValue) { DebugTraceValue($"{exprName} »", item); } diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs index 837f9a6b9f..95afb2ccd3 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs @@ -32,6 +32,28 @@ public Closure(Closure parent, EvaluationContext ctx) Id = ctx.IncrementClosuresCreatedCount(); } + /// + /// When the debug/trace is enabled this property is used to record the focus of the closure. + /// It is set in the delegate produced for each node by the evaluator visitor. + /// The value is set immediately before returning the result of the evaluation of the node, + /// after all it's processing, this must be done as the same context is re-used in many + /// cases, and thus needs to be re-set just before it returns from the delegate. + /// The debug tracer uses this information in the wrapped delegate to report not only the + /// result of the expression, but also the other states of the closure, such as the focus, + /// resource, root resource, etc. + /// The $this variable doesn't change within a closure object, so it is not set here. + /// + public IEnumerable focus + { + get => _focus; + set + { + _focus = value; + } + } + + private IEnumerable _focus; + public EvaluationContext EvaluationContext { get; private set; } public static Closure Root(ITypedElement root, EvaluationContext ctx = null) diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/DynaDispatcher.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/DynaDispatcher.cs index eacae4d4ca..e0190fffab 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/DynaDispatcher.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/DynaDispatcher.cs @@ -48,6 +48,7 @@ public FocusCollection Dispatcher(Closure context, IEnumerable args, ou // The Get() here should never fail, since we already know there's a (dynamic) matching candidate // Need to clean up this duplicate logic later var argFuncs = actualArgs.Select(InvokeeFactory.Return); + context.focus = focus; return entry(context, argFuncs, out _); } catch (TargetInvocationException tie) diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs index 40b473f907..30e183728f 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs @@ -22,8 +22,11 @@ private Invokee WrapForDebugTracer(Invokee invokee, Expression expression) if (_injectDebugHook) { return (Closure context, IEnumerable arguments, out FocusCollection focus) => { + var oldFocus = context.focus; var result = invokee(context, arguments, out focus); - context.EvaluationContext.DebugTracer?.TraceCall(expression, context.Id, focus, context.GetThis(), context.GetIndex()?.FirstOrDefault(), context.GetTotal(), result, context.Variables()); + context.EvaluationContext.DebugTracer?.TraceCall(expression, context.Id, context.focus, context.GetThis(), context.GetIndex()?.FirstOrDefault(), context.GetTotal(), result, context.Variables()); + // restore the original focus to the context + context.focus = oldFocus; return result; }; } @@ -110,6 +113,7 @@ FocusCollection chainResolves(Closure context, IEnumerable invokees, ou { // this was in the context, so the scope was $this (the context) focus = context.GetThis(); + context.focus = focus; return value; } else diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs index 09fc79608a..02e7828d43 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs @@ -24,41 +24,52 @@ internal static class InvokeeFactory { public static readonly IEnumerable EmptyArgs = []; - public static FocusCollection GetThis(Closure context, IEnumerable _, out FocusCollection focus) => focus = context.GetThis(); + public static FocusCollection GetThis(Closure context, IEnumerable _, out FocusCollection focus) + { + focus = context.GetThis(); + context.focus = focus; + return focus; + } public static FocusCollection GetTotal(Closure context, IEnumerable _, out FocusCollection focus) { focus = context.GetThis(); + context.focus = focus; return context.GetTotal(); } public static FocusCollection GetContext(Closure context, IEnumerable _, out FocusCollection focus) { focus = context.GetThis(); + context.focus = focus; return context.GetOriginalContext(); } public static FocusCollection GetResource(Closure context, IEnumerable _, out FocusCollection focus) { focus = context.GetThis(); + context.focus = focus; return context.GetResource(); } public static FocusCollection GetRootResource(Closure context, IEnumerable arguments, out FocusCollection focus) { focus = context.GetThis(); + context.focus = focus; return context.GetRootResource(); } public static FocusCollection GetThat(Closure context, IEnumerable _, out FocusCollection focus) { focus = context.GetThis(); + context.focus = focus; return context.GetThat(); } public static FocusCollection GetIndex(Closure context, IEnumerable args, out FocusCollection focus) { focus = context.GetThis(); + context.focus = focus; return context.GetIndex(); } @@ -92,6 +103,7 @@ public static Invokee Wrap(Func func) return (Closure ctx, IEnumerable _, out FocusCollection focus) => { focus = ctx.GetThis(); + ctx.focus = focus; return Typecasts.CastTo(func()); }; } @@ -103,6 +115,7 @@ public static Invokee Wrap(Func func, bool propNull) if (typeof(A) != typeof(EvaluationContext)) { focus = args.First()(ctx, EmptyArgs, out _); + ctx.focus = focus; if (getPropagator(propNull, typeof(A))(focus)) return ElementNode.EmptyList; return Typecasts.CastTo(func(Typecasts.CastTo(focus))); @@ -110,6 +123,7 @@ public static Invokee Wrap(Func func, bool propNull) else { focus = ctx.GetThis(); + ctx.focus = focus; } A lastPar = (A)(object)ctx.EvaluationContext; @@ -127,6 +141,7 @@ internal static Invokee WrapWithPropNullForFocus(Func fu { // Get the original focus first before any processing var originalFocus = args.First()(ctx, EmptyArgs, out _); + ctx.focus = originalFocus; // Preserve the original focus for the debug tracer focus = originalFocus; @@ -147,6 +162,7 @@ internal static Invokee WrapWithPropNullForFocus(Func fu if (typeof(C) != typeof(EvaluationContext)) { var argB = args.Skip(2).First()(ctx, EmptyArgs, out _); + ctx.focus = originalFocus; // reset here as the argument processing might change it if (getPropagator(false, typeof(C))(argB)) return ElementNode.EmptyList; return Typecasts.CastTo(func(Typecasts.CastTo(originalFocus), @@ -155,6 +171,7 @@ internal static Invokee WrapWithPropNullForFocus(Func fu } else { + ctx.focus = originalFocus; // reset here as the argument processing might change it C lastPar = (C)(object)ctx.EvaluationContext; return Typecasts.CastTo(func(Typecasts.CastTo(originalFocus), Typecasts.CastTo(argA), lastPar)); @@ -167,6 +184,7 @@ internal static Invokee WrapWithPropNullForFocus(Func fu if (typeof(C) != typeof(EvaluationContext)) { var argB = args.Skip(2).First()(ctx, EmptyArgs, out _); + ctx.focus = originalFocus; // reset here as the argument processing might change it if (getPropagator(false, typeof(C))(argB)) return ElementNode.EmptyList; return Typecasts.CastTo(func(Typecasts.CastTo(originalFocus), @@ -175,6 +193,7 @@ internal static Invokee WrapWithPropNullForFocus(Func fu } else { + ctx.focus = originalFocus; // reset here as the argument processing might change it C lastPar = (C)(object)ctx.EvaluationContext; return Typecasts.CastTo(func(Typecasts.CastTo(originalFocus), argA, lastPar)); @@ -188,11 +207,13 @@ public static Invokee Wrap(Func func, bool propNull) return (Closure ctx, IEnumerable args, out FocusCollection focus) => { focus = args.First()(ctx, EmptyArgs, out _); + ctx.focus = focus; if (getPropagator(propNull, typeof(A))(focus)) return ElementNode.EmptyList; if (typeof(B) != typeof(EvaluationContext)) { var argA = args.Skip(1).First()(ctx, EmptyArgs, out _); + ctx.focus = focus; // reset here as the argument processing might change it if (getPropagator(propNull, typeof(B))(argA)) return ElementNode.EmptyList; return Typecasts.CastTo(func(Typecasts.CastTo(focus), Typecasts.CastTo(argA))); @@ -210,14 +231,17 @@ public static Invokee Wrap(Func func, bool propNull) return (Closure ctx, IEnumerable args, out FocusCollection focus) => { focus = args.First()(ctx, EmptyArgs, out _); + ctx.focus = focus; if (getPropagator(propNull,typeof(A))(focus)) return ElementNode.EmptyList; var argA = args.Skip(1).First()(ctx, EmptyArgs, out _); + ctx.focus = focus; // reset here as the argument processing might change it if (getPropagator(propNull, typeof(B))(argA)) return ElementNode.EmptyList; if (typeof(C) != typeof(EvaluationContext)) { var argB = args.Skip(2).First()(ctx, EmptyArgs, out _); + ctx.focus = focus; // reset here as the argument processing might change it if (getPropagator(propNull, typeof(C))(argB)) return ElementNode.EmptyList; return Typecasts.CastTo(func(Typecasts.CastTo(focus), Typecasts.CastTo(argA), @@ -237,16 +261,20 @@ public static Invokee Wrap(Func func, bool propNul return (Closure ctx, IEnumerable args, out FocusCollection focus) => { focus = args.First()(ctx, EmptyArgs, out _); + ctx.focus = focus; if (getPropagator(propNull, typeof(A))(focus)) return ElementNode.EmptyList; var argA = args.Skip(1).First()(ctx, EmptyArgs, out _); + ctx.focus = focus; // reset here as the argument processing might change it if (getPropagator(propNull, typeof(B))(argA)) return ElementNode.EmptyList; var argB = args.Skip(2).First()(ctx, EmptyArgs, out _); + ctx.focus = focus; // reset here as the argument processing might change it if (getPropagator(propNull, typeof(C))(argB)) return ElementNode.EmptyList; if (typeof(D) != typeof(EvaluationContext)) { var argC = args.Skip(3).First()(ctx, EmptyArgs, out _); + ctx.focus = focus; // reset here as the argument processing might change it if (getPropagator(propNull, typeof(D))(argC)) return ElementNode.EmptyList; return Typecasts.CastTo(func(Typecasts.CastTo(focus), @@ -274,21 +302,29 @@ public static Invokee WrapLogic(Func, Func, bool?> func) var right = args.Skip(2).First(); // Return function that actually executes the Invokee at the last moment - return Typecasts.CastTo( + var result = Typecasts.CastTo( func(() => left(ctx, EmptyArgs, out _).BooleanEval(), () => right(ctx, EmptyArgs, out _).BooleanEval())); + + // Set the focus after the arguments are both calculated + // leave the functions to be evaluated as func style arguments so the logic can decide + // if both arguments need to be evaluated + ctx.focus = focus; + return result; }; } public static Invokee Return(ITypedElement value) => (Closure ctx, IEnumerable _, out FocusCollection focus) => { focus = ctx.GetThis(); + ctx.focus = focus; return [value]; }; public static Invokee Return(FocusCollection value) => (Closure ctx, IEnumerable _, out FocusCollection focus) => { focus = ctx.GetThis(); + ctx.focus = focus; return value; }; @@ -310,7 +346,14 @@ public static Invokee Invoke(string functionName, IEnumerable arguments static Invokee wrapWithNextContext(Invokee unwrappedArgument) { - return (Closure ctx, IEnumerable args, out FocusCollection focus) => unwrappedArgument(ctx.Nest(ctx.GetThis()), args, out focus); + return (Closure ctx, IEnumerable args, out FocusCollection focus) => + { + // Bring the context outside the call so that it is created before calling the invokee + // so that the debug tracer which will be injected gets the correct context object in it. + var newContext = ctx.Nest(ctx.GetThis()); + var result = unwrappedArgument(newContext, args, out focus); + return result; + }; } string formatFunctionName(string name) diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs index 64b4b989e2..e597d301a6 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs @@ -251,6 +251,7 @@ internal static void AddBuiltinChildren(this SymbolTable table) focus = focusCollection; var name = (string?)iks[1](ctx, InvokeeFactory.EmptyArgs, out _).First().Value; var result = focusCollection.Navigate(name); + ctx.focus = focus; return result; }); @@ -270,6 +271,7 @@ private static IEnumerable runAggregate(Closure ctx, IEnumerable< { var focusCollection = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); focus = focusCollection; + ctx.focus = focus; var incrExpre = arguments.Skip(1).First(); IEnumerable initialValue = ElementNode.EmptyList; if (arguments.Count() > 2) @@ -285,6 +287,7 @@ private static IEnumerable runAggregate(Closure ctx, IEnumerable< { var newFocus = ElementNode.CreateList(element); var newContext = totalContext.Nest(newFocus); + newContext.focus = newFocus; newContext.SetThis(newFocus); newContext.SetTotal(totalContext.GetTotal()); var newTotalResult = incrExpre(newContext, InvokeeFactory.EmptyArgs, out _); @@ -297,12 +300,14 @@ private static IEnumerable runAggregate(Closure ctx, IEnumerable< private static IEnumerable Trace(Closure ctx, IEnumerable arguments, out FocusCollection focus) { focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); + ctx.focus = focus; var name = arguments.Skip(1).First()(ctx, InvokeeFactory.EmptyArgs, out _).FirstOrDefault()?.Value as string; List selectArgs = [arguments.First(), .. arguments.Skip(2)]; var selectResults = runSelect(ctx, selectArgs, out _); - ctx?.EvaluationContext?.Tracer?.Invoke(name, selectResults); + ctx.EvaluationContext?.Tracer?.Invoke(name, selectResults); + ctx.focus = focus; // restore focus after trace return focus; } @@ -311,6 +316,7 @@ private static IEnumerable DefineVariable(Closure ctx, IEnumerabl Invokee[] enumerable = arguments as Invokee[] ?? arguments.ToArray(); var focusCollection = enumerable[0](ctx, InvokeeFactory.EmptyArgs, out _); focus = focusCollection; + ctx.focus = focus; var name = enumerable[1](ctx, InvokeeFactory.EmptyArgs, out _).FirstOrDefault()?.Value as string; if(ctx.ResolveValue(name) is not null) throw new InvalidOperationException($"Variable {name} is already defined in this scope"); @@ -322,6 +328,7 @@ private static IEnumerable DefineVariable(Closure ctx, IEnumerabl else { var newContext = ctx.Nest(focusCollection); + newContext.focus = focusCollection; newContext.SetThis(focusCollection); var result = enumerable[2](newContext, InvokeeFactory.EmptyArgs, out _); ctx.SetValue(name, result); @@ -335,8 +342,10 @@ private static IEnumerable runIif(Closure ctx, IEnumerable runWhere(Closure ctx, IEnumerable runForeach() { var newFocus = ElementNode.CreateList(element); var newContext = ctx.Nest(newFocus); + newContext.focus = newFocus; newContext.SetThis(newFocus); newContext.SetIndex(ElementNode.CreateList(index)); index++; @@ -381,6 +392,7 @@ private static IEnumerable runSelect(Closure ctx, IEnumerable runForeach() { var newFocus = ElementNode.CreateList(element); var newContext = ctx.Nest(newFocus); + newContext.focus = newFocus; newContext.SetThis(newFocus); newContext.SetIndex(ElementNode.CreateList(index)); index++; @@ -408,6 +421,7 @@ private static IEnumerable runRepeat(Closure ctx, IEnumerable(); @@ -422,6 +436,7 @@ private static IEnumerable runRepeat(Closure ctx, IEnumerable runAll(Closure ctx, IEnumerable runAll(Closure ctx, IEnumerable runAny(Closure ctx, IEnumerable runAny(Closure ctx, IEnumerable result, IEnumerable>> variables) { + DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, variables); + var exprName = TraceExpressionNodeName(expr); if (exprName == null) return; // this is a node that we aren't interested in tracing (Identifier and $that) @@ -55,8 +57,21 @@ public void TraceCall( string output = $"{pi.RawPosition},{pi.Length},{exprName}:" + $" focus={focus?.Count() ?? 0} result={result?.Count() ?? 0}"; traceOutput.Add(output); + if (TraceNode != null) + { + TraceNode(traceOutput.Count-1, expr, contextId, + focus, thisValue, index, totalValue, result); + } } + public delegate void TraceNodeDelegate(int n, Expression expr, int contextId, + IEnumerable focus, + IEnumerable thisValue, + ITypedElement index, + IEnumerable totalValue, + IEnumerable result); + public TraceNodeDelegate TraceNode { get; set; } = null; + public string TraceExpressionNodeName(Expression expr) { switch (expr) @@ -99,6 +114,18 @@ public void DumpDiagnostics() System.Diagnostics.Trace.WriteLine(item); } } + + public string DebugTraceValue(Expression expr, ITypedElement? item) + { + string exprName = TraceExpressionNodeName(expr); + if (item == null) + return null; // possible with a null focus to kick things off + + if (item.Location == "@primitivevalue@" || item.Location == "@QuantityAsPrimitiveValue@") + return $"{exprName}:\t{item.Value}\t({item.InstanceType})"; + + return $"{exprName}:\t{item.Value}\t({item.InstanceType})\t{item.Location}"; + } } [TestMethod] From 4b9e4c894d0f0332fd727ae87036f7752c4ebd2b Mon Sep 17 00:00:00 2001 From: brian_pos Date: Mon, 18 Aug 2025 15:08:30 +1000 Subject: [PATCH 41/78] Additional unit testing --- src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs | 75 ++++++++++++++++++- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs index fccc565f38..6feaa2f818 100644 --- a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs +++ b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs @@ -18,6 +18,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.ExceptionServices; namespace Hl7.FhirPath.Tests { @@ -38,6 +39,7 @@ public static void Initialize(TestContext ctx) private class TestDebugTracer: IDebugTracer { public List traceOutput = new List(); + public List exceptions = new List(); public void TraceCall( Expression expr, int contextId, @@ -48,7 +50,7 @@ public void TraceCall( IEnumerable result, IEnumerable>> variables) { - DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, variables); + // DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, variables); var exprName = TraceExpressionNodeName(expr); if (exprName == null) @@ -59,8 +61,17 @@ public void TraceCall( traceOutput.Add(output); if (TraceNode != null) { - TraceNode(traceOutput.Count-1, expr, contextId, - focus, thisValue, index, totalValue, result); + try + { + TraceNode(traceOutput.Count - 1, expr, contextId, + focus, thisValue, index, totalValue, result); + } + catch(Exception e) + { + // swallow the exception while tracing during testing, then after evaluation + // is complete, we can throw them. + exceptions.Add(ExceptionDispatchInfo.Capture(e)); + } } } @@ -152,6 +163,64 @@ public void testDebugTrace_PropertyWalking() } [TestMethod] + [TestMethod] + public void testDebugTrace_Aggregate() + { + var expression = "(1|2).aggregate($total+$this, 0)"; + var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); + var tracer = new TestDebugTracer(); + tracer.TraceNode = (n, expr, contextId, focus, thisValue, index, totalValue, result) => + { + // TODO: Check the focus values. + if (n == 2) + { + // the results of the | operator + DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, null); + var vThis = tracer.DebugTraceValue(expr, thisValue?.FirstOrDefault()); + var vFocus = tracer.DebugTraceValue(expr, focus?.FirstOrDefault()); + var vResult1 = tracer.DebugTraceValue(expr, result?.FirstOrDefault()); + var vResult2 = tracer.DebugTraceValue(expr, result?.Skip(1)?.FirstOrDefault()); + Assert.AreEqual(0, contextId); + } + if (n == 3) { + // the results of the constant "0" for the init expression + DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, null); + var v1 = tracer.DebugTraceValue(expr, focus?.FirstOrDefault()); + var v2 = tracer.DebugTraceValue(expr, focus?.Skip(1)?.FirstOrDefault()); + // Assert.AreEqual(3, contextId); + } + }; + + var expr = compiler.Compile(expression, true); + System.Diagnostics.Trace.WriteLine("Expression: " + expression); + var results = expr(input, new FhirEvaluationContext() { DebugTracer = tracer }).ToFhirValues().ToList(); + tracer.DumpDiagnostics(); + + Assert.AreEqual(1, results.Count()); + Assert.AreEqual("3", results[0].ToString()); + + // Now check the tracer outputs + Assert.AreEqual(11, tracer.traceOutput.Count()); + int n = 0; + Assert.AreEqual("1,1,constant: focus=1 result=1", tracer.traceOutput[n++]); + Assert.AreEqual("3,1,constant: focus=1 result=1", tracer.traceOutput[n++]); + Assert.AreEqual("2,1,|: focus=1 result=2", tracer.traceOutput[n++]); + Assert.AreEqual("30,1,constant: focus=1 result=1", tracer.traceOutput[n++]); + Assert.AreEqual("16,6,$total: focus=1 result=1", tracer.traceOutput[n++]); + Assert.AreEqual("23,5,$this: focus=1 result=1", tracer.traceOutput[n++]); + Assert.AreEqual("22,1,+: focus=1 result=1", tracer.traceOutput[n++]); + Assert.AreEqual("16,6,$total: focus=1 result=1", tracer.traceOutput[n++]); + Assert.AreEqual("23,5,$this: focus=1 result=1", tracer.traceOutput[n++]); + Assert.AreEqual("22,1,+: focus=1 result=1", tracer.traceOutput[n++]); + Assert.AreEqual("6,9,aggregate: focus=2 result=1", tracer.traceOutput[n++]); + + // Now check the tracer assertions + System.Diagnostics.Trace.WriteLine($"Tracer exceptions: {tracer.exceptions.Count}"); + foreach (var item in tracer.exceptions) + { + item.Throw(); + } + } public void testDebugTrace_WhereClause() { var expression = "name.where(use='official' or use='usual').given"; From 31d12eac170e5caa18bfabf6676c4bb692a9a28a Mon Sep 17 00:00:00 2001 From: brian_pos Date: Mon, 18 Aug 2025 16:54:28 +1000 Subject: [PATCH 42/78] Include assertions into the debug trace tests too. --- src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs | 244 ++++++++++++++++-- 1 file changed, 224 insertions(+), 20 deletions(-) diff --git a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs index 6feaa2f818..608dc68856 100644 --- a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs +++ b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs @@ -39,7 +39,20 @@ public static void Initialize(TestContext ctx) private class TestDebugTracer: IDebugTracer { public List traceOutput = new List(); - public List exceptions = new List(); + private List exceptions = new List(); + + public void Assert() + { + if (exceptions.Count == 0) + return; // no exceptions to throw + System.Diagnostics.Trace.WriteLine($"Tracer exceptions: {exceptions.Count}"); + foreach (var item in exceptions) + { + item.Throw(); + } + } + + public void TraceCall( Expression expr, int contextId, @@ -120,22 +133,22 @@ public string TraceExpressionNodeName(Expression expr) public void DumpDiagnostics() { + System.Diagnostics.Trace.WriteLine("---"); foreach (var item in traceOutput) { System.Diagnostics.Trace.WriteLine(item); } } - public string DebugTraceValue(Expression expr, ITypedElement? item) + public string DebugTraceValue(ITypedElement? item) { - string exprName = TraceExpressionNodeName(expr); if (item == null) return null; // possible with a null focus to kick things off if (item.Location == "@primitivevalue@" || item.Location == "@QuantityAsPrimitiveValue@") - return $"{exprName}:\t{item.Value}\t({item.InstanceType})"; + return $"{item.Value}\t({item.InstanceType})"; - return $"{exprName}:\t{item.Value}\t({item.InstanceType})\t{item.Location}"; + return $"{item.Value}\t({item.InstanceType})\t{item.Location}"; } } @@ -145,9 +158,42 @@ public void testDebugTrace_PropertyWalking() var expression = "Patient.birthDate.toString().substring(0, 4)"; var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); var tracer = new TestDebugTracer(); + tracer.TraceNode = (n, expr, contextId, focus, thisValue, index, totalValue, result) => + { + DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, null); + var vThis = tracer.DebugTraceValue(thisValue?.FirstOrDefault()); + Assert.AreEqual("\t(Patient)\tPatient", vThis); + var vFocus = tracer.DebugTraceValue(focus?.FirstOrDefault()); + var vResult = tracer.DebugTraceValue(result?.FirstOrDefault()); + + if (n == 2) + { + // toString + Assert.AreEqual("1974-12-25\t(date)\tPatient.birthDate[0]", vFocus); + Assert.AreEqual("1974-12-25\t(System.String)", vResult); + } + if (n == 3) + { + // constant 0 + Assert.AreEqual("\t(Patient)\tPatient", vFocus); + Assert.AreEqual("0\t(System.Integer)", vResult); + } + if (n == 4) + { + // constant 4 + Assert.AreEqual("\t(Patient)\tPatient", vFocus); + Assert.AreEqual("4\t(System.Integer)", vResult); + } + if (n == 5) + { + // substring + Assert.AreEqual("1974-12-25\t(System.String)", vFocus); + Assert.AreEqual("1974\t(System.String)", vResult); + } + }; var expr = compiler.Compile(expression, true); + Trace.WriteLine("Expression: " + expression + "\r\n"); var results = expr(input, new FhirEvaluationContext() { DebugTracer = tracer }).ToFhirValues().ToList(); - System.Diagnostics.Trace.WriteLine("Expression: " + expression); tracer.DumpDiagnostics(); Assert.AreEqual(1, results.Count()); @@ -160,9 +206,55 @@ public void testDebugTrace_PropertyWalking() Assert.AreEqual("39,1,constant: focus=1 result=1", tracer.traceOutput[3]); Assert.AreEqual("42,1,constant: focus=1 result=1", tracer.traceOutput[4]); Assert.AreEqual("29,9,substring: focus=1 result=1", tracer.traceOutput[5]); + + // Now check the tracer assertions + tracer.Assert(); } [TestMethod] + public void testDebugTrace_PropertyAndFunctionCalls() + { + var expression = "Patient.id.indexOf('am')"; + var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); + var tracer = new TestDebugTracer(); + tracer.TraceNode = (n, expr, contextId, focus, thisValue, index, totalValue, result) => + { + DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, null); + var vThis = tracer.DebugTraceValue(thisValue?.FirstOrDefault()); + var vFocus = tracer.DebugTraceValue(focus?.FirstOrDefault()); + var vResult = tracer.DebugTraceValue(result?.FirstOrDefault()); + Assert.AreEqual("\t(Patient)\tPatient", vThis); // in this specific expression, this is always the patient + if (n == 2) + { + // the context and results of the constant 'am' call + Assert.AreEqual("\t(Patient)\tPatient", vFocus); + Assert.AreEqual("am\t(System.String)", vResult); + } + if (n == 3) + { + // the context and results of toString call + Assert.AreEqual("example\t(id)\tPatient.id[0]", vFocus); + Assert.AreEqual("2\t(System.Integer)", vResult); + } + }; + var expr = compiler.Compile(expression, true); + Trace.WriteLine("Expression: " + expression + "\r\n"); + var results = expr(input, new FhirEvaluationContext() { DebugTracer = tracer }).ToFhirValues().ToList(); + tracer.DumpDiagnostics(); + + Assert.AreEqual(1, results.Count()); + Assert.AreEqual("2", results[0].ToString()); + + Assert.AreEqual(4, tracer.traceOutput.Count()); + Assert.AreEqual("0,7,Patient: focus=1 result=1", tracer.traceOutput[0]); + Assert.AreEqual("8,2,id: focus=1 result=1", tracer.traceOutput[1]); + Assert.AreEqual("19,4,constant: focus=1 result=1", tracer.traceOutput[2]); + Assert.AreEqual("11,7,indexOf: focus=1 result=1", tracer.traceOutput[3]); + + // Now check the tracer assertions + tracer.Assert(); + } + [TestMethod] public void testDebugTrace_Aggregate() { @@ -176,23 +268,23 @@ public void testDebugTrace_Aggregate() { // the results of the | operator DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, null); - var vThis = tracer.DebugTraceValue(expr, thisValue?.FirstOrDefault()); - var vFocus = tracer.DebugTraceValue(expr, focus?.FirstOrDefault()); - var vResult1 = tracer.DebugTraceValue(expr, result?.FirstOrDefault()); - var vResult2 = tracer.DebugTraceValue(expr, result?.Skip(1)?.FirstOrDefault()); + var vThis = tracer.DebugTraceValue(thisValue?.FirstOrDefault()); + var vFocus = tracer.DebugTraceValue(focus?.FirstOrDefault()); + var vResult1 = tracer.DebugTraceValue(result?.FirstOrDefault()); + var vResult2 = tracer.DebugTraceValue(result?.Skip(1)?.FirstOrDefault()); Assert.AreEqual(0, contextId); } if (n == 3) { // the results of the constant "0" for the init expression DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, null); - var v1 = tracer.DebugTraceValue(expr, focus?.FirstOrDefault()); - var v2 = tracer.DebugTraceValue(expr, focus?.Skip(1)?.FirstOrDefault()); + var v1 = tracer.DebugTraceValue(focus?.FirstOrDefault()); + var v2 = tracer.DebugTraceValue(focus?.Skip(1)?.FirstOrDefault()); // Assert.AreEqual(3, contextId); } }; var expr = compiler.Compile(expression, true); - System.Diagnostics.Trace.WriteLine("Expression: " + expression); + Trace.WriteLine("Expression: " + expression + "\r\n"); var results = expr(input, new FhirEvaluationContext() { DebugTracer = tracer }).ToFhirValues().ToList(); tracer.DumpDiagnostics(); @@ -215,21 +307,108 @@ public void testDebugTrace_Aggregate() Assert.AreEqual("6,9,aggregate: focus=2 result=1", tracer.traceOutput[n++]); // Now check the tracer assertions - System.Diagnostics.Trace.WriteLine($"Tracer exceptions: {tracer.exceptions.Count}"); - foreach (var item in tracer.exceptions) + tracer.Assert(); + } + + [TestMethod] + public void testDebugTrace_Operator() + { + var expression = "Patient.id.toString() = Patient.id"; + var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); + var tracer = new TestDebugTracer(); + tracer.TraceNode = (n, expr, contextId, focus, thisValue, index, totalValue, result) => { - item.Throw(); - } + DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, null); + var vThis = tracer.DebugTraceValue(thisValue?.FirstOrDefault()); + Assert.AreEqual("\t(Patient)\tPatient", vThis); + var vFocus = tracer.DebugTraceValue(focus?.FirstOrDefault()); + if (n == 2) + { + // the context and results of toString call + var vResult = tracer.DebugTraceValue(result?.FirstOrDefault()); + Assert.AreEqual("example\t(id)\tPatient.id[0]", vFocus); + Assert.AreEqual("example\t(System.String)", vResult); + } + }; + + var expr = compiler.Compile(expression, true); + Trace.WriteLine("Expression: " + expression + "\r\n"); + var results = expr(input, new FhirEvaluationContext() { DebugTracer = tracer }).ToFhirValues().ToList(); + tracer.DumpDiagnostics(); + + Assert.AreEqual(1, results.Count()); + Assert.AreEqual("true", results[0].ToString()); + + // Now check the tracer outputs + Assert.AreEqual(6, tracer.traceOutput.Count()); + int n = 0; + Assert.AreEqual("0,7,Patient: focus=1 result=1", tracer.traceOutput[n++]); + Assert.AreEqual("8,2,id: focus=1 result=1", tracer.traceOutput[n++]); + Assert.AreEqual("11,8,toString: focus=1 result=1", tracer.traceOutput[n++]); + Assert.AreEqual("24,7,Patient: focus=1 result=1", tracer.traceOutput[n++]); + Assert.AreEqual("32,2,id: focus=1 result=1", tracer.traceOutput[n++]); + Assert.AreEqual("22,1,=: focus=1 result=1", tracer.traceOutput[n++]); + + // Now check the tracer assertions + tracer.Assert(); } + + [TestMethod] public void testDebugTrace_WhereClause() { var expression = "name.where(use='official' or use='usual').given"; var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); var tracer = new TestDebugTracer(); + tracer.TraceNode = (n, expr, contextId, focus, thisValue, index, totalValue, result) => + { + DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, null); + var vThis = tracer.DebugTraceValue(thisValue?.FirstOrDefault()); + var vFocus = tracer.DebugTraceValue(focus?.FirstOrDefault()); + var vResult = tracer.DebugTraceValue(result?.FirstOrDefault()); + var vIndex= index?.Value; + if (n == 0) + { + // name + Assert.AreEqual("\t(Patient)\tPatient", vThis); + Assert.AreEqual("\t(Patient)\tPatient", vFocus); + Assert.AreEqual(2, result.Count()); + } + + if (n == 1 || n == 2 || n == 3 || n == 4) + { + Assert.AreEqual("\t(HumanName)\tPatient.name[0]", vThis); + Assert.AreEqual("\t(HumanName)\tPatient.name[0]", vFocus); + Assert.AreEqual(0, vIndex); + } + if (n >= 5 && n <= 11) + { + Assert.AreEqual("\t(HumanName)\tPatient.name[1]", vThis); + Assert.AreEqual("\t(HumanName)\tPatient.name[1]", vFocus); + Assert.AreEqual(1, vIndex); + } + + if (n == 12) + { + // Where clause + Assert.AreEqual("\t(Patient)\tPatient", vThis); + Assert.AreEqual("\t(HumanName)\tPatient.name[0]", vFocus); + Assert.AreEqual(2, focus.Count()); + Assert.AreEqual(2, result.Count()); + Assert.AreEqual("\t(HumanName)\tPatient.name[0]", vResult); + } + if (n == 13) + { + // The final given prop navigator + Assert.AreEqual("\t(Patient)\tPatient", vThis); + Assert.AreEqual("\t(HumanName)\tPatient.name[0]", vFocus); + Assert.AreEqual(2, focus.Count()); + Assert.AreEqual(3, result.Count()); + } + }; var expr = compiler.Compile(expression, true); + Trace.WriteLine("Expression: " + expression + "\r\n"); var results = expr(input, new FhirEvaluationContext() { DebugTracer = tracer }).ToList(); - System.Diagnostics.Trace.WriteLine("Expression: " + expression); tracer.DumpDiagnostics(); Assert.AreEqual(3, results.Count()); @@ -256,6 +435,9 @@ public void testDebugTrace_WhereClause() Assert.AreEqual("26,2,or: focus=1 result=1", tracer.traceOutput[11]); Assert.AreEqual("5,5,where: focus=2 result=2", tracer.traceOutput[12]); Assert.AreEqual("42,5,given: focus=2 result=3", tracer.traceOutput[13]); + + // Now check the tracer assertions + tracer.Assert(); } [TestMethod] @@ -265,9 +447,13 @@ public void testDebugTrace_ConstantValues() var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); var tracer = new TestDebugTracer(); + tracer.TraceNode = (n, expr, contextId, focus, thisValue, index, totalValue, result) => + { + DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, null); + }; var expr = compiler.Compile(expression, true); + Trace.WriteLine("Expression: " + expression + "\r\n"); var results = expr(input, new FhirEvaluationContext() { DebugTracer = tracer }).ToFhirValues().ToList(); - System.Diagnostics.Trace.WriteLine("Expression: " + expression); tracer.DumpDiagnostics(); Assert.AreEqual(1, results.Count()); @@ -275,6 +461,9 @@ public void testDebugTrace_ConstantValues() Assert.AreEqual(1, tracer.traceOutput.Count()); Assert.AreEqual("0,4,constant: focus=1 result=1", tracer.traceOutput[0]); + + // Now check the tracer assertions + tracer.Assert(); } [TestMethod] @@ -284,9 +473,21 @@ public void testDebugTrace_GroupedOr() var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); var tracer = new TestDebugTracer(); + tracer.TraceNode = (n, expr, contextId, focus, thisValue, index, totalValue, result) => + { + DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, null); + + // interestingly all the nodes in this expression have the same focus and $this value + var vThis = tracer.DebugTraceValue(thisValue?.FirstOrDefault()); + var vFocus = tracer.DebugTraceValue(focus?.FirstOrDefault()); + Assert.AreEqual("\t(Patient)\tPatient", vThis); + Assert.AreEqual("\t(Patient)\tPatient", vFocus); + + + }; var expr = compiler.Compile(expression, true); + Trace.WriteLine("Expression: " + expression + "\r\n"); var results = expr(input, new FhirEvaluationContext() { DebugTracer = tracer }).ToFhirValues().ToList(); - System.Diagnostics.Trace.WriteLine("Expression: " + expression); tracer.DumpDiagnostics(); Assert.AreEqual(1, results.Count()); @@ -300,6 +501,9 @@ public void testDebugTrace_GroupedOr() Assert.AreEqual("20,9,constant: focus=1 result=1", tracer.traceOutput[4]); Assert.AreEqual("19,1,=: focus=1 result=1", tracer.traceOutput[5]); Assert.AreEqual("14,2,or: focus=1 result=1", tracer.traceOutput[6]); + + // Now check the tracer assertions + tracer.Assert(); } } } \ No newline at end of file From 3489cfc223e95bac14e8b9aa615a69cd7de9f9ce Mon Sep 17 00:00:00 2001 From: brian_pos Date: Mon, 18 Aug 2025 19:27:16 +1000 Subject: [PATCH 43/78] And remove the out parameter (discovered that the only place where the evaluation wasn't wrapeed is the dynamic dispatcher, so that one needs to have special handling on the focus to "put it back" as there's no log wapper at that stage - already executed and logged the parameters) --- .../FhirPath/Expressions/Closure.cs | 27 ++-- .../FhirPath/Expressions/DynaDispatcher.cs | 13 +- .../FhirPath/Expressions/EvaluatorVisitor.cs | 13 +- .../FhirPath/Expressions/Invokee.cs | 146 +++++++----------- .../FhirPath/Expressions/SymbolTableInit.cs | 107 ++++++------- .../FhirPath/FhirPathCompiler.cs | 4 +- src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs | 2 +- 7 files changed, 144 insertions(+), 168 deletions(-) diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs index 95afb2ccd3..e2f104dfaa 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs @@ -23,6 +23,7 @@ public Closure(EvaluationContext ctx) { EvaluationContext = ctx; Id = ctx.IncrementClosuresCreatedCount(); + _debugTracerActive = ctx.DebugTracer != null; } public Closure(Closure parent, EvaluationContext ctx) @@ -30,29 +31,37 @@ public Closure(Closure parent, EvaluationContext ctx) Parent = parent; EvaluationContext = ctx; Id = ctx.IncrementClosuresCreatedCount(); + _debugTracerActive = ctx.DebugTracer != null; } /// /// When the debug/trace is enabled this property is used to record the focus of the closure. - /// It is set in the delegate produced for each node by the evaluator visitor. - /// The value is set immediately before returning the result of the evaluation of the node, - /// after all it's processing, this must be done as the same context is re-used in many - /// cases, and thus needs to be re-set just before it returns from the delegate. - /// The debug tracer uses this information in the wrapped delegate to report not only the - /// result of the expression, but also the other states of the closure, such as the focus, - /// resource, root resource, etc. - /// The $this variable doesn't change within a closure object, so it is not set here. + ///
VALUE IS NOT USED OUTSIDE DEBUG - without debug/tracer, the value is not consistent. ///
+ /// + /// It is set in the delegate produced for each node by the evaluator visitor. + /// The debug tracer will reset the focus in the closure after calling the delegate it's wrapping. + /// ensuring that argument evaluation doesn't impact the focus logged in the debug trace in other + /// calls. + /// public IEnumerable focus { - get => _focus; + get + { + if (!_debugTracerActive) + return ElementNode.EmptyList; + return _focus; + } set { + if (!_debugTracerActive) + return; _focus = value; } } private IEnumerable _focus; + private bool _debugTracerActive = false; public EvaluationContext EvaluationContext { get; private set; } diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/DynaDispatcher.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/DynaDispatcher.cs index e0190fffab..3ee814d80e 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/DynaDispatcher.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/DynaDispatcher.cs @@ -26,17 +26,18 @@ public DynaDispatcher(string name, SymbolTable scope) private readonly string _name; private readonly SymbolTable _scope; - public FocusCollection Dispatcher(Closure context, IEnumerable args, out FocusCollection focus) + public FocusCollection Dispatcher(Closure context, IEnumerable args) { var actualArgs = new List(); - focus = args.First()(context, InvokeeFactory.EmptyArgs, out _); + var focus = args.First()(context, InvokeeFactory.EmptyArgs); + context.focus = focus; if (!focus.Any()) return ElementNode.EmptyList; actualArgs.Add(focus); var newCtx = context.Nest(focus); - actualArgs.AddRange(args.Skip(1).Select(a => a(newCtx, InvokeeFactory.EmptyArgs, out _))); + actualArgs.AddRange(args.Skip(1).Select(a => a(newCtx, InvokeeFactory.EmptyArgs))); if (actualArgs.Any(aa => !aa.Any())) return ElementNode.EmptyList; var entry = _scope.DynamicGet(_name, actualArgs); @@ -48,8 +49,12 @@ public FocusCollection Dispatcher(Closure context, IEnumerable args, ou // The Get() here should never fail, since we already know there's a (dynamic) matching candidate // Need to clean up this duplicate logic later var argFuncs = actualArgs.Select(InvokeeFactory.Return); + var result = entry(context, argFuncs); + + // Dynamically dispatched function arguments aren't wrapped + // for the debug/trace, so need to manually put the focus back context.focus = focus; - return entry(context, argFuncs, out _); + return result; } catch (TargetInvocationException tie) { diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs index 30e183728f..960b339f47 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs @@ -21,10 +21,12 @@ private Invokee WrapForDebugTracer(Invokee invokee, Expression expression) { if (_injectDebugHook) { - return (Closure context, IEnumerable arguments, out FocusCollection focus) => { + return (Closure context, IEnumerable arguments) => { var oldFocus = context.focus; - var result = invokee(context, arguments, out focus); + var result = invokee(context, arguments); + context.EvaluationContext.DebugTracer?.TraceCall(expression, context.Id, context.focus, context.GetThis(), context.GetIndex()?.FirstOrDefault(), context.GetTotal(), result, context.Variables()); + // restore the original focus to the context context.focus = oldFocus; return result; @@ -106,19 +108,18 @@ public override Invokee VisitVariableRef(FP.VariableRefExpression expression) return WrapForDebugTracer(chainResolves, expression); - FocusCollection chainResolves(Closure context, IEnumerable invokees, out FocusCollection focus) + FocusCollection chainResolves(Closure context, IEnumerable invokees) { var value = context.ResolveValue(expression.Name); if (value != null) { // this was in the context, so the scope was $this (the context) - focus = context.GetThis(); - context.focus = focus; + context.focus = context.GetThis(); return value; } else { - return resolve(Symbols, expression.Name, Enumerable.Empty())(context, [], out focus); + return resolve(Symbols, expression.Name, Enumerable.Empty())(context, []); } } } diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs index 02e7828d43..5ca6045c3a 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs @@ -18,58 +18,52 @@ namespace Hl7.FhirPath.Expressions; -internal delegate FocusCollection Invokee(Closure context, IEnumerable arguments, out FocusCollection focus); +internal delegate FocusCollection Invokee(Closure context, IEnumerable arguments); internal static class InvokeeFactory { public static readonly IEnumerable EmptyArgs = []; - public static FocusCollection GetThis(Closure context, IEnumerable _, out FocusCollection focus) + public static FocusCollection GetThis(Closure context, IEnumerable _) { - focus = context.GetThis(); - context.focus = focus; - return focus; + var result = context.GetThis(); + context.focus = result; + return result; } - public static FocusCollection GetTotal(Closure context, IEnumerable _, out FocusCollection focus) + public static FocusCollection GetTotal(Closure context, IEnumerable _) { - focus = context.GetThis(); - context.focus = focus; + context.focus = context.GetThis(); return context.GetTotal(); } - public static FocusCollection GetContext(Closure context, IEnumerable _, out FocusCollection focus) + public static FocusCollection GetContext(Closure context, IEnumerable _) { - focus = context.GetThis(); - context.focus = focus; + context.focus = context.GetThis(); return context.GetOriginalContext(); } - public static FocusCollection GetResource(Closure context, IEnumerable _, out FocusCollection focus) + public static FocusCollection GetResource(Closure context, IEnumerable _) { - focus = context.GetThis(); - context.focus = focus; + context.focus = context.GetThis(); return context.GetResource(); } - public static FocusCollection GetRootResource(Closure context, IEnumerable arguments, out FocusCollection focus) + public static FocusCollection GetRootResource(Closure context, IEnumerable arguments) { - focus = context.GetThis(); - context.focus = focus; + context.focus = context.GetThis(); return context.GetRootResource(); } - public static FocusCollection GetThat(Closure context, IEnumerable _, out FocusCollection focus) + public static FocusCollection GetThat(Closure context, IEnumerable _) { - focus = context.GetThis(); - context.focus = focus; + context.focus = context.GetThis(); return context.GetThat(); } - public static FocusCollection GetIndex(Closure context, IEnumerable args, out FocusCollection focus) + public static FocusCollection GetIndex(Closure context, IEnumerable args) { - focus = context.GetThis(); - context.focus = focus; + context.focus = context.GetThis(); return context.GetIndex(); } @@ -100,21 +94,20 @@ true when isPrimitiveDotNetType(argType) => PROPAGATE_EMPTY_PRIMITIVE, public static Invokee Wrap(Func func) { - return (Closure ctx, IEnumerable _, out FocusCollection focus) => + return (Closure ctx, IEnumerable _) => { - focus = ctx.GetThis(); - ctx.focus = focus; + ctx.focus = ctx.GetThis(); return Typecasts.CastTo(func()); }; } public static Invokee Wrap(Func func, bool propNull) { - return (Closure ctx, IEnumerable args, out FocusCollection focus) => + return (Closure ctx, IEnumerable args) => { if (typeof(A) != typeof(EvaluationContext)) { - focus = args.First()(ctx, EmptyArgs, out _); + var focus = args.First()(ctx, EmptyArgs); ctx.focus = focus; if (getPropagator(propNull, typeof(A))(focus)) return ElementNode.EmptyList; @@ -122,8 +115,7 @@ public static Invokee Wrap(Func func, bool propNull) } else { - focus = ctx.GetThis(); - ctx.focus = focus; + ctx.focus = ctx.GetThis(); } A lastPar = (A)(object)ctx.EvaluationContext; @@ -137,43 +129,36 @@ public static Invokee Wrap(Func func, bool propNull) /// internal static Invokee WrapWithPropNullForFocus(Func func) { - return (Closure ctx, IEnumerable args, out FocusCollection focus) => + return (Closure ctx, IEnumerable args) => { // Get the original focus first before any processing - var originalFocus = args.First()(ctx, EmptyArgs, out _); - ctx.focus = originalFocus; - - // Preserve the original focus for the debug tracer - focus = originalFocus; + var focus = args.First()(ctx, EmptyArgs); + ctx.focus = focus; // Check for null propagation condition - if (getPropagator(true, typeof(A))(originalFocus)) - { - return ElementNode.EmptyList; - } + if (getPropagator(true, typeof(A))(focus)) return ElementNode.EmptyList; // For the actual function execution, we need a new Invokee that handles the arguments // but doesn't modify the focus for debug tracing + // re-wrapping (as the old code did) will fully re-evaluate the focus, again. Which can be VERY expensive in some expressions. if (typeof(B) != typeof(EvaluationContext)) { - var argA = args.Skip(1).First()(ctx, EmptyArgs, out _); + var argA = args.Skip(1).First()(ctx, EmptyArgs); if (getPropagator(false, typeof(B))(argA)) return ElementNode.EmptyList; if (typeof(C) != typeof(EvaluationContext)) { - var argB = args.Skip(2).First()(ctx, EmptyArgs, out _); - ctx.focus = originalFocus; // reset here as the argument processing might change it + var argB = args.Skip(2).First()(ctx, EmptyArgs); if (getPropagator(false, typeof(C))(argB)) return ElementNode.EmptyList; - return Typecasts.CastTo(func(Typecasts.CastTo
(originalFocus), + return Typecasts.CastTo(func(Typecasts.CastTo(focus), Typecasts.CastTo(argA), Typecasts.CastTo(argB))); } else { - ctx.focus = originalFocus; // reset here as the argument processing might change it C lastPar = (C)(object)ctx.EvaluationContext; - return Typecasts.CastTo(func(Typecasts.CastTo(originalFocus), + return Typecasts.CastTo(func(Typecasts.CastTo(focus), Typecasts.CastTo(argA), lastPar)); } } @@ -183,19 +168,17 @@ internal static Invokee WrapWithPropNullForFocus(Func fu if (typeof(C) != typeof(EvaluationContext)) { - var argB = args.Skip(2).First()(ctx, EmptyArgs, out _); - ctx.focus = originalFocus; // reset here as the argument processing might change it + var argB = args.Skip(2).First()(ctx, EmptyArgs); if (getPropagator(false, typeof(C))(argB)) return ElementNode.EmptyList; - return Typecasts.CastTo(func(Typecasts.CastTo(originalFocus), + return Typecasts.CastTo(func(Typecasts.CastTo(focus), argA, Typecasts.CastTo(argB))); } else { - ctx.focus = originalFocus; // reset here as the argument processing might change it C lastPar = (C)(object)ctx.EvaluationContext; - return Typecasts.CastTo(func(Typecasts.CastTo(originalFocus), + return Typecasts.CastTo(func(Typecasts.CastTo(focus), argA, lastPar)); } } @@ -204,16 +187,15 @@ internal static Invokee WrapWithPropNullForFocus(Func fu public static Invokee Wrap(Func func, bool propNull) { - return (Closure ctx, IEnumerable args, out FocusCollection focus) => + return (Closure ctx, IEnumerable args) => { - focus = args.First()(ctx, EmptyArgs, out _); + var focus = args.First()(ctx, EmptyArgs); ctx.focus = focus; if (getPropagator(propNull, typeof(A))(focus)) return ElementNode.EmptyList; if (typeof(B) != typeof(EvaluationContext)) { - var argA = args.Skip(1).First()(ctx, EmptyArgs, out _); - ctx.focus = focus; // reset here as the argument processing might change it + var argA = args.Skip(1).First()(ctx, EmptyArgs); if (getPropagator(propNull, typeof(B))(argA)) return ElementNode.EmptyList; return Typecasts.CastTo(func(Typecasts.CastTo(focus), Typecasts.CastTo(argA))); @@ -228,20 +210,18 @@ public static Invokee Wrap(Func func, bool propNull) public static Invokee Wrap(Func func, bool propNull) { - return (Closure ctx, IEnumerable args, out FocusCollection focus) => + return (Closure ctx, IEnumerable args) => { - focus = args.First()(ctx, EmptyArgs, out _); + var focus = args.First()(ctx, EmptyArgs); ctx.focus = focus; if (getPropagator(propNull,typeof(A))(focus)) return ElementNode.EmptyList; - var argA = args.Skip(1).First()(ctx, EmptyArgs, out _); - ctx.focus = focus; // reset here as the argument processing might change it + var argA = args.Skip(1).First()(ctx, EmptyArgs); if (getPropagator(propNull, typeof(B))(argA)) return ElementNode.EmptyList; if (typeof(C) != typeof(EvaluationContext)) { - var argB = args.Skip(2).First()(ctx, EmptyArgs, out _); - ctx.focus = focus; // reset here as the argument processing might change it + var argB = args.Skip(2).First()(ctx, EmptyArgs); if (getPropagator(propNull, typeof(C))(argB)) return ElementNode.EmptyList; return Typecasts.CastTo(func(Typecasts.CastTo(focus), Typecasts.CastTo(argA), @@ -258,23 +238,20 @@ public static Invokee Wrap(Func func, bool propNull) public static Invokee Wrap(Func func, bool propNull) { - return (Closure ctx, IEnumerable args, out FocusCollection focus) => + return (Closure ctx, IEnumerable args) => { - focus = args.First()(ctx, EmptyArgs, out _); + var focus = args.First()(ctx, EmptyArgs); ctx.focus = focus; if (getPropagator(propNull, typeof(A))(focus)) return ElementNode.EmptyList; - var argA = args.Skip(1).First()(ctx, EmptyArgs, out _); - ctx.focus = focus; // reset here as the argument processing might change it + var argA = args.Skip(1).First()(ctx, EmptyArgs); if (getPropagator(propNull, typeof(B))(argA)) return ElementNode.EmptyList; - var argB = args.Skip(2).First()(ctx, EmptyArgs, out _); - ctx.focus = focus; // reset here as the argument processing might change it + var argB = args.Skip(2).First()(ctx, EmptyArgs); if (getPropagator(propNull, typeof(C))(argB)) return ElementNode.EmptyList; if (typeof(D) != typeof(EvaluationContext)) { - var argC = args.Skip(3).First()(ctx, EmptyArgs, out _); - ctx.focus = focus; // reset here as the argument processing might change it + var argC = args.Skip(3).First()(ctx, EmptyArgs); if (getPropagator(propNull, typeof(D))(argC)) return ElementNode.EmptyList; return Typecasts.CastTo(func(Typecasts.CastTo(focus), @@ -293,49 +270,42 @@ public static Invokee Wrap(Func func, bool propNul public static Invokee WrapLogic(Func, Func, bool?> func) { - return (Closure ctx, IEnumerable args, out FocusCollection focus) => + return (Closure ctx, IEnumerable args) => { // Ignore focus // Arguments to functions (except iterative functions like `where` and `select` that update the value of $this) are not processed on the focus, they are processed on $this. - focus = ctx.GetThis(); + ctx.focus = ctx.GetThis(); var left = args.Skip(1).First(); var right = args.Skip(2).First(); // Return function that actually executes the Invokee at the last moment var result = Typecasts.CastTo( - func(() => left(ctx, EmptyArgs, out _).BooleanEval(), - () => right(ctx, EmptyArgs, out _).BooleanEval())); - - // Set the focus after the arguments are both calculated - // leave the functions to be evaluated as func style arguments so the logic can decide - // if both arguments need to be evaluated - ctx.focus = focus; + func(() => left(ctx, EmptyArgs).BooleanEval(), + () => right(ctx, EmptyArgs).BooleanEval())); return result; }; } - public static Invokee Return(ITypedElement value) => (Closure ctx, IEnumerable _, out FocusCollection focus) => + public static Invokee Return(ITypedElement value) => (Closure ctx, IEnumerable _) => { - focus = ctx.GetThis(); - ctx.focus = focus; + ctx.focus = ctx.GetThis(); return [value]; }; - public static Invokee Return(FocusCollection value) => (Closure ctx, IEnumerable _, out FocusCollection focus) => + public static Invokee Return(FocusCollection value) => (Closure ctx, IEnumerable _) => { - focus = ctx.GetThis(); - ctx.focus = focus; + ctx.focus = ctx.GetThis(); return value; }; public static Invokee Invoke(string functionName, IEnumerable arguments, Invokee invokee) { - return (Closure ctx, IEnumerable _, out FocusCollection focus) => + return (Closure ctx, IEnumerable _) => { try { var wrappedArguments = arguments.Skip(1).Select(wrapWithNextContext); - return invokee(ctx, [arguments.First(),.. wrappedArguments], out focus); + return invokee(ctx, [arguments.First(),.. wrappedArguments]); } catch (Exception e) { @@ -346,12 +316,12 @@ public static Invokee Invoke(string functionName, IEnumerable arguments static Invokee wrapWithNextContext(Invokee unwrappedArgument) { - return (Closure ctx, IEnumerable args, out FocusCollection focus) => + return (Closure ctx, IEnumerable args) => { // Bring the context outside the call so that it is created before calling the invokee // so that the debug tracer which will be injected gets the correct context object in it. var newContext = ctx.Nest(ctx.GetThis()); - var result = unwrappedArgument(newContext, args, out focus); + var result = unwrappedArgument(newContext, args); return result; }; } diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs index e597d301a6..4d37e0c6ef 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs @@ -244,14 +244,13 @@ internal static void AddBuiltinChildren(this SymbolTable table) table.Add(new CallSignature("builtin.children", typeof(IEnumerable), typeof(IEnumerable), - typeof(string)), (Closure ctx, IEnumerable invokees, out FocusCollection focus) => + typeof(string)), (Closure ctx, IEnumerable invokees) => { var iks = invokees.ToArray(); - var focusCollection = iks[0](ctx, InvokeeFactory.EmptyArgs, out _); - focus = focusCollection; - var name = (string?)iks[1](ctx, InvokeeFactory.EmptyArgs, out _).First().Value; - var result = focusCollection.Navigate(name); + var focus = iks[0](ctx, InvokeeFactory.EmptyArgs); ctx.focus = focus; + var name = (string?)iks[1](ctx, InvokeeFactory.EmptyArgs).First().Value; + var result = focus.Navigate(name); return result; }); @@ -267,88 +266,85 @@ private static string getCoreValueSetUrl(string id) return "http://hl7.org/fhir/ValueSet/" + id; } - private static IEnumerable runAggregate(Closure ctx, IEnumerable arguments, out FocusCollection focus) + private static IEnumerable runAggregate(Closure ctx, IEnumerable arguments) { - var focusCollection = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); - focus = focusCollection; + var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); ctx.focus = focus; var incrExpre = arguments.Skip(1).First(); IEnumerable initialValue = ElementNode.EmptyList; if (arguments.Count() > 2) { var initialValueExpr = arguments.Skip(2).First(); - initialValue = initialValueExpr(ctx, InvokeeFactory.EmptyArgs, out _); + initialValue = initialValueExpr(ctx, InvokeeFactory.EmptyArgs); } var totalContext = ctx.Nest(); totalContext.SetTotal(initialValue); - foreach (ITypedElement element in focusCollection) + foreach (ITypedElement element in focus) { var newFocus = ElementNode.CreateList(element); var newContext = totalContext.Nest(newFocus); newContext.focus = newFocus; newContext.SetThis(newFocus); newContext.SetTotal(totalContext.GetTotal()); - var newTotalResult = incrExpre(newContext, InvokeeFactory.EmptyArgs, out _); + var newTotalResult = incrExpre(newContext, InvokeeFactory.EmptyArgs); totalContext.SetTotal(newTotalResult); } return totalContext.GetTotal(); } - private static IEnumerable Trace(Closure ctx, IEnumerable arguments, out FocusCollection focus) + private static IEnumerable Trace(Closure ctx, IEnumerable arguments) { - focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); + var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); ctx.focus = focus; - var name = arguments.Skip(1).First()(ctx, InvokeeFactory.EmptyArgs, out _).FirstOrDefault()?.Value as string; + var name = arguments.Skip(1).First()(ctx, InvokeeFactory.EmptyArgs).FirstOrDefault()?.Value as string; List selectArgs = [arguments.First(), .. arguments.Skip(2)]; - var selectResults = runSelect(ctx, selectArgs, out _); + var selectResults = runSelect(ctx, selectArgs); ctx.EvaluationContext?.Tracer?.Invoke(name, selectResults); - ctx.focus = focus; // restore focus after trace return focus; } - private static IEnumerable DefineVariable(Closure ctx, IEnumerable arguments, out FocusCollection focus) + private static IEnumerable DefineVariable(Closure ctx, IEnumerable arguments) { Invokee[] enumerable = arguments as Invokee[] ?? arguments.ToArray(); - var focusCollection = enumerable[0](ctx, InvokeeFactory.EmptyArgs, out _); - focus = focusCollection; + var focus = enumerable[0](ctx, InvokeeFactory.EmptyArgs); ctx.focus = focus; - var name = enumerable[1](ctx, InvokeeFactory.EmptyArgs, out _).FirstOrDefault()?.Value as string; + var name = enumerable[1](ctx, InvokeeFactory.EmptyArgs).FirstOrDefault()?.Value as string; if(ctx.ResolveValue(name) is not null) throw new InvalidOperationException($"Variable {name} is already defined in this scope"); if (enumerable.Length == 2) { - ctx.SetValue(name, focusCollection); + ctx.SetValue(name, focus); } else { - var newContext = ctx.Nest(focusCollection); - newContext.focus = focusCollection; - newContext.SetThis(focusCollection); - var result = enumerable[2](newContext, InvokeeFactory.EmptyArgs, out _); + var newContext = ctx.Nest(focus); + newContext.focus = focus; + newContext.SetThis(focus); + var result = enumerable[2](newContext, InvokeeFactory.EmptyArgs); ctx.SetValue(name, result); } - return focusCollection; + return focus; } - private static IEnumerable runIif(Closure ctx, IEnumerable arguments, out FocusCollection focus) + private static IEnumerable runIif(Closure ctx, IEnumerable arguments) { // iif(criterion: expression, true-result: collection [, otherwise-result: collection]) : collection // note: short-circuit behavior is expected in this function - focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); + var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); ctx.focus = focus; var newContext = ctx.Nest(focus); newContext.focus = focus; newContext.SetThis(focus); - var expression = arguments.Skip(1).First()(newContext, InvokeeFactory.EmptyArgs, out _); + var expression = arguments.Skip(1).First()(newContext, InvokeeFactory.EmptyArgs); var trueResult = arguments.Skip(2).First(); var otherResult = arguments.Skip(3).FirstOrDefault(); @@ -356,14 +352,13 @@ private static IEnumerable runIif(Closure ctx, IEnumerable runWhere(Closure ctx, IEnumerable arguments, out FocusCollection focus) + private static IEnumerable runWhere(Closure ctx, IEnumerable arguments) { - var focusCollection = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); - focus = focusCollection; + var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); ctx.focus = focus; var lambda = arguments.Skip(1).First(); @@ -373,7 +368,7 @@ IEnumerable runForeach() { var index = 0; - foreach (ITypedElement element in focusCollection) + foreach (ITypedElement element in focus) { var newFocus = ElementNode.CreateList(element); var newContext = ctx.Nest(newFocus); @@ -382,16 +377,15 @@ IEnumerable runForeach() newContext.SetIndex(ElementNode.CreateList(index)); index++; - if (lambda(newContext, InvokeeFactory.EmptyArgs, out _).BooleanEval() == true) + if (lambda(newContext, InvokeeFactory.EmptyArgs).BooleanEval() == true) yield return element; } } } - private static IEnumerable runSelect(Closure ctx, IEnumerable arguments, out FocusCollection focus) + private static IEnumerable runSelect(Closure ctx, IEnumerable arguments) { - var focusCollection = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); - focus = focusCollection; + var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); ctx.focus = focus; var lambda = arguments.Skip(1).First(); @@ -401,7 +395,7 @@ IEnumerable runForeach() { var index = 0; - foreach (ITypedElement element in focusCollection) + foreach (ITypedElement element in focus) { var newFocus = ElementNode.CreateList(element); var newContext = ctx.Nest(newFocus); @@ -410,18 +404,17 @@ IEnumerable runForeach() newContext.SetIndex(ElementNode.CreateList(index)); index++; - var result = lambda(newContext, InvokeeFactory.EmptyArgs, out _); + var result = lambda(newContext, InvokeeFactory.EmptyArgs); foreach (var resultElement in result) // implement SelectMany() yield return resultElement; } } } - private static IEnumerable runRepeat(Closure ctx, IEnumerable arguments, out FocusCollection focus) + private static IEnumerable runRepeat(Closure ctx, IEnumerable arguments) { - var newNodes = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _).ToList(); - focus = newNodes.ToArray(); - ctx.focus = focus; + var newNodes = arguments.First()(ctx, InvokeeFactory.EmptyArgs).ToList(); + ctx.focus = newNodes; var lambda = arguments.Skip(1).First(); var fullResult = new List(); @@ -441,10 +434,10 @@ private static IEnumerable runRepeat(Closure ctx, IEnumerable runRepeat(Closure ctx, IEnumerable runAll(Closure ctx, IEnumerable arguments, out FocusCollection focus) + private static IEnumerable runAll(Closure ctx, IEnumerable arguments) { - var focusCollection = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); - focus = focusCollection; + var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); ctx.focus = focus; var lambda = arguments.Skip(1).First(); var index = 0; - foreach (ITypedElement element in focusCollection) + foreach (ITypedElement element in focus) { var newFocus = ElementNode.CreateList(element); var newContext = ctx.Nest(newFocus); @@ -470,7 +462,7 @@ private static IEnumerable runAll(Closure ctx, IEnumerable runAll(Closure ctx, IEnumerable runAny(Closure ctx, IEnumerable arguments, out FocusCollection focus) + private static IEnumerable runAny(Closure ctx, IEnumerable arguments) { - var focusCollection = arguments.First()(ctx, InvokeeFactory.EmptyArgs, out _); - focus = focusCollection; + var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); ctx.focus = focus; var lambda = arguments.Skip(1).First(); var index = 0; - foreach (ITypedElement element in focusCollection) + foreach (ITypedElement element in focus) { var newFocus = ElementNode.CreateList(element); var newContext = ctx.Nest(newFocus); @@ -495,7 +486,7 @@ private static IEnumerable runAny(Closure ctx, IEnumerable { var closure = Closure.Root(focus, ctx); - return inv(closure, InvokeeFactory.EmptyArgs, out _); + return inv(closure, InvokeeFactory.EmptyArgs); }; } @@ -77,7 +77,7 @@ public CompiledExpression Compile(Expression expression, bool injectDebugTraceHo return (ITypedElement focus, EvaluationContext ctx) => { var closure = Closure.Root(focus, ctx); - return inv(closure, InvokeeFactory.EmptyArgs, out _); + return inv(closure, InvokeeFactory.EmptyArgs); }; } diff --git a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs index 608dc68856..0b44a8e243 100644 --- a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs +++ b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs @@ -232,7 +232,7 @@ public void testDebugTrace_PropertyAndFunctionCalls() } if (n == 3) { - // the context and results of toString call + // the context and results of indexOf call Assert.AreEqual("example\t(id)\tPatient.id[0]", vFocus); Assert.AreEqual("2\t(System.Integer)", vResult); } From 064ce03c2bdcd352e6d7945a4d79c9e509ffc2e2 Mon Sep 17 00:00:00 2001 From: Ewout Kramer Date: Wed, 20 Aug 2025 11:33:57 +0200 Subject: [PATCH 44/78] The ModelInfo.ModelInspector property was pretty expensive (although mostly cached) - now really cached. --- .../Model/ModelInfo.cs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/Hl7.Fhir.Shims.STU3AndUp/Model/ModelInfo.cs b/src/Hl7.Fhir.Shims.STU3AndUp/Model/ModelInfo.cs index 02a54c3061..5895f679ad 100644 --- a/src/Hl7.Fhir.Shims.STU3AndUp/Model/ModelInfo.cs +++ b/src/Hl7.Fhir.Shims.STU3AndUp/Model/ModelInfo.cs @@ -259,19 +259,18 @@ public static bool IsInstanceTypeFor(FHIRAllTypes superclass, FHIRAllTypes subcl /// Gets the providing metadata for the resources and /// datatypes in this release of FHIR. /// - public static ModelInspector ModelInspector + private static readonly Lazy _modelInspector = new(() => { - get + var inspector = ModelInspector.ForAssembly(typeof(ModelInfo).GetTypeInfo().Assembly); + if (inspector.FhirRelease != Specification.FhirRelease.STU3) { - var inspector = ModelInspector.ForAssembly(typeof(ModelInfo).GetTypeInfo().Assembly); - if (inspector.FhirRelease != Specification.FhirRelease.STU3) - { - // In case of release 4 or higher, also load the assembly with common conformance resources, like StructureDefinition - inspector.Import(typeof(StructureDefinition).GetTypeInfo().Assembly); - } - return inspector; + // In case of release 4 or higher, also load the assembly with common conformance resources, like StructureDefinition + inspector.Import(typeof(StructureDefinition).GetTypeInfo().Assembly); } - } + return inspector; + }); + + public static ModelInspector ModelInspector => _modelInspector.Value; } public static class ModelInfoExtensions From cf9a700ba0c57153b942d7889bfb086ed97e515b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Skowro=C5=84ski?= Date: Wed, 20 Aug 2025 16:25:19 +0200 Subject: [PATCH 45/78] Include `ID_LITERAL_INVALID_CODE` as a recoverable issue in `IsRecoverableIssue` filter. --- src/Hl7.Fhir.Base/Serialization/FilterPredicateExtensions.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Hl7.Fhir.Base/Serialization/FilterPredicateExtensions.cs b/src/Hl7.Fhir.Base/Serialization/FilterPredicateExtensions.cs index edc043c910..6a33e869c9 100644 --- a/src/Hl7.Fhir.Base/Serialization/FilterPredicateExtensions.cs +++ b/src/Hl7.Fhir.Base/Serialization/FilterPredicateExtensions.cs @@ -2,6 +2,7 @@ using Hl7.Fhir.Serialization; using Hl7.Fhir.Utility; +using Hl7.Fhir.Validation; using System; using System.Collections.Generic; using System.Linq; @@ -11,7 +12,7 @@ namespace Hl7.Fhir.Serialization; internal static class FilterPredicateExtensions { internal static Predicate IsRecoverableIssue => - FhirXmlException.RecoverableIssues.Concat(FhirJsonException.RecoverableIssues).ToPredicate(); + FhirXmlException.RecoverableIssues.Concat(FhirJsonException.RecoverableIssues).Append(CodedValidationException.ID_LITERAL_INVALID_CODE).ToPredicate(); internal static Predicate IsBackwardsCompatibilityIssue => FhirXmlException.BackwardsCompatibilityAllowedIssues.Concat(FhirJsonException.BackwardsCompatibilityAllowedIssues).ToPredicate(); From a0eec11265685e710f5ab00b81fd727e391fb6b8 Mon Sep 17 00:00:00 2001 From: Ewout Kramer Date: Wed, 20 Aug 2025 16:56:32 +0200 Subject: [PATCH 46/78] Moved doccomment. --- src/Hl7.Fhir.Shims.STU3AndUp/Model/ModelInfo.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Hl7.Fhir.Shims.STU3AndUp/Model/ModelInfo.cs b/src/Hl7.Fhir.Shims.STU3AndUp/Model/ModelInfo.cs index 5895f679ad..af8fbca35e 100644 --- a/src/Hl7.Fhir.Shims.STU3AndUp/Model/ModelInfo.cs +++ b/src/Hl7.Fhir.Shims.STU3AndUp/Model/ModelInfo.cs @@ -255,10 +255,6 @@ public static bool IsInstanceTypeFor(FHIRAllTypes superclass, FHIRAllTypes subcl public static Canonical? CanonicalUriForFhirCoreType(FHIRAllTypes type) => FhirTypeToFhirTypeName(type) is { } name ? CanonicalUriForFhirCoreType(name) : null; - /// - /// Gets the providing metadata for the resources and - /// datatypes in this release of FHIR. - /// private static readonly Lazy _modelInspector = new(() => { var inspector = ModelInspector.ForAssembly(typeof(ModelInfo).GetTypeInfo().Assembly); @@ -270,6 +266,10 @@ public static bool IsInstanceTypeFor(FHIRAllTypes superclass, FHIRAllTypes subcl return inspector; }); + /// + /// Gets the providing metadata for the resources and + /// datatypes in this release of FHIR. + /// public static ModelInspector ModelInspector => _modelInspector.Value; } From 60fbf6403b2e7a040c1e8c56f6c66a9a8d5b4fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Skowro=C5=84ski?= Date: Wed, 27 Aug 2025 10:46:46 +0200 Subject: [PATCH 47/78] Update release notes --- release-notes.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/release-notes.md b/release-notes.md index 6f9df29a3a..9cb225836c 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,3 +1,5 @@ ## Intro: -Added an FHIR R6-ballot3 sattelite and Nuget package. \ No newline at end of file +Added FHIRPath debug tracer. +Fixes bugs related to paralelization when using SerializationFilter. +Optimization tweaks. From f5eed1a210c2c9e51d18bb78f969d47d41a9278e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Skowro=C5=84ski?= Date: Wed, 27 Aug 2025 11:33:51 +0200 Subject: [PATCH 48/78] Start development phase 5.12.3 --- src/firely-net-sdk.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/firely-net-sdk.props b/src/firely-net-sdk.props index 11e788bb69..f9c300a48c 100644 --- a/src/firely-net-sdk.props +++ b/src/firely-net-sdk.props @@ -6,7 +6,7 @@ - 5.12.2 + 5.12.3 Firely (info@fire.ly) and contributors Firely (https://fire.ly) From 34103f3e4132f2ea70274a5dada360c4b3d57229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Skowro=C5=84ski?= Date: Thu, 28 Aug 2025 12:50:46 +0200 Subject: [PATCH 49/78] Add support for preserving whitespaces in JSON values --- .../Serialization/BaseFhirParser.cs | 3 +- .../Serialization/FhirJsonNode.cs | 15 +++---- .../Serialization/FhirJsonParsingSettings.cs | 7 ++++ .../Serialization/ParserSettings.cs | 7 ++++ .../FhirJsonParserTests.cs | 41 +++++++++++++++++++ ....Fhir.Serialization.Shared.Tests.projitems | 1 + 6 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs diff --git a/src/Hl7.Fhir.Base/Serialization/BaseFhirParser.cs b/src/Hl7.Fhir.Base/Serialization/BaseFhirParser.cs index 1e313d6282..068327d763 100644 --- a/src/Hl7.Fhir.Base/Serialization/BaseFhirParser.cs +++ b/src/Hl7.Fhir.Base/Serialization/BaseFhirParser.cs @@ -46,7 +46,8 @@ internal static FhirJsonParsingSettings BuildJsonParserSettings(ParserSettings s new() { AllowJsonComments = false, - PermissiveParsing = settings.PermissiveParsing + PermissiveParsing = settings.PermissiveParsing, + PersistWhitespacesInValues = settings.PersistWhitespacesInValues, }; diff --git a/src/Hl7.Fhir.Base/Serialization/FhirJsonNode.cs b/src/Hl7.Fhir.Base/Serialization/FhirJsonNode.cs index 0040362d02..b16c677aea 100644 --- a/src/Hl7.Fhir.Base/Serialization/FhirJsonNode.cs +++ b/src/Hl7.Fhir.Base/Serialization/FhirJsonNode.cs @@ -176,18 +176,15 @@ public string Text { get { - if (JsonValue != null) + if (JsonValue is not { Value: {} value }) + return null; + + if (value is string s) { - if (JsonValue.Value != null) - { - // Make sure the representation of this Json-typed value is turned - // into a string representation compatible with the XML serialization - return JsonValue.Value is string s ? s.Trim() - : PrimitiveTypeConverter.ConvertTo(JsonValue.Value); - } + return this._settings.PersistWhitespacesInValues ? s : s.Trim(); } - return null; + return PrimitiveTypeConverter.ConvertTo(value); } } diff --git a/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs b/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs index 51a200d680..3bcafe101c 100644 --- a/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs +++ b/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs @@ -31,6 +31,12 @@ public class FhirJsonParsingSettings /// Validation of xhtml is expensive, so turned off by default. public bool ValidateFhirXhtml { get; set; } // = false; + /// + /// Serialized elements won't be trimmed of whitespaces. + /// + /// It will keep the values as serialized, but it will not be valid if serialized to XML, or validated. + public bool PersistWhitespacesInValues { get; set; } // = false; + /// Default constructor. Creates a new instance with default property values. public FhirJsonParsingSettings() { } @@ -52,6 +58,7 @@ public void CopyTo(FhirJsonParsingSettings other) other.PermissiveParsing = PermissiveParsing; other.AllowJsonComments = AllowJsonComments; other.ValidateFhirXhtml = ValidateFhirXhtml; + other.PersistWhitespacesInValues = PersistWhitespacesInValues; } /// Creates a new object that is a copy of the current instance. diff --git a/src/Hl7.Fhir.Base/Serialization/ParserSettings.cs b/src/Hl7.Fhir.Base/Serialization/ParserSettings.cs index 5333fd7a46..de07422160 100644 --- a/src/Hl7.Fhir.Base/Serialization/ParserSettings.cs +++ b/src/Hl7.Fhir.Base/Serialization/ParserSettings.cs @@ -35,6 +35,12 @@ public class ParserSettings /// public bool PermissiveParsing { get; set; } = true; + /// + /// Instruct JSON parser to keep the values untrimmed. + /// + /// It will keep the values as serialized, but it will not be valid XML attribute and most likely not a valid FHIR value. + public bool PersistWhitespacesInValues { get; set; } + /// /// Allow to parse a FHIR dateTime values into an element of type date. /// @@ -76,6 +82,7 @@ public void CopyTo(ParserSettings other) other.TruncateDateTimeToDate = TruncateDateTimeToDate; #pragma warning restore CS0618 // Type or member is obsolete other.ExceptionHandler = ExceptionHandler; + other.PersistWhitespacesInValues = PersistWhitespacesInValues; } /// diff --git a/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs b/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs new file mode 100644 index 0000000000..7a3d9187c4 --- /dev/null +++ b/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using Hl7.Fhir.Model; +using Hl7.Fhir.Serialization; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Hl7.Fhir.Support.Tests.Serialization; + +[TestClass] +public class FhirJsonParserTests +{ + [TestMethod] + public void FhirJsonParser_WillKeepWhitespace() + { + string json = """ + { + "resourceType": "Patient", + "id": " whitespace ", + } + """; + var res = new FhirJsonParser(new() + { + PersistWhitespacesInValues = true + }).Parse(json); + + res.Id.Should().Be(" whitespace "); + } + + [TestMethod] + public void FhirJsonParser_TrimsWhitespaceByDefault() + { + string json = """ + { + "resourceType": "Patient", + "id": " whitespace ", + } + """; + var res = new FhirJsonParser().Parse(json); + + res.Id.Should().Be("whitespace"); + } +} \ No newline at end of file diff --git a/src/Hl7.Fhir.Serialization.Shared.Tests/Hl7.Fhir.Serialization.Shared.Tests.projitems b/src/Hl7.Fhir.Serialization.Shared.Tests/Hl7.Fhir.Serialization.Shared.Tests.projitems index 8b85e57869..f4e22dd71a 100644 --- a/src/Hl7.Fhir.Serialization.Shared.Tests/Hl7.Fhir.Serialization.Shared.Tests.projitems +++ b/src/Hl7.Fhir.Serialization.Shared.Tests/Hl7.Fhir.Serialization.Shared.Tests.projitems @@ -10,6 +10,7 @@ + From 42456c85de828c35a140412eff42de83e37645d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Skowro=C5=84ski?= Date: Thu, 28 Aug 2025 14:50:12 +0200 Subject: [PATCH 50/78] Apply suggestion from @alexzautke Co-authored-by: Alexander Zautke --- src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs b/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs index 3bcafe101c..c1391931aa 100644 --- a/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs +++ b/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs @@ -34,7 +34,7 @@ public class FhirJsonParsingSettings /// /// Serialized elements won't be trimmed of whitespaces. /// - /// It will keep the values as serialized, but it will not be valid if serialized to XML, or validated. + /// Retains values exactly as serialized. Untrimmed content is invalid for XML serialization or validation in general. public bool PersistWhitespacesInValues { get; set; } // = false; /// Default constructor. Creates a new instance with default property values. From f27a56343da073d658a37b3474f134bbe1c06dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Skowro=C5=84ski?= Date: Thu, 28 Aug 2025 14:50:26 +0200 Subject: [PATCH 51/78] Apply suggestion from @alexzautke Co-authored-by: Alexander Zautke --- src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs b/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs index c1391931aa..8e3a668074 100644 --- a/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs +++ b/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs @@ -58,7 +58,7 @@ public void CopyTo(FhirJsonParsingSettings other) other.PermissiveParsing = PermissiveParsing; other.AllowJsonComments = AllowJsonComments; other.ValidateFhirXhtml = ValidateFhirXhtml; - other.PersistWhitespacesInValues = PersistWhitespacesInValues; + other.PreserveWhitespaceInValues = PreserveWhitespaceInValues; } /// Creates a new object that is a copy of the current instance. From 995140a23420d16e222a9bd73fd2769052a42e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Skowro=C5=84ski?= Date: Thu, 28 Aug 2025 14:54:32 +0200 Subject: [PATCH 52/78] Rename --- src/Hl7.Fhir.Base/Serialization/BaseFhirParser.cs | 2 +- src/Hl7.Fhir.Base/Serialization/FhirJsonNode.cs | 2 +- src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs | 2 +- src/Hl7.Fhir.Base/Serialization/ParserSettings.cs | 4 ++-- .../FhirJsonParserTests.cs | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Hl7.Fhir.Base/Serialization/BaseFhirParser.cs b/src/Hl7.Fhir.Base/Serialization/BaseFhirParser.cs index 068327d763..34e8557c2f 100644 --- a/src/Hl7.Fhir.Base/Serialization/BaseFhirParser.cs +++ b/src/Hl7.Fhir.Base/Serialization/BaseFhirParser.cs @@ -47,7 +47,7 @@ internal static FhirJsonParsingSettings BuildJsonParserSettings(ParserSettings s { AllowJsonComments = false, PermissiveParsing = settings.PermissiveParsing, - PersistWhitespacesInValues = settings.PersistWhitespacesInValues, + PreserveWhitespaceInValues = settings.PreserveWhitespaceInValues, }; diff --git a/src/Hl7.Fhir.Base/Serialization/FhirJsonNode.cs b/src/Hl7.Fhir.Base/Serialization/FhirJsonNode.cs index b16c677aea..8c19f03ff9 100644 --- a/src/Hl7.Fhir.Base/Serialization/FhirJsonNode.cs +++ b/src/Hl7.Fhir.Base/Serialization/FhirJsonNode.cs @@ -181,7 +181,7 @@ public string Text if (value is string s) { - return this._settings.PersistWhitespacesInValues ? s : s.Trim(); + return this._settings.PreserveWhitespaceInValues ? s : s.Trim(); } return PrimitiveTypeConverter.ConvertTo(value); diff --git a/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs b/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs index 8e3a668074..9c2d8a088a 100644 --- a/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs +++ b/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs @@ -35,7 +35,7 @@ public class FhirJsonParsingSettings /// Serialized elements won't be trimmed of whitespaces. /// /// Retains values exactly as serialized. Untrimmed content is invalid for XML serialization or validation in general. - public bool PersistWhitespacesInValues { get; set; } // = false; + public bool PreserveWhitespaceInValues { get; set; } // = false; /// Default constructor. Creates a new instance with default property values. public FhirJsonParsingSettings() { } diff --git a/src/Hl7.Fhir.Base/Serialization/ParserSettings.cs b/src/Hl7.Fhir.Base/Serialization/ParserSettings.cs index de07422160..e0d0a69331 100644 --- a/src/Hl7.Fhir.Base/Serialization/ParserSettings.cs +++ b/src/Hl7.Fhir.Base/Serialization/ParserSettings.cs @@ -39,7 +39,7 @@ public class ParserSettings /// Instruct JSON parser to keep the values untrimmed. /// /// It will keep the values as serialized, but it will not be valid XML attribute and most likely not a valid FHIR value. - public bool PersistWhitespacesInValues { get; set; } + public bool PreserveWhitespaceInValues { get; set; } /// /// Allow to parse a FHIR dateTime values into an element of type date. @@ -82,7 +82,7 @@ public void CopyTo(ParserSettings other) other.TruncateDateTimeToDate = TruncateDateTimeToDate; #pragma warning restore CS0618 // Type or member is obsolete other.ExceptionHandler = ExceptionHandler; - other.PersistWhitespacesInValues = PersistWhitespacesInValues; + other.PreserveWhitespaceInValues = PreserveWhitespaceInValues; } /// diff --git a/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs b/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs index 7a3d9187c4..db2987fa38 100644 --- a/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs +++ b/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs @@ -19,7 +19,7 @@ public void FhirJsonParser_WillKeepWhitespace() """; var res = new FhirJsonParser(new() { - PersistWhitespacesInValues = true + PreserveWhitespaceInValues = true }).Parse(json); res.Id.Should().Be(" whitespace "); From 85a93af762a6656cb54731c608366564dea23f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Skowro=C5=84ski?= Date: Thu, 28 Aug 2025 14:55:18 +0200 Subject: [PATCH 53/78] Clarify --- src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs b/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs index 9c2d8a088a..d9b5419afa 100644 --- a/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs +++ b/src/Hl7.Fhir.Base/Serialization/FhirJsonParsingSettings.cs @@ -32,7 +32,7 @@ public class FhirJsonParsingSettings public bool ValidateFhirXhtml { get; set; } // = false; /// - /// Serialized elements won't be trimmed of whitespaces. + /// Serialized string elements won't be trimmed of whitespaces. /// /// Retains values exactly as serialized. Untrimmed content is invalid for XML serialization or validation in general. public bool PreserveWhitespaceInValues { get; set; } // = false; From b621e73db9f148f10cf119a995646efd6ebd4f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Skowro=C5=84ski?= Date: Thu, 28 Aug 2025 14:57:15 +0200 Subject: [PATCH 54/78] Json nitpicks --- .../FhirJsonParserTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs b/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs index db2987fa38..e59048d774 100644 --- a/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs +++ b/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs @@ -14,7 +14,7 @@ public void FhirJsonParser_WillKeepWhitespace() string json = """ { "resourceType": "Patient", - "id": " whitespace ", + "id": " whitespace " } """; var res = new FhirJsonParser(new() @@ -31,7 +31,7 @@ public void FhirJsonParser_TrimsWhitespaceByDefault() string json = """ { "resourceType": "Patient", - "id": " whitespace ", + "id": " whitespace " } """; var res = new FhirJsonParser().Parse(json); From 8857a7eb430c5016c015135c6768ea0148ee4ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Skowro=C5=84ski?= Date: Thu, 28 Aug 2025 14:59:26 +0200 Subject: [PATCH 55/78] Review notes --- src/Hl7.Fhir.Base/Serialization/ParserSettings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Hl7.Fhir.Base/Serialization/ParserSettings.cs b/src/Hl7.Fhir.Base/Serialization/ParserSettings.cs index e0d0a69331..c0b31f3020 100644 --- a/src/Hl7.Fhir.Base/Serialization/ParserSettings.cs +++ b/src/Hl7.Fhir.Base/Serialization/ParserSettings.cs @@ -36,7 +36,7 @@ public class ParserSettings public bool PermissiveParsing { get; set; } = true; /// - /// Instruct JSON parser to keep the values untrimmed. + /// Instruct JSON parser to keep the string values untrimmed. /// /// It will keep the values as serialized, but it will not be valid XML attribute and most likely not a valid FHIR value. public bool PreserveWhitespaceInValues { get; set; } From 81d08f9679325f80c1e7a09557b6cc6214340125 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 Aug 2025 15:13:32 +0000 Subject: [PATCH 56/78] Initial plan From 83500d4610490ecd26e34fa1f6687ef87d3d10c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 Aug 2025 15:38:23 +0000 Subject: [PATCH 57/78] Implement mapping suppression extension functionality Co-authored-by: alexzautke <548617+alexzautke@users.noreply.github.com> --- .../Snapshot/SnapshotGeneratorExtensions.cs | 12 ++ .../Snapshot/ElementDefnMerger.cs | 63 +++++- .../Snapshot/ElementDefnMerger.cs | 63 +++++- ...SnapshotGeneratorMappingSuppressionTest.cs | 198 ++++++++++++++++++ 4 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 src/Hl7.Fhir.Specification.R4.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs diff --git a/src/Hl7.Fhir.Base/Specification/Snapshot/SnapshotGeneratorExtensions.cs b/src/Hl7.Fhir.Base/Specification/Snapshot/SnapshotGeneratorExtensions.cs index 159e5b3788..374100c0df 100644 --- a/src/Hl7.Fhir.Base/Specification/Snapshot/SnapshotGeneratorExtensions.cs +++ b/src/Hl7.Fhir.Base/Specification/Snapshot/SnapshotGeneratorExtensions.cs @@ -26,6 +26,7 @@ public static class SnapshotGeneratorExtensions // public static readonly string CHANGED_BY_DIFF_EXT = "http://hl7.org/fhir/StructureDefinition/changedByDifferential"; public static readonly string CONSTRAINED_BY_DIFF_EXT = "http://hl7.org/fhir/StructureDefinition/constrainedByDifferentialExtension"; public static readonly string STRUCTURE_DEFINITION_INTERFACE_EXT = "http://hl7.org/fhir/StructureDefinition/structuredefinition-interface"; + public static readonly string ELEMENTDEFINITION_SUPPRESS_EXT = "http://hl7.org/fhir/StructureDefinition/elementdefinition-suppress"; /// /// Decorate the specified snapshot element definition with a special extension @@ -53,6 +54,17 @@ public static void RemoveConstrainedByDiffExtension(this IExtendable element) element.RemoveExtension(CONSTRAINED_BY_DIFF_EXT); } + /// Determines if the element has the suppress extension indicating it should not be inherited. + /// An instance. + /// True if the element has the suppress extension, false otherwise. + /// Gets the boolean flag from the extension, if it exists. + public static bool HasSuppressExtension(this IExtendable element) + { + if (element == null) return false; + var suppressValue = element.GetBoolExtension(ELEMENTDEFINITION_SUPPRESS_EXT); + return suppressValue == true; + } + /// Recursively removes all instances of the extension from the specified element definition and all it's child objects. [Obsolete("Use RemoveAllNonInheritableExtensions(this Element element) instead.")] public static void RemoveAllConstrainedByDiffExtensions(this Element element) diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs index 593fed0cda..84cc0a77ec 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs @@ -186,7 +186,8 @@ void merge(ElementDefinition snap, ElementDefinition diff, bool mergeElementId, } // Mappings are cumulative, but keep unique on full contents - snap.Mapping = mergeCollection(snap.Mapping, diff.Mapping, matchExactly); + // Skip mappings from snap (inherited) that have the suppress extension in diff + snap.Mapping = mergeMappings(snap.Mapping, diff.Mapping); } private void correctListMerge(List originalBase, List replacement, Action> setBase) @@ -452,6 +453,66 @@ internal static void InitializeConstraintSource(IEnumerable mergeMappings(List snap, List diff) + { + var result = snap; + if (!diff.IsNullOrEmpty()) + { + if (snap.IsNullOrEmpty()) + { + result = (List)diff.DeepCopy(); + onConstraint(result); + } + else if (!diff.IsExactly(snap)) + { + // Start with inherited mappings from snapshot + result = new List(snap.DeepCopy()); + + // Process each diff mapping + foreach (var diffItem in diff) + { + // Match by Identity and Map, not exact equality (to handle extensions) + var idx = snap.FindIndex(e => IsEqualString(e.Identity, diffItem.Identity) && IsEqualString(e.Map, diffItem.Map)); + ElementDefinition.MappingComponent mergedItem = null; + if (idx < 0) + { + // New mapping from differential - add it (but only if not suppressed) + if (!diffItem.HasSuppressExtension()) + { + mergedItem = (ElementDefinition.MappingComponent)diffItem.DeepCopy(); + result.Add(mergedItem); + } + } + else + { + // Matching mapping exists in snapshot + // Check if diff mapping has suppress extension + if (diffItem.HasSuppressExtension()) + { + // Remove the inherited mapping - it's being suppressed + result.RemoveAt(idx); + continue; + } + else + { + // Merge diff with snap (normal cumulative behavior) + var snapItem = result[idx]; + mergedItem = mergeComplexAttribute(snapItem, diffItem); + result[idx] = mergedItem; + } + } + if (mergedItem != null) + { + onConstraint(mergedItem); + } + } + } + } + return result; + } + // Merge two collections // Differential collection items replace/overwrite matching snapshot collection items List mergeCollection(List snap, List diff, Func matchItems) where T : Element diff --git a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs index bcd81ac848..3f936be70e 100644 --- a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs @@ -102,7 +102,8 @@ void merge(ElementDefinition snap, ElementDefinition diff, bool mergeElementId, snap.AliasElement = mergeCollection(snap.AliasElement, diff.AliasElement, (a, b) => a.Value == b.Value); // Mappings are cumulative, but keep unique on full contents - snap.Mapping = mergeCollection(snap.Mapping, diff.Mapping, (a, b) => a.IsExactly(b)); + // Skip mappings from snap (inherited) that have the suppress extension in diff + snap.Mapping = mergeMappings(snap.Mapping, diff.Mapping); // Note that max is not corrected when max < min! constrainMax could be used if that is desired. snap.MinElement = mergeMin(snap.MinElement, diff.MinElement); @@ -492,6 +493,66 @@ List mergeCollection(List snap, List diff, Func matchIte return result; } + // Custom merge logic for mappings that respects the suppress extension + // Inherit all mapping definitions from a parent resource unless someone added a suppress extension to it + List mergeMappings(List snap, List diff) + { + var result = snap; + if (!diff.IsNullOrEmpty()) + { + if (snap.IsNullOrEmpty()) + { + result = (List)diff.DeepCopy(); + onConstraint(result); + } + else if (!diff.IsExactly(snap)) + { + // Start with inherited mappings from snapshot + result = new List(snap.DeepCopy()); + + // Process each diff mapping + foreach (var diffItem in diff) + { + // Match by Identity and Map, not exact equality (to handle extensions) + var idx = snap.FindIndex(e => isEqualString(e.Identity, diffItem.Identity) && isEqualString(e.Map, diffItem.Map)); + ElementDefinition.MappingComponent mergedItem = null; + if (idx < 0) + { + // New mapping from differential - add it (but only if not suppressed) + if (!diffItem.HasSuppressExtension()) + { + mergedItem = (ElementDefinition.MappingComponent)diffItem.DeepCopy(); + result.Add(mergedItem); + } + } + else + { + // Matching mapping exists in snapshot + // Check if diff mapping has suppress extension + if (diffItem.HasSuppressExtension()) + { + // Remove the inherited mapping - it's being suppressed + result.RemoveAt(idx); + continue; + } + else + { + // Merge diff with snap (normal cumulative behavior) + var snapItem = result[idx]; + mergedItem = mergeComplexAttribute(snapItem, diffItem); + result[idx] = mergedItem; + } + } + if (mergedItem != null) + { + onConstraint(mergedItem); + } + } + } + } + return result; + } + //[MS 20201211] Separate function introduced to make sure that introduced extensions on Binding.Valueset in the diff are merged with the base. // This is a very specific fix and might be replaced by a more general merging method using ITypedElement in the future. diff --git a/src/Hl7.Fhir.Specification.R4.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs b/src/Hl7.Fhir.Specification.R4.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs new file mode 100644 index 0000000000..2a0a5064ce --- /dev/null +++ b/src/Hl7.Fhir.Specification.R4.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2018, Firely (info@fire.ly) and contributors + * See the file CONTRIBUTORS for details. + * + * This file is licensed under the BSD 3-Clause license + * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE + */ + +using Hl7.Fhir.Model; +using Hl7.Fhir.Specification.Snapshot; +using Hl7.Fhir.Specification.Source; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.Linq; +using Hl7.Fhir.Utility; + +namespace Hl7.Fhir.Specification.Tests +{ + [TestClass] + public class SnapshotGeneratorMappingSuppressionTest + { + + [TestMethod] + public async System.Threading.Tasks.Task TestMappingInheritanceWithoutSuppression() + { + // Create a base profile with a mapping that already has snapshot + var baseProfile = CreateBaseProfileWithMapping(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient" + } + } + } + } + }; + + // Create a derived profile without suppress extension + var derivedProfile = CreateDerivedProfileWithoutSuppression(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Generate snapshot + var generator = new SnapshotGenerator(mockResolver, new SnapshotGeneratorSettings()); + await generator.UpdateAsync(derivedProfile); + + // Verify that mapping is inherited + var rootElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(rootElement, "Should have Patient root element"); + Assert.IsNotNull(rootElement.Mapping, "Mapping should be inherited from base profile"); + Assert.AreEqual(1, rootElement.Mapping.Count, "Should have inherited one mapping"); + Assert.AreEqual("test-identity", rootElement.Mapping[0].Identity, "Should have inherited the correct mapping"); + } + + [TestMethod] + public async System.Threading.Tasks.Task TestMappingSuppressionWithExtension() + { + // Create a base profile with a mapping that already has snapshot + var baseProfile = CreateBaseProfileWithMapping(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient" + } + } + } + } + }; + + // Create a derived profile with suppress extension on mapping + var derivedProfile = CreateDerivedProfileWithSuppressedMapping(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Generate snapshot + var generator = new SnapshotGenerator(mockResolver, new SnapshotGeneratorSettings()); + await generator.UpdateAsync(derivedProfile); + + // Verify that mapping is NOT inherited due to suppression + var rootElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(rootElement, "Should have Patient root element"); + var inheritedMapping = rootElement.Mapping?.FirstOrDefault(m => m.Identity == "test-identity"); + Assert.IsNull(inheritedMapping, "Mapping with suppress extension should not be inherited"); + } + + private StructureDefinition CreateBaseProfileWithMapping() + { + return new StructureDefinition() + { + Type = "Patient", + BaseDefinition = ModelInfo.CanonicalUriForFhirCoreType("Patient"), + Name = "BasePatientWithMapping", + Url = @"http://example.org/fhir/StructureDefinition/BasePatientWithMapping", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Differential = new StructureDefinition.DifferentialComponent() + { + Element = new List() + { + new ElementDefinition("Patient") + { + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient" + } + } + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithoutSuppression() + { + return new StructureDefinition() + { + Type = "Patient", + BaseDefinition = @"http://example.org/fhir/StructureDefinition/BasePatientWithMapping", + Name = "DerivedPatientWithoutSuppression", + Url = @"http://example.org/fhir/StructureDefinition/DerivedPatientWithoutSuppression", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Differential = new StructureDefinition.DifferentialComponent() + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile" + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithSuppressedMapping() + { + return new StructureDefinition() + { + Type = "Patient", + BaseDefinition = @"http://example.org/fhir/StructureDefinition/BasePatientWithMapping", + Name = "DerivedPatientWithSuppressedMapping", + Url = @"http://example.org/fhir/StructureDefinition/DerivedPatientWithSuppressedMapping", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Differential = new StructureDefinition.DifferentialComponent() + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile", + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient", + Extension = new List() + { + new Extension() + { + Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, + Value = new FhirBoolean(true) + } + } + } + } + } + } + } + }; + } + } +} \ No newline at end of file From d24bfbf8b1f9df6156daaa15d03648c4ac57a81c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 Aug 2025 18:26:15 +0000 Subject: [PATCH 58/78] Implement example suppression extension functionality Apply suppress extension support to ElementDefinition.example matching the existing mapping functionality. Examples can now be selectively excluded from inheritance using the elementdefinition-suppress extension. Co-authored-by: alexzautke <548617+alexzautke@users.noreply.github.com> --- .../Snapshot/ElementDefnMerger.cs | 63 +++++- .../Snapshot/ElementDefnMerger.cs | 63 +++++- ...SnapshotGeneratorMappingSuppressionTest.cs | 195 ++++++++++++++++++ 3 files changed, 319 insertions(+), 2 deletions(-) diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs index 84cc0a77ec..5ace07ccc6 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs @@ -141,7 +141,8 @@ void merge(ElementDefinition snap, ElementDefinition diff, bool mergeElementId, // Examples are cumulative based on the full value // [EK 20170301] In STU3, this was turned into a collection - snap.Example = mergeCollection(snap.Example, diff.Example, matchExactly); + // Skip examples from snap (inherited) that have the suppress extension in diff + snap.Example = mergeExamples(snap.Example, diff.Example); snap.MinValue = mergeComplexAttribute(snap.MinValue, diff.MinValue); snap.MaxValue = mergeComplexAttribute(snap.MaxValue, diff.MaxValue); @@ -513,6 +514,66 @@ internal static void InitializeConstraintSource(IEnumerable mergeExamples(List snap, List diff) + { + var result = snap; + if (!diff.IsNullOrEmpty()) + { + if (snap.IsNullOrEmpty()) + { + result = (List)diff.DeepCopy(); + onConstraint(result); + } + else if (!diff.IsExactly(snap)) + { + // Start with inherited examples from snapshot + result = new List(snap.DeepCopy()); + + // Process each diff example + foreach (var diffItem in diff) + { + // Match by Label, not exact equality (to handle extensions) + var idx = snap.FindIndex(e => IsEqualString(e.Label, diffItem.Label)); + ElementDefinition.ExampleComponent mergedItem = null; + if (idx < 0) + { + // New example from differential - add it (but only if not suppressed) + if (!diffItem.HasSuppressExtension()) + { + mergedItem = (ElementDefinition.ExampleComponent)diffItem.DeepCopy(); + result.Add(mergedItem); + } + } + else + { + // Matching example exists in snapshot + // Check if diff example has suppress extension + if (diffItem.HasSuppressExtension()) + { + // Remove the inherited example - it's being suppressed + result.RemoveAt(idx); + continue; + } + else + { + // Merge diff with snap (normal cumulative behavior) + var snapItem = result[idx]; + mergedItem = mergeComplexAttribute(snapItem, diffItem); + result[idx] = mergedItem; + } + } + if (mergedItem != null) + { + onConstraint(mergedItem); + } + } + } + } + return result; + } + // Merge two collections // Differential collection items replace/overwrite matching snapshot collection items List mergeCollection(List snap, List diff, Func matchItems) where T : Element diff --git a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs index 3f936be70e..0e95fd6919 100644 --- a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs @@ -128,7 +128,8 @@ void merge(ElementDefinition snap, ElementDefinition diff, bool mergeElementId, snap.MaxLengthElement = mergePrimitiveAttribute(snap.MaxLengthElement, diff.MaxLengthElement); // [EK 20170301] In STU3, this was turned into a collection - snap.Example = mergeCollection(snap.Example, diff.Example, (a, b) => a.IsExactly(b)); + // Skip examples from snap (inherited) that have the suppress extension in diff + snap.Example = mergeExamples(snap.Example, diff.Example); snap.MinValue = mergeComplexAttribute(snap.MinValue, diff.MinValue); snap.MaxValue = mergeComplexAttribute(snap.MaxValue, diff.MaxValue); @@ -553,6 +554,66 @@ List mergeCollection(List snap, List diff, Func matchIte return result; } + // Custom merge logic for examples that respects the suppress extension + // Inherit all example definitions from a parent resource unless someone added a suppress extension to it + List mergeExamples(List snap, List diff) + { + var result = snap; + if (!diff.IsNullOrEmpty()) + { + if (snap.IsNullOrEmpty()) + { + result = (List)diff.DeepCopy(); + onConstraint(result); + } + else if (!diff.IsExactly(snap)) + { + // Start with inherited examples from snapshot + result = new List(snap.DeepCopy()); + + // Process each diff example + foreach (var diffItem in diff) + { + // Match by Label, not exact equality (to handle extensions) + var idx = snap.FindIndex(e => isEqualString(e.Label, diffItem.Label)); + ElementDefinition.ExampleComponent mergedItem = null; + if (idx < 0) + { + // New example from differential - add it (but only if not suppressed) + if (!diffItem.HasSuppressExtension()) + { + mergedItem = (ElementDefinition.ExampleComponent)diffItem.DeepCopy(); + result.Add(mergedItem); + } + } + else + { + // Matching example exists in snapshot + // Check if diff example has suppress extension + if (diffItem.HasSuppressExtension()) + { + // Remove the inherited example - it's being suppressed + result.RemoveAt(idx); + continue; + } + else + { + // Merge diff with snap (normal cumulative behavior) + var snapItem = result[idx]; + mergedItem = mergeComplexAttribute(snapItem, diffItem); + result[idx] = mergedItem; + } + } + if (mergedItem != null) + { + onConstraint(mergedItem); + } + } + } + } + return result; + } + //[MS 20201211] Separate function introduced to make sure that introduced extensions on Binding.Valueset in the diff are merged with the base. // This is a very specific fix and might be replaced by a more general merging method using ITypedElement in the future. diff --git a/src/Hl7.Fhir.Specification.R4.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs b/src/Hl7.Fhir.Specification.R4.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs index 2a0a5064ce..7fb4e42bc6 100644 --- a/src/Hl7.Fhir.Specification.R4.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs +++ b/src/Hl7.Fhir.Specification.R4.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs @@ -194,5 +194,200 @@ private StructureDefinition CreateDerivedProfileWithSuppressedMapping() } }; } + [TestMethod] + public async System.Threading.Tasks.Task TestExampleInheritanceWithoutSuppression() + { + // Create a base profile with an example that already has snapshot + var baseProfile = CreateBaseProfileWithExample(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name") + } + } + } + } + }; + + // Create a derived profile without suppress extension + var derivedProfile = CreateDerivedProfileWithoutExampleSuppression(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Create snapshot generator + var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); + + // Generate snapshot for the derived profile + generator.Update(derivedProfile); + + // Assert that the derived profile inherited the example from the base + Assert.IsNotNull(derivedProfile.Snapshot); + var patientElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(patientElement); + Assert.IsNotNull(patientElement.Example); + Assert.AreEqual(1, patientElement.Example.Count); + Assert.AreEqual("test-example", patientElement.Example[0].Label); + Assert.AreEqual("Example patient name", (patientElement.Example[0].Value as FhirString)?.Value); + } + + [TestMethod] + public async System.Threading.Tasks.Task TestExampleSuppressionExtension() + { + // Create a base profile with an example that already has snapshot + var baseProfile = CreateBaseProfileWithExample(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name") + } + } + } + } + }; + + // Create a derived profile that suppresses the inherited example + var derivedProfile = CreateDerivedProfileWithExampleSuppression(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Create snapshot generator + var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); + + // Generate snapshot for the derived profile + generator.Update(derivedProfile); + + // Assert that the derived profile did NOT inherit the example (it was suppressed) + Assert.IsNotNull(derivedProfile.Snapshot); + var patientElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(patientElement); + + // The example should be absent because it was suppressed + Assert.IsTrue(patientElement.Example == null || patientElement.Example.Count == 0); + } + + private StructureDefinition CreateBaseProfileWithExample() + { + return new StructureDefinition() + { + Id = "base-patient-profile-with-example", + Url = "http://example.org/fhir/StructureDefinition/base-patient-with-example", + Name = "BasePatientProfileWithExample", + Status = PublicationStatus.Active, + FhirVersion = FHIRVersion.N4_0_1, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + Type = "Patient", + BaseDefinition = "http://hl7.org/fhir/StructureDefinition/Patient", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Differential = new StructureDefinition.DifferentialComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Base patient profile with example", + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name") + } + } + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithoutExampleSuppression() + { + return new StructureDefinition() + { + Id = "derived-patient-profile-no-example-suppression", + Url = "http://example.org/fhir/StructureDefinition/derived-patient-no-example-suppression", + Name = "DerivedPatientProfileNoExampleSuppression", + Status = PublicationStatus.Active, + FhirVersion = FHIRVersion.N4_0_1, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + Type = "Patient", + BaseDefinition = "http://example.org/fhir/StructureDefinition/base-patient-with-example", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Differential = new StructureDefinition.DifferentialComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile without example suppression" + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithExampleSuppression() + { + return new StructureDefinition() + { + Id = "derived-patient-profile-with-example-suppression", + Url = "http://example.org/fhir/StructureDefinition/derived-patient-with-example-suppression", + Name = "DerivedPatientProfileWithExampleSuppression", + Status = PublicationStatus.Active, + FhirVersion = FHIRVersion.N4_0_1, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + Type = "Patient", + BaseDefinition = "http://example.org/fhir/StructureDefinition/base-patient-with-example", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Differential = new StructureDefinition.DifferentialComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile with example suppression", + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name"), + Extension = new List() + { + new Extension() + { + Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, + Value = new FhirBoolean(true) + } + } + } + } + } + } + } + }; + } } } \ No newline at end of file From 6323a4e34fb2a7ee34feeaa4c9b3106d947f7f31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 Aug 2025 18:41:33 +0000 Subject: [PATCH 59/78] Optimize code duplication by creating generic mergeCollectionWithSuppression method Co-authored-by: alexzautke <548617+alexzautke@users.noreply.github.com> --- .../Snapshot/ElementDefnMerger.cs | 92 +++++-------------- .../Snapshot/ElementDefnMerger.cs | 92 +++++-------------- 2 files changed, 46 insertions(+), 138 deletions(-) diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs index 5ace07ccc6..2022ce6a63 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs @@ -454,45 +454,45 @@ internal static void InitializeConstraintSource(IEnumerable mergeMappings(List snap, List diff) + // Generic merge logic for collections that respects the suppress extension + // Inherit all collection items from a parent resource unless someone added a suppress extension to it + List mergeCollectionWithSuppression(List snap, List diff, Func matchItems) where T : Element, IExtendable { var result = snap; if (!diff.IsNullOrEmpty()) { if (snap.IsNullOrEmpty()) { - result = (List)diff.DeepCopy(); + result = (List)diff.DeepCopy(); onConstraint(result); } else if (!diff.IsExactly(snap)) { - // Start with inherited mappings from snapshot - result = new List(snap.DeepCopy()); + // Start with inherited items from snapshot + result = new List(snap.DeepCopy()); - // Process each diff mapping + // Process each diff item foreach (var diffItem in diff) { - // Match by Identity and Map, not exact equality (to handle extensions) - var idx = snap.FindIndex(e => IsEqualString(e.Identity, diffItem.Identity) && IsEqualString(e.Map, diffItem.Map)); - ElementDefinition.MappingComponent mergedItem = null; + // Match by the provided matching function + var idx = snap.FindIndex(e => matchItems(e, diffItem)); + T mergedItem = null; if (idx < 0) { - // New mapping from differential - add it (but only if not suppressed) + // New item from differential - add it (but only if not suppressed) if (!diffItem.HasSuppressExtension()) { - mergedItem = (ElementDefinition.MappingComponent)diffItem.DeepCopy(); + mergedItem = (T)diffItem.DeepCopy(); result.Add(mergedItem); } } else { - // Matching mapping exists in snapshot - // Check if diff mapping has suppress extension + // Matching item exists in snapshot + // Check if diff item has suppress extension if (diffItem.HasSuppressExtension()) { - // Remove the inherited mapping - it's being suppressed + // Remove the inherited item - it's being suppressed result.RemoveAt(idx); continue; } @@ -514,64 +514,18 @@ internal static void InitializeConstraintSource(IEnumerable mergeMappings(List snap, List diff) + { + return mergeCollectionWithSuppression(snap, diff, (s, d) => IsEqualString(s.Identity, d.Identity) && IsEqualString(s.Map, d.Map)); + } + // Custom merge logic for examples that respects the suppress extension // Inherit all example definitions from a parent resource unless someone added a suppress extension to it List mergeExamples(List snap, List diff) { - var result = snap; - if (!diff.IsNullOrEmpty()) - { - if (snap.IsNullOrEmpty()) - { - result = (List)diff.DeepCopy(); - onConstraint(result); - } - else if (!diff.IsExactly(snap)) - { - // Start with inherited examples from snapshot - result = new List(snap.DeepCopy()); - - // Process each diff example - foreach (var diffItem in diff) - { - // Match by Label, not exact equality (to handle extensions) - var idx = snap.FindIndex(e => IsEqualString(e.Label, diffItem.Label)); - ElementDefinition.ExampleComponent mergedItem = null; - if (idx < 0) - { - // New example from differential - add it (but only if not suppressed) - if (!diffItem.HasSuppressExtension()) - { - mergedItem = (ElementDefinition.ExampleComponent)diffItem.DeepCopy(); - result.Add(mergedItem); - } - } - else - { - // Matching example exists in snapshot - // Check if diff example has suppress extension - if (diffItem.HasSuppressExtension()) - { - // Remove the inherited example - it's being suppressed - result.RemoveAt(idx); - continue; - } - else - { - // Merge diff with snap (normal cumulative behavior) - var snapItem = result[idx]; - mergedItem = mergeComplexAttribute(snapItem, diffItem); - result[idx] = mergedItem; - } - } - if (mergedItem != null) - { - onConstraint(mergedItem); - } - } - } - } - return result; + return mergeCollectionWithSuppression(snap, diff, (s, d) => IsEqualString(s.Label, d.Label)); } // Merge two collections diff --git a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs index 0e95fd6919..574c70017c 100644 --- a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs @@ -494,45 +494,45 @@ List mergeCollection(List snap, List diff, Func matchIte return result; } - // Custom merge logic for mappings that respects the suppress extension - // Inherit all mapping definitions from a parent resource unless someone added a suppress extension to it - List mergeMappings(List snap, List diff) + // Generic merge logic for collections that respects the suppress extension + // Inherit all collection items from a parent resource unless someone added a suppress extension to it + List mergeCollectionWithSuppression(List snap, List diff, Func matchItems) where T : Element, IExtendable { var result = snap; if (!diff.IsNullOrEmpty()) { if (snap.IsNullOrEmpty()) { - result = (List)diff.DeepCopy(); + result = (List)diff.DeepCopy(); onConstraint(result); } else if (!diff.IsExactly(snap)) { - // Start with inherited mappings from snapshot - result = new List(snap.DeepCopy()); + // Start with inherited items from snapshot + result = new List(snap.DeepCopy()); - // Process each diff mapping + // Process each diff item foreach (var diffItem in diff) { - // Match by Identity and Map, not exact equality (to handle extensions) - var idx = snap.FindIndex(e => isEqualString(e.Identity, diffItem.Identity) && isEqualString(e.Map, diffItem.Map)); - ElementDefinition.MappingComponent mergedItem = null; + // Match by the provided matching function + var idx = snap.FindIndex(e => matchItems(e, diffItem)); + T mergedItem = null; if (idx < 0) { - // New mapping from differential - add it (but only if not suppressed) + // New item from differential - add it (but only if not suppressed) if (!diffItem.HasSuppressExtension()) { - mergedItem = (ElementDefinition.MappingComponent)diffItem.DeepCopy(); + mergedItem = (T)diffItem.DeepCopy(); result.Add(mergedItem); } } else { - // Matching mapping exists in snapshot - // Check if diff mapping has suppress extension + // Matching item exists in snapshot + // Check if diff item has suppress extension if (diffItem.HasSuppressExtension()) { - // Remove the inherited mapping - it's being suppressed + // Remove the inherited item - it's being suppressed result.RemoveAt(idx); continue; } @@ -554,64 +554,18 @@ List mergeCollection(List snap, List diff, Func matchIte return result; } + // Custom merge logic for mappings that respects the suppress extension + // Inherit all mapping definitions from a parent resource unless someone added a suppress extension to it + List mergeMappings(List snap, List diff) + { + return mergeCollectionWithSuppression(snap, diff, (s, d) => isEqualString(s.Identity, d.Identity) && isEqualString(s.Map, d.Map)); + } + // Custom merge logic for examples that respects the suppress extension // Inherit all example definitions from a parent resource unless someone added a suppress extension to it List mergeExamples(List snap, List diff) { - var result = snap; - if (!diff.IsNullOrEmpty()) - { - if (snap.IsNullOrEmpty()) - { - result = (List)diff.DeepCopy(); - onConstraint(result); - } - else if (!diff.IsExactly(snap)) - { - // Start with inherited examples from snapshot - result = new List(snap.DeepCopy()); - - // Process each diff example - foreach (var diffItem in diff) - { - // Match by Label, not exact equality (to handle extensions) - var idx = snap.FindIndex(e => isEqualString(e.Label, diffItem.Label)); - ElementDefinition.ExampleComponent mergedItem = null; - if (idx < 0) - { - // New example from differential - add it (but only if not suppressed) - if (!diffItem.HasSuppressExtension()) - { - mergedItem = (ElementDefinition.ExampleComponent)diffItem.DeepCopy(); - result.Add(mergedItem); - } - } - else - { - // Matching example exists in snapshot - // Check if diff example has suppress extension - if (diffItem.HasSuppressExtension()) - { - // Remove the inherited example - it's being suppressed - result.RemoveAt(idx); - continue; - } - else - { - // Merge diff with snap (normal cumulative behavior) - var snapItem = result[idx]; - mergedItem = mergeComplexAttribute(snapItem, diffItem); - result[idx] = mergedItem; - } - } - if (mergedItem != null) - { - onConstraint(mergedItem); - } - } - } - } - return result; + return mergeCollectionWithSuppression(snap, diff, (s, d) => isEqualString(s.Label, d.Label)); } From 77e3e35b828fd67698ef947c8ac93675a1705981 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 Aug 2025 19:04:17 +0000 Subject: [PATCH 60/78] Add comprehensive tests for suppress extension functionality across all FHIR versions - Added SnapshotGeneratorMappingSuppressionTest.cs for STU3, R5, and R4B - Tests validate both mapping and example suppression functionality - Ensures suppress extension works consistently across STU3, R4, R4B, R5, and R6 - All tests pass validating the implementation is complete for all supported FHIR versions Co-authored-by: alexzautke <548617+alexzautke@users.noreply.github.com> --- ...SnapshotGeneratorMappingSuppressionTest.cs | 390 ++++++++++++++++++ ...SnapshotGeneratorMappingSuppressionTest.cs | 390 ++++++++++++++++++ ...SnapshotGeneratorMappingSuppressionTest.cs | 390 ++++++++++++++++++ 3 files changed, 1170 insertions(+) create mode 100644 src/Hl7.Fhir.Specification.R4B.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs create mode 100644 src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs create mode 100644 src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs diff --git a/src/Hl7.Fhir.Specification.R4B.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs b/src/Hl7.Fhir.Specification.R4B.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs new file mode 100644 index 0000000000..881e7cdca3 --- /dev/null +++ b/src/Hl7.Fhir.Specification.R4B.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs @@ -0,0 +1,390 @@ +/* + * Copyright (c) 2018, Firely (info@fire.ly) and contributors + * See the file CONTRIBUTORS for details. + * + * This file is licensed under the BSD 3-Clause license + * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE + */ + +using Hl7.Fhir.Model; +using Hl7.Fhir.Specification.Snapshot; +using Hl7.Fhir.Specification.Source; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.Linq; +using Hl7.Fhir.Utility; + +namespace Hl7.Fhir.Specification.Tests +{ + [TestClass] + public class SnapshotGeneratorMappingSuppressionTest + { + + [TestMethod] + public async System.Threading.Tasks.Task TestMappingInheritanceWithoutSuppression() + { + // Create a base profile with a mapping that already has snapshot + var baseProfile = CreateBaseProfileWithMapping(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient" + } + } + } + } + }; + + // Create a derived profile without suppress extension + var derivedProfile = CreateDerivedProfileWithoutSuppression(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Generate snapshot + var generator = new SnapshotGenerator(mockResolver, new SnapshotGeneratorSettings()); + await generator.UpdateAsync(derivedProfile); + + // Verify that mapping is inherited + var rootElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(rootElement, "Should have Patient root element"); + Assert.IsNotNull(rootElement.Mapping, "Mapping should be inherited from base profile"); + Assert.AreEqual(1, rootElement.Mapping.Count, "Should have inherited one mapping"); + Assert.AreEqual("test-identity", rootElement.Mapping[0].Identity, "Should have inherited the correct mapping"); + } + + [TestMethod] + public async System.Threading.Tasks.Task TestMappingSuppressionWithExtension() + { + // Create a base profile with a mapping that already has snapshot + var baseProfile = CreateBaseProfileWithMapping(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient" + } + } + } + } + }; + + // Create a derived profile with suppress extension on mapping + var derivedProfile = CreateDerivedProfileWithSuppressedMapping(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Generate snapshot + var generator = new SnapshotGenerator(mockResolver, new SnapshotGeneratorSettings()); + await generator.UpdateAsync(derivedProfile); + + // Verify that mapping is NOT inherited due to suppression + var rootElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(rootElement, "Should have Patient root element"); + var inheritedMapping = rootElement.Mapping?.FirstOrDefault(m => m.Identity == "test-identity"); + Assert.IsNull(inheritedMapping, "Mapping with suppress extension should not be inherited"); + } + + private StructureDefinition CreateBaseProfileWithMapping() + { + return new StructureDefinition() + { + Type = "Patient", + BaseDefinition = ModelInfo.CanonicalUriForFhirCoreType("Patient"), + Name = "BasePatientWithMapping", + Url = @"http://example.org/fhir/StructureDefinition/BasePatientWithMapping", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Differential = new StructureDefinition.DifferentialComponent() + { + Element = new List() + { + new ElementDefinition("Patient") + { + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient" + } + } + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithoutSuppression() + { + return new StructureDefinition() + { + Type = "Patient", + BaseDefinition = @"http://example.org/fhir/StructureDefinition/BasePatientWithMapping", + Name = "DerivedPatientWithoutSuppression", + Url = @"http://example.org/fhir/StructureDefinition/DerivedPatientWithoutSuppression", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Differential = new StructureDefinition.DifferentialComponent() + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile" + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithSuppressedMapping() + { + return new StructureDefinition() + { + Type = "Patient", + BaseDefinition = @"http://example.org/fhir/StructureDefinition/BasePatientWithMapping", + Name = "DerivedPatientWithSuppressedMapping", + Url = @"http://example.org/fhir/StructureDefinition/DerivedPatientWithSuppressedMapping", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Differential = new StructureDefinition.DifferentialComponent() + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile", + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient", + Extension = new List() + { + new Extension() + { + Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, + Value = new FhirBoolean(true) + } + } + } + } + } + } + } + }; + } + [TestMethod] + public async System.Threading.Tasks.Task TestExampleInheritanceWithoutSuppression() + { + // Create a base profile with an example that already has snapshot + var baseProfile = CreateBaseProfileWithExample(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name") + } + } + } + } + }; + + // Create a derived profile without suppress extension + var derivedProfile = CreateDerivedProfileWithoutExampleSuppression(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Create snapshot generator + var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); + + // Generate snapshot for the derived profile + generator.Update(derivedProfile); + + // Assert that the derived profile inherited the example from the base + Assert.IsNotNull(derivedProfile.Snapshot); + var patientElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(patientElement); + Assert.IsNotNull(patientElement.Example); + Assert.AreEqual(1, patientElement.Example.Count); + Assert.AreEqual("test-example", patientElement.Example[0].Label); + Assert.AreEqual("Example patient name", (patientElement.Example[0].Value as FhirString)?.Value); + } + + [TestMethod] + public async System.Threading.Tasks.Task TestExampleSuppressionExtension() + { + // Create a base profile with an example that already has snapshot + var baseProfile = CreateBaseProfileWithExample(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name") + } + } + } + } + }; + + // Create a derived profile that suppresses the inherited example + var derivedProfile = CreateDerivedProfileWithExampleSuppression(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Create snapshot generator + var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); + + // Generate snapshot for the derived profile + generator.Update(derivedProfile); + + // Assert that the derived profile did NOT inherit the example (it was suppressed) + Assert.IsNotNull(derivedProfile.Snapshot); + var patientElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(patientElement); + + // The example should be absent because it was suppressed + Assert.IsTrue(patientElement.Example == null || patientElement.Example.Count == 0); + } + + private StructureDefinition CreateBaseProfileWithExample() + { + return new StructureDefinition() + { + Id = "base-patient-profile-with-example", + Url = "http://example.org/fhir/StructureDefinition/base-patient-with-example", + Name = "BasePatientProfileWithExample", + Status = PublicationStatus.Active, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + Type = "Patient", + BaseDefinition = "http://hl7.org/fhir/StructureDefinition/Patient", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Differential = new StructureDefinition.DifferentialComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Base patient profile with example", + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name") + } + } + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithoutExampleSuppression() + { + return new StructureDefinition() + { + Id = "derived-patient-profile-no-example-suppression", + Url = "http://example.org/fhir/StructureDefinition/derived-patient-no-example-suppression", + Name = "DerivedPatientProfileNoExampleSuppression", + Status = PublicationStatus.Active, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + Type = "Patient", + BaseDefinition = "http://example.org/fhir/StructureDefinition/base-patient-with-example", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Differential = new StructureDefinition.DifferentialComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile without example suppression" + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithExampleSuppression() + { + return new StructureDefinition() + { + Id = "derived-patient-profile-with-example-suppression", + Url = "http://example.org/fhir/StructureDefinition/derived-patient-with-example-suppression", + Name = "DerivedPatientProfileWithExampleSuppression", + Status = PublicationStatus.Active, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + Type = "Patient", + BaseDefinition = "http://example.org/fhir/StructureDefinition/base-patient-with-example", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Differential = new StructureDefinition.DifferentialComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile with example suppression", + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name"), + Extension = new List() + { + new Extension() + { + Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, + Value = new FhirBoolean(true) + } + } + } + } + } + } + } + }; + } + } +} \ No newline at end of file diff --git a/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs b/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs new file mode 100644 index 0000000000..881e7cdca3 --- /dev/null +++ b/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs @@ -0,0 +1,390 @@ +/* + * Copyright (c) 2018, Firely (info@fire.ly) and contributors + * See the file CONTRIBUTORS for details. + * + * This file is licensed under the BSD 3-Clause license + * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE + */ + +using Hl7.Fhir.Model; +using Hl7.Fhir.Specification.Snapshot; +using Hl7.Fhir.Specification.Source; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.Linq; +using Hl7.Fhir.Utility; + +namespace Hl7.Fhir.Specification.Tests +{ + [TestClass] + public class SnapshotGeneratorMappingSuppressionTest + { + + [TestMethod] + public async System.Threading.Tasks.Task TestMappingInheritanceWithoutSuppression() + { + // Create a base profile with a mapping that already has snapshot + var baseProfile = CreateBaseProfileWithMapping(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient" + } + } + } + } + }; + + // Create a derived profile without suppress extension + var derivedProfile = CreateDerivedProfileWithoutSuppression(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Generate snapshot + var generator = new SnapshotGenerator(mockResolver, new SnapshotGeneratorSettings()); + await generator.UpdateAsync(derivedProfile); + + // Verify that mapping is inherited + var rootElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(rootElement, "Should have Patient root element"); + Assert.IsNotNull(rootElement.Mapping, "Mapping should be inherited from base profile"); + Assert.AreEqual(1, rootElement.Mapping.Count, "Should have inherited one mapping"); + Assert.AreEqual("test-identity", rootElement.Mapping[0].Identity, "Should have inherited the correct mapping"); + } + + [TestMethod] + public async System.Threading.Tasks.Task TestMappingSuppressionWithExtension() + { + // Create a base profile with a mapping that already has snapshot + var baseProfile = CreateBaseProfileWithMapping(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient" + } + } + } + } + }; + + // Create a derived profile with suppress extension on mapping + var derivedProfile = CreateDerivedProfileWithSuppressedMapping(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Generate snapshot + var generator = new SnapshotGenerator(mockResolver, new SnapshotGeneratorSettings()); + await generator.UpdateAsync(derivedProfile); + + // Verify that mapping is NOT inherited due to suppression + var rootElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(rootElement, "Should have Patient root element"); + var inheritedMapping = rootElement.Mapping?.FirstOrDefault(m => m.Identity == "test-identity"); + Assert.IsNull(inheritedMapping, "Mapping with suppress extension should not be inherited"); + } + + private StructureDefinition CreateBaseProfileWithMapping() + { + return new StructureDefinition() + { + Type = "Patient", + BaseDefinition = ModelInfo.CanonicalUriForFhirCoreType("Patient"), + Name = "BasePatientWithMapping", + Url = @"http://example.org/fhir/StructureDefinition/BasePatientWithMapping", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Differential = new StructureDefinition.DifferentialComponent() + { + Element = new List() + { + new ElementDefinition("Patient") + { + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient" + } + } + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithoutSuppression() + { + return new StructureDefinition() + { + Type = "Patient", + BaseDefinition = @"http://example.org/fhir/StructureDefinition/BasePatientWithMapping", + Name = "DerivedPatientWithoutSuppression", + Url = @"http://example.org/fhir/StructureDefinition/DerivedPatientWithoutSuppression", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Differential = new StructureDefinition.DifferentialComponent() + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile" + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithSuppressedMapping() + { + return new StructureDefinition() + { + Type = "Patient", + BaseDefinition = @"http://example.org/fhir/StructureDefinition/BasePatientWithMapping", + Name = "DerivedPatientWithSuppressedMapping", + Url = @"http://example.org/fhir/StructureDefinition/DerivedPatientWithSuppressedMapping", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Differential = new StructureDefinition.DifferentialComponent() + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile", + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient", + Extension = new List() + { + new Extension() + { + Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, + Value = new FhirBoolean(true) + } + } + } + } + } + } + } + }; + } + [TestMethod] + public async System.Threading.Tasks.Task TestExampleInheritanceWithoutSuppression() + { + // Create a base profile with an example that already has snapshot + var baseProfile = CreateBaseProfileWithExample(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name") + } + } + } + } + }; + + // Create a derived profile without suppress extension + var derivedProfile = CreateDerivedProfileWithoutExampleSuppression(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Create snapshot generator + var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); + + // Generate snapshot for the derived profile + generator.Update(derivedProfile); + + // Assert that the derived profile inherited the example from the base + Assert.IsNotNull(derivedProfile.Snapshot); + var patientElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(patientElement); + Assert.IsNotNull(patientElement.Example); + Assert.AreEqual(1, patientElement.Example.Count); + Assert.AreEqual("test-example", patientElement.Example[0].Label); + Assert.AreEqual("Example patient name", (patientElement.Example[0].Value as FhirString)?.Value); + } + + [TestMethod] + public async System.Threading.Tasks.Task TestExampleSuppressionExtension() + { + // Create a base profile with an example that already has snapshot + var baseProfile = CreateBaseProfileWithExample(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name") + } + } + } + } + }; + + // Create a derived profile that suppresses the inherited example + var derivedProfile = CreateDerivedProfileWithExampleSuppression(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Create snapshot generator + var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); + + // Generate snapshot for the derived profile + generator.Update(derivedProfile); + + // Assert that the derived profile did NOT inherit the example (it was suppressed) + Assert.IsNotNull(derivedProfile.Snapshot); + var patientElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(patientElement); + + // The example should be absent because it was suppressed + Assert.IsTrue(patientElement.Example == null || patientElement.Example.Count == 0); + } + + private StructureDefinition CreateBaseProfileWithExample() + { + return new StructureDefinition() + { + Id = "base-patient-profile-with-example", + Url = "http://example.org/fhir/StructureDefinition/base-patient-with-example", + Name = "BasePatientProfileWithExample", + Status = PublicationStatus.Active, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + Type = "Patient", + BaseDefinition = "http://hl7.org/fhir/StructureDefinition/Patient", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Differential = new StructureDefinition.DifferentialComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Base patient profile with example", + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name") + } + } + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithoutExampleSuppression() + { + return new StructureDefinition() + { + Id = "derived-patient-profile-no-example-suppression", + Url = "http://example.org/fhir/StructureDefinition/derived-patient-no-example-suppression", + Name = "DerivedPatientProfileNoExampleSuppression", + Status = PublicationStatus.Active, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + Type = "Patient", + BaseDefinition = "http://example.org/fhir/StructureDefinition/base-patient-with-example", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Differential = new StructureDefinition.DifferentialComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile without example suppression" + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithExampleSuppression() + { + return new StructureDefinition() + { + Id = "derived-patient-profile-with-example-suppression", + Url = "http://example.org/fhir/StructureDefinition/derived-patient-with-example-suppression", + Name = "DerivedPatientProfileWithExampleSuppression", + Status = PublicationStatus.Active, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + Type = "Patient", + BaseDefinition = "http://example.org/fhir/StructureDefinition/base-patient-with-example", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Differential = new StructureDefinition.DifferentialComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile with example suppression", + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name"), + Extension = new List() + { + new Extension() + { + Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, + Value = new FhirBoolean(true) + } + } + } + } + } + } + } + }; + } + } +} \ No newline at end of file diff --git a/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs b/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs new file mode 100644 index 0000000000..881e7cdca3 --- /dev/null +++ b/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs @@ -0,0 +1,390 @@ +/* + * Copyright (c) 2018, Firely (info@fire.ly) and contributors + * See the file CONTRIBUTORS for details. + * + * This file is licensed under the BSD 3-Clause license + * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE + */ + +using Hl7.Fhir.Model; +using Hl7.Fhir.Specification.Snapshot; +using Hl7.Fhir.Specification.Source; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; +using System.Linq; +using Hl7.Fhir.Utility; + +namespace Hl7.Fhir.Specification.Tests +{ + [TestClass] + public class SnapshotGeneratorMappingSuppressionTest + { + + [TestMethod] + public async System.Threading.Tasks.Task TestMappingInheritanceWithoutSuppression() + { + // Create a base profile with a mapping that already has snapshot + var baseProfile = CreateBaseProfileWithMapping(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient" + } + } + } + } + }; + + // Create a derived profile without suppress extension + var derivedProfile = CreateDerivedProfileWithoutSuppression(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Generate snapshot + var generator = new SnapshotGenerator(mockResolver, new SnapshotGeneratorSettings()); + await generator.UpdateAsync(derivedProfile); + + // Verify that mapping is inherited + var rootElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(rootElement, "Should have Patient root element"); + Assert.IsNotNull(rootElement.Mapping, "Mapping should be inherited from base profile"); + Assert.AreEqual(1, rootElement.Mapping.Count, "Should have inherited one mapping"); + Assert.AreEqual("test-identity", rootElement.Mapping[0].Identity, "Should have inherited the correct mapping"); + } + + [TestMethod] + public async System.Threading.Tasks.Task TestMappingSuppressionWithExtension() + { + // Create a base profile with a mapping that already has snapshot + var baseProfile = CreateBaseProfileWithMapping(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient" + } + } + } + } + }; + + // Create a derived profile with suppress extension on mapping + var derivedProfile = CreateDerivedProfileWithSuppressedMapping(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Generate snapshot + var generator = new SnapshotGenerator(mockResolver, new SnapshotGeneratorSettings()); + await generator.UpdateAsync(derivedProfile); + + // Verify that mapping is NOT inherited due to suppression + var rootElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(rootElement, "Should have Patient root element"); + var inheritedMapping = rootElement.Mapping?.FirstOrDefault(m => m.Identity == "test-identity"); + Assert.IsNull(inheritedMapping, "Mapping with suppress extension should not be inherited"); + } + + private StructureDefinition CreateBaseProfileWithMapping() + { + return new StructureDefinition() + { + Type = "Patient", + BaseDefinition = ModelInfo.CanonicalUriForFhirCoreType("Patient"), + Name = "BasePatientWithMapping", + Url = @"http://example.org/fhir/StructureDefinition/BasePatientWithMapping", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Differential = new StructureDefinition.DifferentialComponent() + { + Element = new List() + { + new ElementDefinition("Patient") + { + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient" + } + } + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithoutSuppression() + { + return new StructureDefinition() + { + Type = "Patient", + BaseDefinition = @"http://example.org/fhir/StructureDefinition/BasePatientWithMapping", + Name = "DerivedPatientWithoutSuppression", + Url = @"http://example.org/fhir/StructureDefinition/DerivedPatientWithoutSuppression", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Differential = new StructureDefinition.DifferentialComponent() + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile" + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithSuppressedMapping() + { + return new StructureDefinition() + { + Type = "Patient", + BaseDefinition = @"http://example.org/fhir/StructureDefinition/BasePatientWithMapping", + Name = "DerivedPatientWithSuppressedMapping", + Url = @"http://example.org/fhir/StructureDefinition/DerivedPatientWithSuppressedMapping", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Differential = new StructureDefinition.DifferentialComponent() + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile", + Mapping = new List() + { + new ElementDefinition.MappingComponent() + { + Identity = "test-identity", + Map = "TestMapping.Patient", + Extension = new List() + { + new Extension() + { + Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, + Value = new FhirBoolean(true) + } + } + } + } + } + } + } + }; + } + [TestMethod] + public async System.Threading.Tasks.Task TestExampleInheritanceWithoutSuppression() + { + // Create a base profile with an example that already has snapshot + var baseProfile = CreateBaseProfileWithExample(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name") + } + } + } + } + }; + + // Create a derived profile without suppress extension + var derivedProfile = CreateDerivedProfileWithoutExampleSuppression(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Create snapshot generator + var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); + + // Generate snapshot for the derived profile + generator.Update(derivedProfile); + + // Assert that the derived profile inherited the example from the base + Assert.IsNotNull(derivedProfile.Snapshot); + var patientElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(patientElement); + Assert.IsNotNull(patientElement.Example); + Assert.AreEqual(1, patientElement.Example.Count); + Assert.AreEqual("test-example", patientElement.Example[0].Label); + Assert.AreEqual("Example patient name", (patientElement.Example[0].Value as FhirString)?.Value); + } + + [TestMethod] + public async System.Threading.Tasks.Task TestExampleSuppressionExtension() + { + // Create a base profile with an example that already has snapshot + var baseProfile = CreateBaseProfileWithExample(); + baseProfile.Snapshot = new StructureDefinition.SnapshotComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name") + } + } + } + } + }; + + // Create a derived profile that suppresses the inherited example + var derivedProfile = CreateDerivedProfileWithExampleSuppression(); + + // Mock resolver to return base profile when requested + var mockResolver = new InMemoryResourceResolver(); + mockResolver.Add(baseProfile); + + // Create snapshot generator + var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); + + // Generate snapshot for the derived profile + generator.Update(derivedProfile); + + // Assert that the derived profile did NOT inherit the example (it was suppressed) + Assert.IsNotNull(derivedProfile.Snapshot); + var patientElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); + Assert.IsNotNull(patientElement); + + // The example should be absent because it was suppressed + Assert.IsTrue(patientElement.Example == null || patientElement.Example.Count == 0); + } + + private StructureDefinition CreateBaseProfileWithExample() + { + return new StructureDefinition() + { + Id = "base-patient-profile-with-example", + Url = "http://example.org/fhir/StructureDefinition/base-patient-with-example", + Name = "BasePatientProfileWithExample", + Status = PublicationStatus.Active, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + Type = "Patient", + BaseDefinition = "http://hl7.org/fhir/StructureDefinition/Patient", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Differential = new StructureDefinition.DifferentialComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Base patient profile with example", + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name") + } + } + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithoutExampleSuppression() + { + return new StructureDefinition() + { + Id = "derived-patient-profile-no-example-suppression", + Url = "http://example.org/fhir/StructureDefinition/derived-patient-no-example-suppression", + Name = "DerivedPatientProfileNoExampleSuppression", + Status = PublicationStatus.Active, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + Type = "Patient", + BaseDefinition = "http://example.org/fhir/StructureDefinition/base-patient-with-example", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Differential = new StructureDefinition.DifferentialComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile without example suppression" + } + } + } + }; + } + + private StructureDefinition CreateDerivedProfileWithExampleSuppression() + { + return new StructureDefinition() + { + Id = "derived-patient-profile-with-example-suppression", + Url = "http://example.org/fhir/StructureDefinition/derived-patient-with-example-suppression", + Name = "DerivedPatientProfileWithExampleSuppression", + Status = PublicationStatus.Active, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + Type = "Patient", + BaseDefinition = "http://example.org/fhir/StructureDefinition/base-patient-with-example", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Differential = new StructureDefinition.DifferentialComponent + { + Element = new List() + { + new ElementDefinition("Patient") + { + Short = "Derived patient profile with example suppression", + Example = new List() + { + new ElementDefinition.ExampleComponent() + { + Label = "test-example", + Value = new FhirString("Example patient name"), + Extension = new List() + { + new Extension() + { + Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, + Value = new FhirBoolean(true) + } + } + } + } + } + } + } + }; + } + } +} \ No newline at end of file From 10d4d3a29302d7168217ae46793e2e5bcebfef57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 Aug 2025 19:09:26 +0000 Subject: [PATCH 61/78] Initial plan From ba2d8636e36005e556423ab9c5f3125413527ce8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 Aug 2025 19:23:43 +0000 Subject: [PATCH 62/78] Fix snapshot generation for elements with extensions but no value Co-authored-by: alexzautke <548617+alexzautke@users.noreply.github.com> --- .../Snapshot/ElementDefnMerger.cs | 8 +- .../Snapshot/ElementDefnMerger.cs | 7 ++ .../Snapshot/SnapshotGeneratorTest.cs | 79 +++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs index 593fed0cda..ab4da4b97e 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs @@ -669,7 +669,13 @@ T mergePrimitiveElement(T snap, T diff, bool allowAppend = false) where T : P } else { - result.ObjectValue = diffValue; + // [FIX] Issue #3211: When diff has extensions but no ObjectValue (diffValue is null), + // preserve the snap ObjectValue instead of overwriting it with null + if (diffValue != null) + { + result.ObjectValue = diffValue; + } + // If diffValue is null, keep result.ObjectValue as is (from snap) } // Also merge element id and extensions on primitives result.ElementId = mergeString(snap.ElementId, diff.ElementId); diff --git a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs index bcd81ac848..a03779fd2f 100644 --- a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs @@ -230,6 +230,13 @@ T mergePrimitiveAttribute(T snap, T diff, bool allowAppend = false) where T : { var result = (T)diff.DeepCopy(); + // [FIX] Issue #3211: When diff has extensions but no ObjectValue, + // preserve the snap ObjectValue instead of losing it + if (diff.ObjectValue == null && snap != null && snap.ObjectValue != null) + { + result.ObjectValue = snap.ObjectValue; + } + if (allowAppend && diff.ObjectValue is string) { var diffText = diff.ObjectValue as string; diff --git a/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs b/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs index 28cec05a3f..dbf79f11a5 100644 --- a/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs +++ b/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs @@ -10362,5 +10362,84 @@ private StructureDefinition createR5StructureDefinition() } }; } + + [TestMethod] + public async Tasks.Task TestElementDefinitionExtensionWithoutValuePreservesBaseValue() + { + // Test for issue #3211 + // When a derived profile has an element with extension but no value, + // the snapshot generator should preserve the base value and merge the extension + + var derivedProfile = new StructureDefinition() + { + Url = "http://example.org/derived-patient", + Name = "DerivedPatient", + Status = PublicationStatus.Active, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + Type = FHIRAllTypes.Patient.GetLiteral(), + BaseDefinition = ModelInfo.CanonicalUriForFhirCoreType(FHIRAllTypes.Patient), + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Differential = new StructureDefinition.DifferentialComponent() + { + Element = new List() + { + new ElementDefinition("Patient.name") + { + // No Definition value, but has extension on _definition + DefinitionElement = new Markdown() + { + Extension = new List() + { + new Extension() + { + Url = "http://hl7.org/fhir/StructureDefinition/translation", + Extension = new List() + { + new Extension("lang", new Code("fr")), + new Extension("content", new Markdown("Une nomme associe de la individuelle")) + } + } + } + } + } + } + } + }; + + // Create resolver + var resolver = new InMemoryResourceResolver(derivedProfile); + var multiResolver = new MultiResolver(_standardFhirSource, resolver); + _generator = new SnapshotGenerator(multiResolver, _settings); + + // Generate snapshot for derived profile + await _generator.UpdateAsync(derivedProfile); + Assert.IsNotNull(derivedProfile.Snapshot?.Element); + + // Find Patient.name element in snapshot + var nameElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient.name"); + Assert.IsNotNull(nameElement, "Patient.name element not found in snapshot"); + + // Should have the base definition value preserved + Assert.IsNotNull(nameElement.Definition, "Definition should not be null - base value should be preserved"); + Assert.IsTrue(nameElement.Definition.Contains("name"), "Definition should contain base content about name"); + + // Should also have the extension from differential + Assert.IsNotNull(nameElement.DefinitionElement?.Extension, "DefinitionElement.Extension should not be null"); + Assert.AreEqual(1, nameElement.DefinitionElement.Extension.Count, "Should have exactly one extension"); + + var translationExt = nameElement.DefinitionElement.Extension.First(); + Assert.AreEqual("http://hl7.org/fhir/StructureDefinition/translation", translationExt.Url); + Assert.IsNotNull(translationExt.Extension); + Assert.AreEqual(2, translationExt.Extension.Count); + + var langExt = translationExt.Extension.FirstOrDefault(e => e.Url == "lang"); + Assert.IsNotNull(langExt); + Assert.AreEqual("fr", (langExt.Value as Code)?.Value); + + var contentExt = translationExt.Extension.FirstOrDefault(e => e.Url == "content"); + Assert.IsNotNull(contentExt); + Assert.AreEqual("Une nomme associe de la individuelle", (contentExt.Value as Markdown)?.Value); + } } } \ No newline at end of file From 866e71c1d388ea4f36ddbaafedb6479c26b30cb3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 Aug 2025 20:01:40 +0000 Subject: [PATCH 63/78] Apply Java validator logic for primitive element merging Co-authored-by: alexzautke <548617+alexzautke@users.noreply.github.com> --- .../Snapshot/ElementDefnMerger.cs | 128 +++++++++++++++-- .../Snapshot/ElementDefnMerger.cs | 131 ++++++++++++++++-- 2 files changed, 243 insertions(+), 16 deletions(-) diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs index ab4da4b97e..18e0b3e46f 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs @@ -669,23 +669,135 @@ T mergePrimitiveElement(T snap, T diff, bool allowAppend = false) where T : P } else { - // [FIX] Issue #3211: When diff has extensions but no ObjectValue (diffValue is null), - // preserve the snap ObjectValue instead of overwriting it with null - if (diffValue != null) - { - result.ObjectValue = diffValue; - } - // If diffValue is null, keep result.ObjectValue as is (from snap) + // Apply Java validator logic for merging primitive elements + result = mergePrimitiveElementValue(result, diff); } // Also merge element id and extensions on primitives result.ElementId = mergeString(snap.ElementId, diff.ElementId); - result.Extension = mergeExtensions(snap.Extension, diff.Extension); onConstraint(result); } } return result; } + /// + /// Merge primitive element values following Java validator logic. + /// Based on Java FHIR validator's mergeStrings method. + /// + T mergePrimitiveElementValue(T dest, T source) where T : PrimitiveType + { + var result = dest; // Already a copy of snap (dest in Java) + + // If result doesn't have value and source (diff) has value, use source value + if (result.IsNullOrEmpty() && !source.IsNullOrEmpty()) + { + result.ObjectValue = source.ObjectValue; + } + // If both have values and result starts with "...", append derived text + else if (!result.IsNullOrEmpty() && !source.IsNullOrEmpty() && + result.ObjectValue is string resultText && resultText.StartsWith("...")) + { + result.ObjectValue = appendDerivedTextToBase(resultText, source.ObjectValue as string); + } + // Note: If source has no value, we preserve result's value (don't overwrite with null) + + // Merge extensions with special handling for translation extensions + result.Extension = mergePrimitiveExtensions(result.Extension, source.Extension); + + return result; + } + + /// + /// Append derived text to base text, handling "..." prefix + /// Based on Java Utilities.appendDerivedTextToBase + /// + string appendDerivedTextToBase(string baseText, string derivedText) + { + if (string.IsNullOrEmpty(derivedText)) + { + return baseText; + } + + if (string.IsNullOrEmpty(baseText)) + { + return derivedText; + } + + // Remove "..." prefix from base and append derived to it + if (baseText.StartsWith("...")) + { + var cleanBaseText = baseText.Substring(3); + return string.IsNullOrEmpty(cleanBaseText) ? derivedText : derivedText + "\r\n" + cleanBaseText; + } + + return baseText; + } + + /// + /// Merge extensions on primitive elements with special handling for translation extensions + /// + List mergePrimitiveExtensions(List destExtensions, List sourceExtensions) + { + var result = destExtensions?.ToList() ?? new List(); + + if (sourceExtensions?.Any() == true) + { + foreach (var sourceExtension in sourceExtensions) + { + var matchingExtension = findMatchingExtension(result, sourceExtension); + if (matchingExtension == null) + { + result.Add((Extension)sourceExtension.DeepCopy()); + } + else + { + // Update matching extension value + matchingExtension.Value = (DataType)sourceExtension.Value?.DeepCopy(); + } + } + } + + return result; + } + + /// + /// Find matching extension in destination list, with special logic for translation extensions + /// + Extension findMatchingExtension(List destExtensions, Extension extensionToMatch) + { + const string EXT_TRANSLATION = "http://hl7.org/fhir/StructureDefinition/translation"; + + foreach (var elementExtension in destExtensions.Where(e => e.Url == extensionToMatch.Url)) + { + if (EXT_TRANSLATION.Equals(elementExtension.Url)) + { + // For translation extensions, match by language code + var sourceLang = getExtensionString(extensionToMatch, "lang"); + var destLang = getExtensionString(elementExtension, "lang"); + if (string.Equals(sourceLang, destLang, StringComparison.Ordinal)) + { + return elementExtension; + } + } + else + { + // For other extensions, first match by URL is sufficient + return elementExtension; + } + } + + return null; + } + + /// + /// Helper to get extension string value by URL + /// + string getExtensionString(Extension extension, string url) + { + var subExtension = extension.Extension?.FirstOrDefault(e => e.Url == url); + return (subExtension?.Value as PrimitiveType)?.ObjectValue as string; + } + static string mergeId(ElementDefinition snap, ElementDefinition diff, bool mergeElementId) { // Note: Element.ElementId is a simple string property (not Element) diff --git a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs index a03779fd2f..2ff6207323 100644 --- a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs @@ -230,13 +230,6 @@ T mergePrimitiveAttribute(T snap, T diff, bool allowAppend = false) where T : { var result = (T)diff.DeepCopy(); - // [FIX] Issue #3211: When diff has extensions but no ObjectValue, - // preserve the snap ObjectValue instead of losing it - if (diff.ObjectValue == null && snap != null && snap.ObjectValue != null) - { - result.ObjectValue = snap.ObjectValue; - } - if (allowAppend && diff.ObjectValue is string) { var diffText = diff.ObjectValue as string; @@ -253,10 +246,14 @@ T mergePrimitiveAttribute(T snap, T diff, bool allowAppend = false) where T : result.ObjectValue = diffText; } + else + { + // Apply Java validator logic for merging primitive elements + result = mergePrimitiveElementValue(result, diff, snap); + } // Also merge element id and extensions on primitives // [Backported from R4] result.ElementId = mergeString(snap?.ElementId, diff.ElementId); - result.Extension = mergeExtensions(snap?.Extension, diff.Extension); onConstraint(result); return result; } @@ -264,6 +261,124 @@ T mergePrimitiveAttribute(T snap, T diff, bool allowAppend = false) where T : return snap; } + /// + /// Merge primitive element values following Java validator logic. + /// Based on Java FHIR validator's mergeStrings method. + /// + T mergePrimitiveElementValue(T dest, T source, T originalSnap) where T : PrimitiveType + { + var result = dest; // Already a copy of source (diff) + + // Apply Java validator logic: + // If result (diff copy) doesn't have value and originalSnap has value, use snap value + if (result.IsNullOrEmpty() && !originalSnap.IsNullOrEmpty()) + { + result.ObjectValue = originalSnap.ObjectValue; + } + // If both have values and result starts with "...", append derived text + else if (!result.IsNullOrEmpty() && !originalSnap.IsNullOrEmpty() && + result.ObjectValue is string resultText && resultText.StartsWith("...")) + { + result.ObjectValue = appendDerivedTextToBase(resultText, originalSnap.ObjectValue as string); + } + // Note: If source has no value, we preserve original snap's value + + // Merge extensions with special handling for translation extensions + result.Extension = mergePrimitiveExtensions(originalSnap?.Extension, source.Extension); + + return result; + } + + /// + /// Append derived text to base text, handling "..." prefix + /// + string appendDerivedTextToBase(string baseText, string derivedText) + { + if (string.IsNullOrEmpty(derivedText)) + { + return baseText; + } + + if (string.IsNullOrEmpty(baseText)) + { + return derivedText; + } + + // Remove "..." prefix and append to base + if (baseText.StartsWith("...")) + { + var cleanBaseText = baseText.Substring(3); + return string.IsNullOrEmpty(cleanBaseText) ? derivedText : derivedText + "\r\n" + cleanBaseText; + } + + return baseText; + } + + /// + /// Merge extensions on primitive elements with special handling for translation extensions + /// + List mergePrimitiveExtensions(List destExtensions, List sourceExtensions) + { + var result = destExtensions?.ToList() ?? new List(); + + if (sourceExtensions?.Any() == true) + { + foreach (var sourceExtension in sourceExtensions) + { + var matchingExtension = findMatchingExtension(result, sourceExtension); + if (matchingExtension == null) + { + result.Add((Extension)sourceExtension.DeepCopy()); + } + else + { + // Update matching extension value + matchingExtension.Value = (DataType)sourceExtension.Value?.DeepCopy(); + } + } + } + + return result; + } + + /// + /// Find matching extension in destination list, with special logic for translation extensions + /// + Extension findMatchingExtension(List destExtensions, Extension extensionToMatch) + { + const string EXT_TRANSLATION = "http://hl7.org/fhir/StructureDefinition/translation"; + + foreach (var elementExtension in destExtensions.Where(e => e.Url == extensionToMatch.Url)) + { + if (EXT_TRANSLATION.Equals(elementExtension.Url)) + { + // For translation extensions, match by language code + var sourceLang = getExtensionString(extensionToMatch, "lang"); + var destLang = getExtensionString(elementExtension, "lang"); + if (string.Equals(sourceLang, destLang, StringComparison.Ordinal)) + { + return elementExtension; + } + } + else + { + // For other extensions, first match by URL is sufficient + return elementExtension; + } + } + + return null; + } + + /// + /// Helper to get extension string value by URL + /// + string getExtensionString(Extension extension, string url) + { + var subExtension = extension.Extension?.FirstOrDefault(e => e.Url == url); + return (subExtension?.Value as PrimitiveType)?.ObjectValue as string; + } + /// /// Merge the Min element of the differential into the snapshot. The most constrained will win: so the maximum of both values. /// From 6ffae553f3403cdabf6b17389b4c7d694b5165ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 Aug 2025 20:28:20 +0000 Subject: [PATCH 64/78] Integrate Java validator logic without duplicating existing string merging Co-authored-by: alexzautke <548617+alexzautke@users.noreply.github.com> --- .../Snapshot/ElementDefnMerger.cs | 192 +++++++---------- .../Snapshot/ElementDefnMerger.cs | 194 +++++++----------- 2 files changed, 148 insertions(+), 238 deletions(-) diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs index 18e0b3e46f..1ca00f9dc4 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs @@ -391,6 +391,74 @@ List mergeCanonicals(List snap, List diff) List mergeExtensions(List snap, List diff) => mergeCollection(snap, diff, matchExtensions); + // Enhanced extension merging with special handling for translation extensions + List mergeExtensionsWithTranslationSupport(List snap, List diff) + { + var result = snap; + if (!diff.IsNullOrEmpty()) + { + if (snap.IsNullOrEmpty()) + { + result = (List)diff.DeepCopy(); + onConstraint(result); + } + else if (!diff.IsExactly(snap)) + { + result = new List(snap.DeepCopy()); + // Properly merge matching collection items with translation support + foreach (var diffItem in diff) + { + var idx = snap.FindIndex(e => matchExtensionsWithTranslation(e, diffItem)); + Extension mergedItem; + if (idx < 0) + { + // No match; add diff item + mergedItem = (Extension)diffItem.DeepCopy(); + result.Add(mergedItem); + } + else + { + // Match; merge diff with snap + var snapItem = result[idx]; + mergedItem = mergeComplexAttribute(snapItem, diffItem); + result[idx] = mergedItem; + } + onConstraint(mergedItem); + } + } + } + return result; + } + + // Enhanced extension matching with special logic for translation extensions + static bool matchExtensionsWithTranslation(Extension x, Extension y) + { + if (x is null || y is null || !IsEqualUri(x.Url, y.Url)) + return false; + + const string EXT_TRANSLATION = "http://hl7.org/fhir/StructureDefinition/translation"; + + if (EXT_TRANSLATION.Equals(x.Url)) + { + // For translation extensions, match by language code + var xLang = getExtensionString(x, "lang"); + var yLang = getExtensionString(y, "lang"); + return IsEqualString(xLang, yLang); + } + + // For other extensions, URL match is sufficient + return true; + } + + /// + /// Helper to get extension string value by URL + /// + static string getExtensionString(Extension extension, string url) + { + var subExtension = extension.Extension?.FirstOrDefault(e => e.Url == url); + return (subExtension?.Value as PrimitiveType)?.ObjectValue as string; + } + List mergeConstraints( List snap, List diff, @@ -669,134 +737,22 @@ T mergePrimitiveElement(T snap, T diff, bool allowAppend = false) where T : P } else { - // Apply Java validator logic for merging primitive elements - result = mergePrimitiveElementValue(result, diff); + // Only overwrite snap value if diff actually has a value (Java validator logic) + if (diffValue != null) + { + result.ObjectValue = diffValue; + } } // Also merge element id and extensions on primitives result.ElementId = mergeString(snap.ElementId, diff.ElementId); + result.Extension = mergeExtensionsWithTranslationSupport(snap.Extension, diff.Extension); onConstraint(result); } } return result; } - /// - /// Merge primitive element values following Java validator logic. - /// Based on Java FHIR validator's mergeStrings method. - /// - T mergePrimitiveElementValue(T dest, T source) where T : PrimitiveType - { - var result = dest; // Already a copy of snap (dest in Java) - - // If result doesn't have value and source (diff) has value, use source value - if (result.IsNullOrEmpty() && !source.IsNullOrEmpty()) - { - result.ObjectValue = source.ObjectValue; - } - // If both have values and result starts with "...", append derived text - else if (!result.IsNullOrEmpty() && !source.IsNullOrEmpty() && - result.ObjectValue is string resultText && resultText.StartsWith("...")) - { - result.ObjectValue = appendDerivedTextToBase(resultText, source.ObjectValue as string); - } - // Note: If source has no value, we preserve result's value (don't overwrite with null) - - // Merge extensions with special handling for translation extensions - result.Extension = mergePrimitiveExtensions(result.Extension, source.Extension); - return result; - } - - /// - /// Append derived text to base text, handling "..." prefix - /// Based on Java Utilities.appendDerivedTextToBase - /// - string appendDerivedTextToBase(string baseText, string derivedText) - { - if (string.IsNullOrEmpty(derivedText)) - { - return baseText; - } - - if (string.IsNullOrEmpty(baseText)) - { - return derivedText; - } - - // Remove "..." prefix from base and append derived to it - if (baseText.StartsWith("...")) - { - var cleanBaseText = baseText.Substring(3); - return string.IsNullOrEmpty(cleanBaseText) ? derivedText : derivedText + "\r\n" + cleanBaseText; - } - - return baseText; - } - - /// - /// Merge extensions on primitive elements with special handling for translation extensions - /// - List mergePrimitiveExtensions(List destExtensions, List sourceExtensions) - { - var result = destExtensions?.ToList() ?? new List(); - - if (sourceExtensions?.Any() == true) - { - foreach (var sourceExtension in sourceExtensions) - { - var matchingExtension = findMatchingExtension(result, sourceExtension); - if (matchingExtension == null) - { - result.Add((Extension)sourceExtension.DeepCopy()); - } - else - { - // Update matching extension value - matchingExtension.Value = (DataType)sourceExtension.Value?.DeepCopy(); - } - } - } - - return result; - } - - /// - /// Find matching extension in destination list, with special logic for translation extensions - /// - Extension findMatchingExtension(List destExtensions, Extension extensionToMatch) - { - const string EXT_TRANSLATION = "http://hl7.org/fhir/StructureDefinition/translation"; - - foreach (var elementExtension in destExtensions.Where(e => e.Url == extensionToMatch.Url)) - { - if (EXT_TRANSLATION.Equals(elementExtension.Url)) - { - // For translation extensions, match by language code - var sourceLang = getExtensionString(extensionToMatch, "lang"); - var destLang = getExtensionString(elementExtension, "lang"); - if (string.Equals(sourceLang, destLang, StringComparison.Ordinal)) - { - return elementExtension; - } - } - else - { - // For other extensions, first match by URL is sufficient - return elementExtension; - } - } - - return null; - } - - /// - /// Helper to get extension string value by URL - /// - string getExtensionString(Extension extension, string url) - { - var subExtension = extension.Extension?.FirstOrDefault(e => e.Url == url); - return (subExtension?.Value as PrimitiveType)?.ObjectValue as string; - } static string mergeId(ElementDefinition snap, ElementDefinition diff, bool mergeElementId) { diff --git a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs index 2ff6207323..06d4a615e8 100644 --- a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs @@ -248,12 +248,16 @@ T mergePrimitiveAttribute(T snap, T diff, bool allowAppend = false) where T : } else { - // Apply Java validator logic for merging primitive elements - result = mergePrimitiveElementValue(result, diff, snap); + // Only overwrite snap value if diff actually has a value (Java validator logic) + if (diff.ObjectValue != null) + { + result.ObjectValue = diff.ObjectValue; + } } // Also merge element id and extensions on primitives // [Backported from R4] result.ElementId = mergeString(snap?.ElementId, diff.ElementId); + result.Extension = mergeExtensionsWithTranslationSupport(snap?.Extension, diff.Extension); onConstraint(result); return result; } @@ -261,124 +265,6 @@ T mergePrimitiveAttribute(T snap, T diff, bool allowAppend = false) where T : return snap; } - /// - /// Merge primitive element values following Java validator logic. - /// Based on Java FHIR validator's mergeStrings method. - /// - T mergePrimitiveElementValue(T dest, T source, T originalSnap) where T : PrimitiveType - { - var result = dest; // Already a copy of source (diff) - - // Apply Java validator logic: - // If result (diff copy) doesn't have value and originalSnap has value, use snap value - if (result.IsNullOrEmpty() && !originalSnap.IsNullOrEmpty()) - { - result.ObjectValue = originalSnap.ObjectValue; - } - // If both have values and result starts with "...", append derived text - else if (!result.IsNullOrEmpty() && !originalSnap.IsNullOrEmpty() && - result.ObjectValue is string resultText && resultText.StartsWith("...")) - { - result.ObjectValue = appendDerivedTextToBase(resultText, originalSnap.ObjectValue as string); - } - // Note: If source has no value, we preserve original snap's value - - // Merge extensions with special handling for translation extensions - result.Extension = mergePrimitiveExtensions(originalSnap?.Extension, source.Extension); - - return result; - } - - /// - /// Append derived text to base text, handling "..." prefix - /// - string appendDerivedTextToBase(string baseText, string derivedText) - { - if (string.IsNullOrEmpty(derivedText)) - { - return baseText; - } - - if (string.IsNullOrEmpty(baseText)) - { - return derivedText; - } - - // Remove "..." prefix and append to base - if (baseText.StartsWith("...")) - { - var cleanBaseText = baseText.Substring(3); - return string.IsNullOrEmpty(cleanBaseText) ? derivedText : derivedText + "\r\n" + cleanBaseText; - } - - return baseText; - } - - /// - /// Merge extensions on primitive elements with special handling for translation extensions - /// - List mergePrimitiveExtensions(List destExtensions, List sourceExtensions) - { - var result = destExtensions?.ToList() ?? new List(); - - if (sourceExtensions?.Any() == true) - { - foreach (var sourceExtension in sourceExtensions) - { - var matchingExtension = findMatchingExtension(result, sourceExtension); - if (matchingExtension == null) - { - result.Add((Extension)sourceExtension.DeepCopy()); - } - else - { - // Update matching extension value - matchingExtension.Value = (DataType)sourceExtension.Value?.DeepCopy(); - } - } - } - - return result; - } - - /// - /// Find matching extension in destination list, with special logic for translation extensions - /// - Extension findMatchingExtension(List destExtensions, Extension extensionToMatch) - { - const string EXT_TRANSLATION = "http://hl7.org/fhir/StructureDefinition/translation"; - - foreach (var elementExtension in destExtensions.Where(e => e.Url == extensionToMatch.Url)) - { - if (EXT_TRANSLATION.Equals(elementExtension.Url)) - { - // For translation extensions, match by language code - var sourceLang = getExtensionString(extensionToMatch, "lang"); - var destLang = getExtensionString(elementExtension, "lang"); - if (string.Equals(sourceLang, destLang, StringComparison.Ordinal)) - { - return elementExtension; - } - } - else - { - // For other extensions, first match by URL is sufficient - return elementExtension; - } - } - - return null; - } - - /// - /// Helper to get extension string value by URL - /// - string getExtensionString(Extension extension, string url) - { - var subExtension = extension.Extension?.FirstOrDefault(e => e.Url == url); - return (subExtension?.Value as PrimitiveType)?.ObjectValue as string; - } - /// /// Merge the Min element of the differential into the snapshot. The most constrained will win: so the maximum of both values. /// @@ -690,6 +576,74 @@ static bool isEqualCoding(Coding c, Coding d) static bool matchExtensions(Extension x, Extension y) => !(x is null) && !(y is null) && (x.Url == y.Url); + // Enhanced extension merging with special handling for translation extensions + List mergeExtensionsWithTranslationSupport(List snap, List diff) + { + var result = snap; + if (!diff.IsNullOrEmpty()) + { + if (snap.IsNullOrEmpty()) + { + result = (List)diff.DeepCopy(); + onConstraint(result); + } + else if (!diff.IsExactly(snap)) + { + result = new List(snap.DeepCopy()); + // Properly merge matching collection items with translation support + foreach (var diffItem in diff) + { + var idx = snap.FindIndex(e => matchExtensionsWithTranslation(e, diffItem)); + Extension mergedItem; + if (idx < 0) + { + // No match; add diff item + mergedItem = (Extension)diffItem.DeepCopy(); + result.Add(mergedItem); + } + else + { + // Match; merge diff with snap + var snapItem = result[idx]; + mergedItem = mergeComplexAttribute(snapItem, diffItem); + result[idx] = mergedItem; + } + onConstraint(mergedItem); + } + } + } + return result; + } + + // Enhanced extension matching with special logic for translation extensions + static bool matchExtensionsWithTranslation(Extension x, Extension y) + { + if (x is null || y is null || x.Url != y.Url) + return false; + + const string EXT_TRANSLATION = "http://hl7.org/fhir/StructureDefinition/translation"; + + if (EXT_TRANSLATION.Equals(x.Url)) + { + // For translation extensions, match by language code + var xLang = getExtensionString(x, "lang"); + var yLang = getExtensionString(y, "lang"); + return isEqualString(xLang, yLang); + } + + // For other extensions, URL match is sufficient + return true; + } + + /// + /// Helper to get extension string value by URL + /// + static string getExtensionString(Extension extension, string url) + { + var subExtension = extension.Extension?.FirstOrDefault(e => e.Url == url); + return (subExtension?.Value as PrimitiveType)?.ObjectValue as string; + } + static bool isEqualString(string x, string y) => StringComparer.Ordinal.Equals(x, y); } } From 12fd09eb088d23cbe3a9893d13684b2bad7d9199 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 Aug 2025 21:10:07 +0000 Subject: [PATCH 65/78] Resolve feedback: move EXT_TRANSLATION constant and restrict to string/markdown types Co-authored-by: alexzautke <548617+alexzautke@users.noreply.github.com> --- .../Specification/Snapshot/ElementDefnMerger.cs | 15 ++++++++------- .../Specification/Snapshot/ElementDefnMerger.cs | 15 ++++++++------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs index 1ca00f9dc4..cd49053246 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs @@ -35,6 +35,8 @@ public static void Merge(SnapshotGenerator generator, ElementDefinition snap, El merger.merge(snap, diff, mergeElementId, baseUrl); } + private const string EXT_TRANSLATION = "http://hl7.org/fhir/StructureDefinition/translation"; + readonly SnapshotGenerator _generator; ElementDefnMerger(SnapshotGenerator generator) @@ -392,7 +394,7 @@ List mergeExtensions(List snap, List diff) => mergeCollection(snap, diff, matchExtensions); // Enhanced extension merging with special handling for translation extensions - List mergeExtensionsWithTranslationSupport(List snap, List diff) + List mergeExtensionsWithTranslationSupport(List snap, List diff) where T : PrimitiveType { var result = snap; if (!diff.IsNullOrEmpty()) @@ -408,7 +410,7 @@ List mergeExtensionsWithTranslationSupport(List snap, List // Properly merge matching collection items with translation support foreach (var diffItem in diff) { - var idx = snap.FindIndex(e => matchExtensionsWithTranslation(e, diffItem)); + var idx = snap.FindIndex(e => matchExtensionsWithTranslation(e, diffItem)); Extension mergedItem; if (idx < 0) { @@ -431,14 +433,13 @@ List mergeExtensionsWithTranslationSupport(List snap, List } // Enhanced extension matching with special logic for translation extensions - static bool matchExtensionsWithTranslation(Extension x, Extension y) + static bool matchExtensionsWithTranslation(Extension x, Extension y) where T : PrimitiveType { if (x is null || y is null || !IsEqualUri(x.Url, y.Url)) return false; - const string EXT_TRANSLATION = "http://hl7.org/fhir/StructureDefinition/translation"; - - if (EXT_TRANSLATION.Equals(x.Url)) + // Translation extension matching only applies to string and markdown primitive types + if (EXT_TRANSLATION.Equals(x.Url) && (typeof(T) == typeof(FhirString) || typeof(T) == typeof(Markdown))) { // For translation extensions, match by language code var xLang = getExtensionString(x, "lang"); @@ -745,7 +746,7 @@ T mergePrimitiveElement(T snap, T diff, bool allowAppend = false) where T : P } // Also merge element id and extensions on primitives result.ElementId = mergeString(snap.ElementId, diff.ElementId); - result.Extension = mergeExtensionsWithTranslationSupport(snap.Extension, diff.Extension); + result.Extension = mergeExtensionsWithTranslationSupport(snap.Extension, diff.Extension); onConstraint(result); } } diff --git a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs index 06d4a615e8..1786a0878a 100644 --- a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs @@ -31,6 +31,8 @@ public static void Merge(SnapshotGenerator generator, ElementDefinition snap, El merger.merge(snap, diff, mergeElementId, baseUrl); } + private const string EXT_TRANSLATION = "http://hl7.org/fhir/StructureDefinition/translation"; + readonly SnapshotGenerator _generator; ElementDefnMerger(SnapshotGenerator generator) @@ -257,7 +259,7 @@ T mergePrimitiveAttribute(T snap, T diff, bool allowAppend = false) where T : // Also merge element id and extensions on primitives // [Backported from R4] result.ElementId = mergeString(snap?.ElementId, diff.ElementId); - result.Extension = mergeExtensionsWithTranslationSupport(snap?.Extension, diff.Extension); + result.Extension = mergeExtensionsWithTranslationSupport(snap?.Extension, diff.Extension); onConstraint(result); return result; } @@ -577,7 +579,7 @@ static bool isEqualCoding(Coding c, Coding d) static bool matchExtensions(Extension x, Extension y) => !(x is null) && !(y is null) && (x.Url == y.Url); // Enhanced extension merging with special handling for translation extensions - List mergeExtensionsWithTranslationSupport(List snap, List diff) + List mergeExtensionsWithTranslationSupport(List snap, List diff) where T : PrimitiveType { var result = snap; if (!diff.IsNullOrEmpty()) @@ -593,7 +595,7 @@ List mergeExtensionsWithTranslationSupport(List snap, List // Properly merge matching collection items with translation support foreach (var diffItem in diff) { - var idx = snap.FindIndex(e => matchExtensionsWithTranslation(e, diffItem)); + var idx = snap.FindIndex(e => matchExtensionsWithTranslation(e, diffItem)); Extension mergedItem; if (idx < 0) { @@ -616,14 +618,13 @@ List mergeExtensionsWithTranslationSupport(List snap, List } // Enhanced extension matching with special logic for translation extensions - static bool matchExtensionsWithTranslation(Extension x, Extension y) + static bool matchExtensionsWithTranslation(Extension x, Extension y) where T : PrimitiveType { if (x is null || y is null || x.Url != y.Url) return false; - const string EXT_TRANSLATION = "http://hl7.org/fhir/StructureDefinition/translation"; - - if (EXT_TRANSLATION.Equals(x.Url)) + // Translation extension matching only applies to string and markdown primitive types + if (EXT_TRANSLATION.Equals(x.Url) && (typeof(T) == typeof(FhirString) || typeof(T) == typeof(Markdown))) { // For translation extensions, match by language code var xLang = getExtensionString(x, "lang"); From 2a9ef822790b85c6e6c14913be1dc76a84af1a30 Mon Sep 17 00:00:00 2001 From: Michael Massa Date: Tue, 2 Sep 2025 15:11:49 -0500 Subject: [PATCH 66/78] add TrimWhiteSpacesInJson setting to optionally trim string values in json during serialization --- .../Serialization/CommonFhirJsonSerializer.cs | 2 +- .../Serialization/FhirJsonBuilder.cs | 2 +- .../FhirJsonSerializationSettings.cs | 3 ++ .../Serialization/SerializerSettings.cs | 6 +++ .../FhirJsonParserTests.cs | 38 +++++++++++++++++++ 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/Hl7.Fhir.Base/Serialization/CommonFhirJsonSerializer.cs b/src/Hl7.Fhir.Base/Serialization/CommonFhirJsonSerializer.cs index 6043056978..0f1a833941 100644 --- a/src/Hl7.Fhir.Base/Serialization/CommonFhirJsonSerializer.cs +++ b/src/Hl7.Fhir.Base/Serialization/CommonFhirJsonSerializer.cs @@ -24,7 +24,7 @@ public CommonFhirJsonSerializer(ModelInspector modelInspector, SerializerSetting } private FhirJsonSerializationSettings buildFhirJsonWriterSettings() => - new() { Pretty = Settings.Pretty, AppendNewLine = Settings.AppendNewLine }; + new() { Pretty = Settings.Pretty, AppendNewLine = Settings.AppendNewLine, TrimWhiteSpacesInJson = Settings.TrimWhiteSpacesInJson }; /// public string SerializeToString(Base instance, SummaryType summary = SummaryType.False, string[]? elements = null) => diff --git a/src/Hl7.Fhir.Base/Serialization/FhirJsonBuilder.cs b/src/Hl7.Fhir.Base/Serialization/FhirJsonBuilder.cs index c5ac6003fc..dc3e29c2ea 100644 --- a/src/Hl7.Fhir.Base/Serialization/FhirJsonBuilder.cs +++ b/src/Hl7.Fhir.Base/Serialization/FhirJsonBuilder.cs @@ -203,7 +203,7 @@ private void addChildren(ITypedElement node, JObject parent) private JValue buildValue(object value, string requiredType = null) => value switch { bool or decimal or Int32 or Int16 or ulong or double or BigInteger or float => new JValue(value), - string s => new JValue(s.Trim()), + string s => _settings.TrimWhiteSpacesInJson ? new JValue(s.Trim()) : new JValue(s), long l when requiredType is "integer" or "unsignedInt" or "positiveInt" => new JValue(l), _ => new JValue(PrimitiveTypeConverter.ConvertTo(value)), }; diff --git a/src/Hl7.Fhir.Base/Serialization/FhirJsonSerializationSettings.cs b/src/Hl7.Fhir.Base/Serialization/FhirJsonSerializationSettings.cs index 6d0f18467e..c14508066f 100644 --- a/src/Hl7.Fhir.Base/Serialization/FhirJsonSerializationSettings.cs +++ b/src/Hl7.Fhir.Base/Serialization/FhirJsonSerializationSettings.cs @@ -31,6 +31,8 @@ public class FhirJsonSerializationSettings /// public bool AppendNewLine { get; set; } // = false; + public bool TrimWhiteSpacesInJson { get; set; } = true; + /// Default constructor. Creates a new instance with default property values. public FhirJsonSerializationSettings() { } @@ -52,6 +54,7 @@ public void CopyTo(FhirJsonSerializationSettings other) other.IgnoreUnknownElements = IgnoreUnknownElements; other.Pretty = Pretty; other.AppendNewLine = AppendNewLine; + other.TrimWhiteSpacesInJson = TrimWhiteSpacesInJson; } /// Creates a new object that is a copy of the current instance. diff --git a/src/Hl7.Fhir.Base/Serialization/SerializerSettings.cs b/src/Hl7.Fhir.Base/Serialization/SerializerSettings.cs index 23e8fdf072..6c701784a2 100644 --- a/src/Hl7.Fhir.Base/Serialization/SerializerSettings.cs +++ b/src/Hl7.Fhir.Base/Serialization/SerializerSettings.cs @@ -29,6 +29,11 @@ public class SerializerSettings /// public bool TrimWhiteSpacesInXml { get; set; } = true; + /// + /// Trim whitespaces at the beginning and end of json string values + /// + public bool TrimWhiteSpacesInJson { get; set; } = true; + /// /// Include mandatory elements when serializing a subset of chosen elements (_elements). /// @@ -55,6 +60,7 @@ public void CopyTo(SerializerSettings other) other.Pretty = Pretty; other.AppendNewLine = AppendNewLine; other.TrimWhiteSpacesInXml = TrimWhiteSpacesInXml; + other.TrimWhiteSpacesInJson = TrimWhiteSpacesInJson; other.IncludeMandatoryInElementsSummary = IncludeMandatoryInElementsSummary; } diff --git a/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs b/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs index e59048d774..3abd98a773 100644 --- a/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs +++ b/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs @@ -2,6 +2,7 @@ using Hl7.Fhir.Model; using Hl7.Fhir.Serialization; using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Linq; namespace Hl7.Fhir.Support.Tests.Serialization; @@ -38,4 +39,41 @@ public void FhirJsonParser_TrimsWhitespaceByDefault() res.Id.Should().Be("whitespace"); } + + [TestMethod] + public void FhirJsonParserSerializer_KeepsWhitespace() + { + string json = """ + { + "resourceType": "Practitioner", + "id": " resourceID", + "identifier": [ + { + "use": "usual", + "type": { + "text": "INTERNAL" + }, + "system": "urn:oid:1.2.840.114350.1.13.211.3.7.2.697780", + "value": " identifier" + } + ], + "active": true, + "gender": "female" + } + """; + + var res = new FhirJsonParser(new() + { + PreserveWhitespaceInValues = true + }).Parse(json); + + res.Id.Should().Be(" resourceID"); + + var internalId = res.Identifier?.FirstOrDefault(i => i.Type?.Text == "INTERNAL")?.Value; + internalId.Should().Be(" identifier"); + var str = new FhirJsonSerializer().SerializeToString(res); + str.Should().Contain("\"value\":\"identifier\""); + str = new FhirJsonSerializer(new SerializerSettings { TrimWhiteSpacesInJson = false }).SerializeToString(res); + str.Should().Contain("\"value\":\" identifier\""); + } } \ No newline at end of file From bb1ef9bd3be787bad70f49634d6b3de88434a614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Skowro=C5=84ski?= Date: Thu, 4 Sep 2025 11:52:55 +0200 Subject: [PATCH 67/78] Also erase contentRef in STU3 --- .../Snapshot/SnapshotGenerator.cs | 26 ++-- .../Snapshot/SnapshotGeneratorTest.cs | 133 ++++++++++++++++-- .../Snapshot/TestProfileArtifactSource.cs | 64 ++++----- 3 files changed, 167 insertions(+), 56 deletions(-) diff --git a/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGenerator.cs b/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGenerator.cs index 7b6029aeb4..77c461537d 100644 --- a/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGenerator.cs +++ b/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGenerator.cs @@ -539,6 +539,12 @@ private async Tasks.Task expandElement(ElementDefinitionNavigator nav) // [WMR 20190926] #1123 Remove annotations and fix Base components! SnapshotGenerator.copyChildren(nav, sourceNav); + + // [EK 20250618] #3177 Ensure we don't have both children and a contentReference. + // We should restore the Type, since that's expected information if there is no + // content reference available. + defn.ContentReference = null; + defn.Type = sourceNav.Current.Type.DeepCopy().ToList(); // [WMR 20180410] // - Regenerate element IDs @@ -1262,22 +1268,22 @@ private static void fixExtensionAnnotationsAfterMerge(ElementDefinition elem) } /// - /// Copy child elements from to . + /// Copy child elements from to . /// Remove existing annotations, fix Base components /// // [WMR 20170501] OBSOLETE: notify listeners - moved to prepareTypeProfileChildren - private static bool copyChildren(ElementDefinitionNavigator nav, ElementDefinitionNavigator typeNav) // , StructureDefinition typeStructure) + private static bool copyChildren(ElementDefinitionNavigator dest, ElementDefinitionNavigator source) // , StructureDefinition typeStructure) { // [WMR 20170426] IMPORTANT! // Do NOT modify typeNav/typeStructure // Call by mergeTypeProfiles: typeNav/typeStructure refers to modified clone of global type profile // Call by expandElement: typeNav/typeStructure refers to global cached type profile (!) - Debug.Assert(!nav.AtRoot); - Debug.Assert(!typeNav.AtRoot); + Debug.Assert(!dest.AtRoot); + Debug.Assert(!source.AtRoot); // [WMR 20170220] CopyChildren returns false if nav already has children - if (nav.CopyChildren(typeNav)) + if (dest.CopyChildren(source)) { // Fix the copied elements and notify observers @@ -1285,12 +1291,12 @@ private static bool copyChildren(ElementDefinitionNavigator nav, ElementDefiniti // typeNav positioned at target element of base profile (not the root element) // => process only the current subtree, not the full structure - var typeRootPath = typeNav.Path; - var typeRootPos = typeNav.OrdinalPosition.Value; // 0 for element type, >0 for content reference - var typeElems = typeNav.Elements; - var elems = nav.Elements; + var typeRootPath = source.Path; + var typeRootPos = source.OrdinalPosition.Value; // 0 for element type, >0 for content reference + var typeElems = source.Elements; + var elems = dest.Elements; - for (int pos = nav.OrdinalPosition.Value + 1, i = typeRootPos + 1; + for (int pos = dest.OrdinalPosition.Value + 1, i = typeRootPos + 1; i < typeElems.Count && pos < elems.Count; i++, pos++) { diff --git a/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorTest.cs b/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorTest.cs index e85807f1a6..9137710fd1 100644 --- a/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorTest.cs +++ b/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorTest.cs @@ -7669,14 +7669,12 @@ public async Tasks.Task TestAbsoluteContentReferenceGeneration() var zipSource = ZipSource.CreateValidationSource(); var generator = new SnapshotGenerator(zipSource, SnapshotGeneratorSettings.CreateDefault()); - //Test if core resource has relative content references. var coreQuestionnaire = await _testResolver.FindStructureDefinitionAsync("http://hl7.org/fhir/StructureDefinition/Questionnaire"); var coreSnapshot = await generator.GenerateAsync(coreQuestionnaire); var item = coreSnapshot.Where(e => e.Path == "Questionnaire.item.item").FirstOrDefault(); item.ContentReference.Should().Be("#Questionnaire.item"); - //Create profile for testing creation of absolute references. var profile = new StructureDefinition { @@ -7718,15 +7716,9 @@ public async Tasks.Task TestAbsoluteContentReferenceGeneration() }, new ElementDefinition { - ElementId = "Questionnaire.item:booleanItem.type", - Path = "Questionnaire.item.type", - Fixed = new Code("boolean") - }, - new ElementDefinition - { - ElementId = "Questionnaire.item:booleanItem.item.type", - Path = "Questionnaire.item.item.type", - Fixed = new Code("string") + ElementId = "Questionnaire.item:booleanItem.item", + Path = "Questionnaire.item.item", + Min = 1 } } } @@ -7737,9 +7729,6 @@ public async Tasks.Task TestAbsoluteContentReferenceGeneration() var cref1 = profileSnapshot.Where(e => e.ElementId == "Questionnaire.item:booleanItem.item").FirstOrDefault(); cref1.ContentReference.Should().Be("http://hl7.org/fhir/StructureDefinition/Questionnaire#Questionnaire.item"); - - var cref2 = profileSnapshot.Where(e => e.ElementId == "Questionnaire.item:booleanItem.item.item").FirstOrDefault(); - cref2.ContentReference.Should().Be("http://hl7.org/fhir/StructureDefinition/Questionnaire#Questionnaire.item"); } [TestMethod] @@ -8460,5 +8449,121 @@ public async Tasks.Task TestMergingAPreviouslyRemovedElement() Assert.IsNull(valueQuantityEld); } + + // Test whether we have fixed issue https://github.com/FirelyTeam/firely-net-sdk/issues/3177. + [TestMethod] + public async Tasks.Task TestSliceWithContentReference() + { + var sd = buildSliceOnContentReference(); + _generator = new SnapshotGenerator(_testResolver, _settings); + var snapshot = await _generator.GenerateAsync(sd); + + // If we have copied the contentReference's children to the slice, there should not be a contentReference + // on the slice itself anymore (but it should still exist on the intro). + snapshot.Single(e => e.ElementId == "Parameters.parameter.part") + .ContentReference.Should().NotBeNull(); + snapshot.Should().ContainSingle(e => e.ElementId == "Parameters.parameter.part:medicationDispense.name"); + + // The slice itself should not have a contentReference, because it is copied the children below it. + var firstSlice = snapshot.Single(e => e.ElementId == "Parameters.parameter.part:medicationDispense"); + firstSlice.ContentReference.Should().BeNull(); + + // But it should now have a TypeRef element! + firstSlice.Type.Should().ContainSingle(tr => tr.Code == "BackboneElement"); + } + + // Test whether fixing issue https://github.com/FirelyTeam/firely-net-sdk/issues/3177 does + // not break the snapshot generation when just the cardinality of the contentReference is changed. + [TestMethod] + public async Tasks.Task TestContentReferenceWithCardinalityChangeOnPart() + { + var sd = changeCardinalityOnContentReference(); + _generator = new SnapshotGenerator(_testResolver, _settings); + var snapshot = await _generator.GenerateAsync(sd); + + // Changing the cardinality will not copy the children, so the contentReference should still + // be there and NOT have a typeref. + var firstPart = snapshot.Single(e => e.ElementId == "Parameters.parameter.part"); + firstPart.ContentReference.Should().NotBeNull(); + firstPart.Type.Should().BeEmpty(); + } + + // Test whether fixing issue https://github.com/FirelyTeam/firely-net-sdk/issues/3177 does + // not break the snapshot generation when just the cardinality of a nested contentReference is changed. + [TestMethod] + public async Tasks.Task TestContentReferenceWithCardinalityChangeOnNestedPart() + { + var sd = changeCardinalityOnNestedContentReference(); + _generator = new SnapshotGenerator(_testResolver, _settings); + var snapshot = await _generator.GenerateAsync(sd); + + // Changing the cardinality in a child *will* copy the children, so the contentReference should + // now be gone, and it should have a TypeRef instead. + var firstPart = snapshot.Single(e => e.ElementId == "Parameters.parameter.part"); + firstPart.ContentReference.Should().BeNull(); + firstPart.Type.Should().ContainSingle(tr => tr.Code == "BackboneElement"); + + // But the nested part should still have a contentReference, and no typeref. + var nestedPart = snapshot.Single(e => e.ElementId == "Parameters.parameter.part.part"); + nestedPart.ContentReference.Should().NotBeNull(); + nestedPart.Type.Should().BeEmpty(); + } + + private static StructureDefinition buildSliceOnContentReference() + { + var result = TestProfileArtifactSource.CreateTestSD("http://validationtest.org/fhir/StructureDefinition/Parameters-issue-3177", "Parameters-issue-3177", + "Parameters with sliced parts - and so copied contentReferences", FHIRAllTypes.Parameters); + + var cons = result.Differential.Element; + + var slicingIntro = new ElementDefinition("Parameters.parameter.part") + .WithSlicingIntro(ElementDefinition.SlicingRules.Closed, + (ElementDefinition.DiscriminatorType.Pattern, "name")) + .Required(); + + cons.Add(slicingIntro); + + cons.Add(new ElementDefinition("Parameters.parameter.part") + { + ElementId = "Parameters.parameter.part:medicationDispense", SliceName = "medicationDispense", + }.Required()); + + cons.Add(new ElementDefinition("Parameters.parameter.part.name") + { + ElementId = "Parameters.parameter.part:medicationDispense.name", + Pattern = new FhirString("medicationDispense") + }); + + return result; + } + + private static StructureDefinition changeCardinalityOnContentReference() + { + var result = TestProfileArtifactSource.CreateTestSD("http://validationtest.org/fhir/StructureDefinition/Parameters-issue-3177", "Parameters-issue-3177", + "Parameters with new cardinality on parts", FHIRAllTypes.Parameters); + + var cons = result.Differential.Element; + + var mainPart = new ElementDefinition("Parameters.parameter.part") + .Required(); + + cons.Add(mainPart); + + return result; + } + + private static StructureDefinition changeCardinalityOnNestedContentReference() + { + var result = TestProfileArtifactSource.CreateTestSD("http://validationtest.org/fhir/StructureDefinition/Parameters-issue-3177", "Parameters-issue-3177", + "Parameters with new cardinality on part.part", FHIRAllTypes.Parameters); + + var cons = result.Differential.Element; + + var nestedPart = new ElementDefinition("Parameters.parameter.part.part") + .Required(); + + cons.Add(nestedPart); + + return result; } } } \ No newline at end of file diff --git a/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/TestProfileArtifactSource.cs b/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/TestProfileArtifactSource.cs index a3e1384775..3abd7c2fe1 100644 --- a/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/TestProfileArtifactSource.cs +++ b/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/TestProfileArtifactSource.cs @@ -60,7 +60,7 @@ internal class TestProfileArtifactSource : IResourceResolver private static StructureDefinition buildObservationWithTargetProfilesAndChildDefs() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/Observation-issue-1654", "Observation-issue-1654", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/Observation-issue-1654", "Observation-issue-1654", "Observation with targetprofile on subject and children definition under subject as well", FHIRAllTypes.Observation); var cons = result.Differential.Element; @@ -80,7 +80,7 @@ private static StructureDefinition buildObservationWithTargetProfilesAndChildDef private static StructureDefinition buildTranslatableCodeableConcept() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/CodeableConceptTranslatable", "CodeableConceptTranslatable", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/CodeableConceptTranslatable", "CodeableConceptTranslatable", "Test CodeableConcept with an extension on CodeableConcept.text", FHIRAllTypes.CodeableConcept); var cons = result.Differential.Element; @@ -96,7 +96,7 @@ private static StructureDefinition buildTranslatableCodeableConcept() private static StructureDefinition buildObservationWithTranslatableCode() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/ObservationWithTranslatableCode", "ObservationWithTranslatableCode", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/ObservationWithTranslatableCode", "ObservationWithTranslatableCode", "Test Observation with a profiled CodeableConcept for Observation.code", FHIRAllTypes.Observation); var cons = result.Differential.Element; @@ -110,7 +110,7 @@ private static StructureDefinition buildObservationWithTranslatableCode() private static StructureDefinition slicingWithCodeableConcept() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/ObservationSlicingCodeableConcept", "ObservationSlicingCodeableConcept", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/ObservationSlicingCodeableConcept", "ObservationSlicingCodeableConcept", "Test Observation with slicing on value[x], first slice CodeableConcept", FHIRAllTypes.Observation); var cons = result.Differential.Element; @@ -133,7 +133,7 @@ private static StructureDefinition slicingWithCodeableConcept() private static StructureDefinition slicingWithQuantity() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/ObservationValueSlicingQuantity", "ObservationSlicingQuantity", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/ObservationValueSlicingQuantity", "ObservationSlicingQuantity", "Test Observation with slicing on value[x], first slice Quantity", FHIRAllTypes.Observation, "http://validationtest.org/fhir/StructureDefinition/ObservationSlicingCodeableConcept"); @@ -158,7 +158,7 @@ private static StructureDefinition slicingWithQuantity() private static StructureDefinition buildPatientWithIdentifierSlicing() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/PatientIdentifierSlicing", "PatientIdentifierSlicing", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/PatientIdentifierSlicing", "PatientIdentifierSlicing", "Test Patient with slicing on Identifier, first slice BSN", FHIRAllTypes.Patient); var cons = result.Differential.Element; @@ -184,7 +184,7 @@ private static StructureDefinition buildPatientWithIdentifierSlicing() private static StructureDefinition buildPatientWithExistsSlicing() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/PatientExistsSlicing", "PatientExistsSlicing", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/PatientExistsSlicing", "PatientExistsSlicing", "Test Patient with exists slicing on Identifier", FHIRAllTypes.Patient); var cons = result.Differential.Element; @@ -233,7 +233,7 @@ private static StructureDefinition buildPatientWithExistsSlicing() private static StructureDefinition buildMiPatient() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/mi-patient", "mi-Patient", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/mi-patient", "mi-Patient", "Test a derived Patient introducing a new slice to the base introduction Slicing", FHIRAllTypes.Patient, "http://validationtest.org/fhir/StructureDefinition/PatientIdentifierSlicing"); @@ -270,7 +270,7 @@ private static StructureDefinition buildMiPatient() private static StructureDefinition buildOrganizationWithRegexConstraintOnName() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/MyOrganization", "My Organization", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/MyOrganization", "My Organization", "Test an organization with Name containing regex", FHIRAllTypes.Organization); var cons = result.Differential.Element; @@ -286,7 +286,7 @@ private static StructureDefinition buildOrganizationWithRegexConstraintOnName() private static StructureDefinition buildOrganizationWithRegexConstraintOnType() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/MyOrganization2", "My Organization", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/MyOrganization2", "My Organization", "Test an organization with Name containing regex", FHIRAllTypes.Organization); var cons = result.Differential.Element; @@ -312,7 +312,7 @@ public Resource ResolveByUri(string uri) private static StructureDefinition buildDutchPatient() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/DutchPatient", "Dutch Patient", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/DutchPatient", "Dutch Patient", "Test Patient which requires an Identifier with either BSN or drivers license", FHIRAllTypes.Patient); var cons = result.Differential.Element; @@ -326,7 +326,7 @@ private static StructureDefinition buildDutchPatient() private static StructureDefinition buildIdentifierWithBSN() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/IdentifierWithBSN", "BSN Identifier", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/IdentifierWithBSN", "BSN Identifier", "Test Identifier which requires a BSN oid", FHIRAllTypes.Identifier); var cons = result.Differential.Element; @@ -340,7 +340,7 @@ private static StructureDefinition buildIdentifierWithBSN() private static StructureDefinition buildIdentifierWithDriversLicense() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/IdentifierWithDL", "Drivers license Identifier", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/IdentifierWithDL", "Drivers license Identifier", "Test Identifier which requires a drivers license oid", FHIRAllTypes.Identifier); var cons = result.Differential.Element; @@ -353,7 +353,7 @@ private static StructureDefinition buildIdentifierWithDriversLicense() private static StructureDefinition buildQuestionnaireWithFixedType() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/QuestionnaireWithFixedType", "Fixed Questionnaire", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/QuestionnaireWithFixedType", "Fixed Questionnaire", "Questionnaire with a fixed question type of 'decimal'", FHIRAllTypes.Questionnaire); var cons = result.Differential.Element; @@ -365,7 +365,7 @@ private static StructureDefinition buildQuestionnaireWithFixedType() private static StructureDefinition buildWeightQuantity() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/WeightQuantity", "Weight Quantity", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/WeightQuantity", "Weight Quantity", "Quantity which allows just kilograms", FHIRAllTypes.Quantity); var cons = result.Differential.Element; @@ -380,7 +380,7 @@ private static StructureDefinition buildWeightQuantity() private static StructureDefinition buildHeightQuantity() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/HeightQuantity", "Height Quantity", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/HeightQuantity", "Height Quantity", "Quantity which allows just centimeters", FHIRAllTypes.Quantity); var cons = result.Differential.Element; @@ -395,7 +395,7 @@ private static StructureDefinition buildHeightQuantity() private static StructureDefinition buildWeightHeightObservation() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/WeightHeightObservation", "Weight/Height Observation", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/WeightHeightObservation", "Weight/Height Observation", "Observation with a choice of weight/height or another type of value", FHIRAllTypes.Observation); var cons = result.Differential.Element; @@ -412,7 +412,7 @@ private static StructureDefinition buildWeightHeightObservation() private static StructureDefinition bundleWithSpecificEntries(string prefix) { - var result = createTestSD($"http://validationtest.org/fhir/StructureDefinition/BundleWith{prefix}Entries", $"Bundle with specific {prefix} test entries", + var result = CreateTestSD($"http://validationtest.org/fhir/StructureDefinition/BundleWith{prefix}Entries", $"Bundle with specific {prefix} test entries", $"Bundle with just Organization or {prefix} Patient entries", FHIRAllTypes.Bundle); var cons = result.Differential.Element; @@ -427,7 +427,7 @@ private static StructureDefinition bundleWithSpecificEntries(string prefix) private static StructureDefinition bundleWithConstrainedContained() { - var result = createTestSD($"http://validationtest.org/fhir/StructureDefinition/BundleWithConstrainedContained", + var result = CreateTestSD($"http://validationtest.org/fhir/StructureDefinition/BundleWithConstrainedContained", $"Bundle with a constraint on the Bundle.entry.resource", $"Bundle with a constraint on the Bundle.entry.resource", FHIRAllTypes.Bundle); @@ -442,7 +442,7 @@ private static StructureDefinition bundleWithConstrainedContained() private static StructureDefinition patientWithSpecificOrganization(IEnumerable aggregation, string prefix) { - var result = createTestSD($"http://validationtest.org/fhir/StructureDefinition/PatientWith{prefix}Organization", $"Patient with {prefix} managing organization", + var result = CreateTestSD($"http://validationtest.org/fhir/StructureDefinition/PatientWith{prefix}Organization", $"Patient with {prefix} managing organization", $"Patient for which the managingOrganization reference is limited to {prefix} references", FHIRAllTypes.Patient); var cons = result.Differential.Element; @@ -457,7 +457,7 @@ private static StructureDefinition patientWithSpecificOrganization(IEnumerable buildPatientWithProfiledReferences() { - yield return createTestSD(PROFILED_ORG_URL, "A profiled organization", + yield return CreateTestSD(PROFILED_ORG_URL, "A profiled organization", "A profiled Organization with no additional constraints", FHIRAllTypes.Organization); - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/PatientWithReferences", "Patient with References", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/PatientWithReferences", "Patient with References", "Test Patient which has a profiled managing organization", FHIRAllTypes.Patient); var cons = result.Differential.Element; @@ -661,7 +661,7 @@ private static IEnumerable buildPatientWithProfiledReferenc private static StructureDefinition buildSliceOnChoice() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/MedicationStatement-issue-2132", "MedicationStatement-issue-2132", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/MedicationStatement-issue-2132", "MedicationStatement-issue-2132", "MedicationStatement sliced on asNeeded[x]", FHIRAllTypes.MedicationStatement); var cons = result.Differential.Element; @@ -688,7 +688,7 @@ private static StructureDefinition buildSliceOnChoice() private static StructureDefinition buildConstrainBindableType() { - var result = createTestSD("http://validationtest.org/fhir/StructureDefinition/MedicationStatement-issue-2132-2", "MedicationStatement-issue-2132", + var result = CreateTestSD("http://validationtest.org/fhir/StructureDefinition/MedicationStatement-issue-2132-2", "MedicationStatement-issue-2132", "MedicationStatement sliced on asNeeded[x]", FHIRAllTypes.MedicationStatement); var cons = result.Differential.Element; From c8efbf5b04fdc417a33c26d0e363b3f7d1b875e8 Mon Sep 17 00:00:00 2001 From: Rob5045 Date: Sat, 20 Sep 2025 20:09:56 +0200 Subject: [PATCH 68/78] Added check to prevent appending text (using ...) more than once + unit tests. --- Hl7.Fhir.sln | 2 + .../Snapshot/ElementDefnMerger.cs | 19 ++- .../Snapshot/SnapshotGenerator.cs | 2 + .../Snapshot/SnapshotGeneratorAnnotations.cs | 50 +++++++ ...SnapshotGeneratorMappingSuppressionTest.cs | 132 +++++++++++++++--- .../Snapshot/SnapshotGeneratorTest.cs | 2 + 6 files changed, 183 insertions(+), 24 deletions(-) diff --git a/Hl7.Fhir.sln b/Hl7.Fhir.sln index e9dbf60882..b81e96750f 100644 --- a/Hl7.Fhir.sln +++ b/Hl7.Fhir.sln @@ -359,6 +359,8 @@ Global src\Hl7.Fhir.ElementModel.Shared.Tests\Hl7.Fhir.ElementModel.Shared.Tests.projitems*{37e47f34-d2d5-4d24-8f31-5753000bd439}*SharedItemsImports = 5 src\Hl7.Fhir.Shims.R4AndUp\Hl7.Fhir.Shims.R4AndUp.projitems*{3d696d26-c0ff-4c6c-b3b7-cffb4f74079f}*SharedItemsImports = 13 src\Hl7.Fhir.Specification.Shared.Tests\Hl7.Fhir.Specification.Shared.Tests.projitems*{3fbbe610-4595-4213-9c0c-2a6af06e5f3c}*SharedItemsImports = 5 + src\Hl7.Fhir.Shims.R4AndUp\Hl7.Fhir.Shims.R4AndUp.projitems*{41cf5ade-844c-45db-8506-181452a28f4e}*SharedItemsImports = 5 + src\Hl7.Fhir.Shims.STU3AndUp\Hl7.Fhir.Shims.STU3AndUp.projitems*{41cf5ade-844c-45db-8506-181452a28f4e}*SharedItemsImports = 5 src\Hl7.Fhir.Shims.R4AndUp\Hl7.Fhir.Shims.R4AndUp.projitems*{437ec873-7d71-497b-a7e6-7e27c020af9e}*SharedItemsImports = 5 src\Hl7.Fhir.Shims.STU3AndUp\Hl7.Fhir.Shims.STU3AndUp.projitems*{437ec873-7d71-497b-a7e6-7e27c020af9e}*SharedItemsImports = 5 src\Hl7.Fhir.ElementModel.Shared.Tests\Hl7.Fhir.ElementModel.Shared.Tests.projitems*{52a8cfd0-fc25-42db-b3aa-292f4df8b72f}*SharedItemsImports = 5 diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs index 97c75a1e46..b20723bcec 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs @@ -800,13 +800,26 @@ T mergePrimitiveElement(T snap, T diff, bool allowAppend = false) where T : P { //var prefix = snap != null ? snap.ObjectValue as string : null; var prefix = snap?.ObjectValue as string; - if (string.IsNullOrEmpty(prefix)) + + if (result.HasAppendedText()) { - diffText = diffText.Substring(3); + // Don't append text twice + diffText = prefix; } else { - diffText = prefix + "\r\n" + diffText.Substring(3); + if (string.IsNullOrEmpty(prefix)) + { + diffText = diffText.Substring(3); + } + else + { + diffText = prefix + "\r\n" + diffText.Substring(3); + } + + // Add marker that text has been appended to prevent it being appended multiple times + // when an element has a type profile (which will result in multiple merges of the same element). + result.SetAppendedTextAnnotation(); } } diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGenerator.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGenerator.cs index db56081b4b..cdea836fd0 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGenerator.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGenerator.cs @@ -467,6 +467,7 @@ private async Tasks.Task> generate(StructureDefinition s // [WMR 20160915] Derived profiles should never inherit the ChangedByDiff extension from the base structure snapshot.RemoveAllNonInheritableExtensions(); snapshot.Element.RemoveAllConstrainedByDiffAnnotations(); + snapshot.Element.RemoveAllAppendedTextAnnotations(); // Notify observers for (int i = 0; i < snapshot.Element.Count; i++) @@ -1510,6 +1511,7 @@ private static bool copyChildren(ElementDefinitionNavigator dest, ElementDefinit // [WMR 20160826] Never inherit Changed extension from base profile! elem.RemoveAllNonInheritableExtensions(); elem.RemoveAllConstrainedByDiffAnnotations(); + elem.RemoveAllAppendedTextAnnotations(); // [WMR 20160902] Initialize empty ElementDefinition.Base components if necessary // [WMR 20170424] Inherit existing base components from type profile diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGeneratorAnnotations.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGeneratorAnnotations.cs index be4257bf0e..b5b0ad1bbb 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGeneratorAnnotations.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGeneratorAnnotations.cs @@ -231,5 +231,55 @@ internal static bool HasSnapshotElementAnnotation(this ElementDefinition ed) #endregion + #region Annotation: AppendedText + + /// + /// Custom annotation for elements and properties in the + /// that are constrained by the . + /// + [Serializable] + sealed class AppendedTextAnnotation + { + // + } + + /// Annotate the specified snapshot element to indicate that it is constrained by the differential. + internal static void SetAppendedTextAnnotation(this Base element) + { + if (element == null) { throw Error.ArgumentNull(nameof(element)); } + element.AddAnnotation(new AppendedTextAnnotation()); + } + + /// Remove any existing differential constraint annotation from the specified snapshot element. + internal static void RemoveAppendedTextAnnotation(this Base element) + { + if (element == null) { throw Error.ArgumentNull(nameof(element)); } + element.RemoveAnnotations(); + } + + /// Recursively remove any existing differential constraint annotations from the specified snapshot element and all it's children. + internal static void RemoveAllAppendedTextAnnotations(this Base element) + { + if (element == null) { throw Error.ArgumentNull(nameof(element)); } + element.RemoveAppendedTextAnnotation(); + foreach (var child in element.Children) + { + child.RemoveAllAppendedTextAnnotations(); + } + } + + /// Recursively remove any existing differential constraint annotations from the specified snapshot elements and all their children. + internal static void RemoveAllAppendedTextAnnotations(this IEnumerable elements) where T : Base + { + if (elements == null) { throw Error.ArgumentNull(nameof(elements)); } + foreach (var elem in elements) + { + elem.RemoveAllAppendedTextAnnotations(); + } + } + + public static bool HasAppendedText(this Element elem) => elem != null && elem.HasAnnotation(); + + #endregion } } diff --git a/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs b/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs index 881e7cdca3..7a1375e41c 100644 --- a/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs +++ b/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs @@ -6,19 +6,109 @@ * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ +using FluentAssertions; using Hl7.Fhir.Model; using Hl7.Fhir.Specification.Snapshot; using Hl7.Fhir.Specification.Source; +using Hl7.Fhir.Utility; using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Collections.Generic; using System.Linq; -using Hl7.Fhir.Utility; namespace Hl7.Fhir.Specification.Tests { [TestClass] public class SnapshotGeneratorMappingSuppressionTest { + private class ElementBaseAnnotation(ElementDefinition baseElemDef) + { + public ElementDefinition BaseElementDefinition { get; } = baseElemDef; + } + + private const string MARKDOWN_COMMENT = "Systems are not required to have markdown support, so the text should be readable without markdown processing. The markdown syntax is GFM - see https://github.github.com/gfm/"; + + [TestMethod] + public async System.Threading.Tasks.Task CodeSystemCopyrightCommentIssueTest() + { + const string copyrightComment = "... Sometimes, the copyright differs between the code system and the codes that are included. The copyright statement should clearly differentiate between these when required."; + + await copyrightCommentIssueTest("CodeSystem", mergeAppendText(MARKDOWN_COMMENT, copyrightComment)); + } + + [TestMethod] + public async System.Threading.Tasks.Task CapabilityStatementCopyrightCommentIssueTest() + { + const string copyrightComment = "..."; + + await copyrightCommentIssueTest("CapabilityStatement", mergeAppendText(MARKDOWN_COMMENT, copyrightComment)); + } + + private static async System.Threading.Tasks.Task copyrightCommentIssueTest(string resource, string expectedComment) + { + var zipSource = ZipSource.CreateValidationSource(); + var resolver = new CachedResolver(zipSource); + var settings = new SnapshotGeneratorSettings + { + ForceRegenerateSnapshots = true, + GenerateAnnotationsOnConstraints = false, + GenerateExtensionsOnConstraints = false, + GenerateElementIds = true, + GenerateSnapshotForExternalProfiles = true + }; + var sd = new StructureDefinition + { + Type = resource, + BaseDefinition = $"http://hl7.org/fhir/StructureDefinition/{resource}", + Name = $"My{resource}", + Url = $"http://example.org/fhir/StructureDefinition/My{resource}", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + FhirVersion = FHIRVersion.N5_0_0, + }; + + var generator = new SnapshotGenerator(resolver, settings); + + generator.PrepareElement += addElementBaseAnnotation; + + var elems = await generator.GenerateAsync(sd); + + generator.PrepareElement -= addElementBaseAnnotation; + + var element = elems.FirstOrDefault(e => e.ElementId == $"{resource}.copyright"); + + element.Should().NotBeNull(); + element.Comment.Should().Be(expectedComment); + + var baseElement = element.Annotation()?.BaseElementDefinition; + + baseElement.Should().NotBeNull(); + element.Comment.Should().Be(baseElement.Comment); + } + + private static string mergeAppendText(string s1, string s2) + { + if (!s2.StartsWith("...")) + return s2; + + return string.IsNullOrEmpty(s1) + ? s2[3..] + : s1 + "\r\n" + s2[3..]; + } + + private static void addElementBaseAnnotation(object sender, SnapshotElementEventArgs e) + { + var elem = e.Element; + + var ann = elem.Annotation(); + + if (ann != null) + elem.RemoveAnnotations(); + + var baseDef = e.BaseElement; + + elem.AddAnnotation(new ElementBaseAnnotation(baseDef)); + } [TestMethod] public async System.Threading.Tasks.Task TestMappingInheritanceWithoutSuppression() @@ -42,14 +132,14 @@ public async System.Threading.Tasks.Task TestMappingInheritanceWithoutSuppressio } } }; - + // Create a derived profile without suppress extension var derivedProfile = CreateDerivedProfileWithoutSuppression(); - + // Mock resolver to return base profile when requested var mockResolver = new InMemoryResourceResolver(); mockResolver.Add(baseProfile); - + // Generate snapshot var generator = new SnapshotGenerator(mockResolver, new SnapshotGeneratorSettings()); await generator.UpdateAsync(derivedProfile); @@ -84,14 +174,14 @@ public async System.Threading.Tasks.Task TestMappingSuppressionWithExtension() } } }; - + // Create a derived profile with suppress extension on mapping var derivedProfile = CreateDerivedProfileWithSuppressedMapping(); - + // Mock resolver to return base profile when requested var mockResolver = new InMemoryResourceResolver(); mockResolver.Add(baseProfile); - + // Generate snapshot var generator = new SnapshotGenerator(mockResolver, new SnapshotGeneratorSettings()); await generator.UpdateAsync(derivedProfile); @@ -183,7 +273,7 @@ private StructureDefinition CreateDerivedProfileWithSuppressedMapping() { new Extension() { - Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, + //Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, Value = new FhirBoolean(true) } } @@ -216,20 +306,20 @@ public async System.Threading.Tasks.Task TestExampleInheritanceWithoutSuppressio } } }; - + // Create a derived profile without suppress extension var derivedProfile = CreateDerivedProfileWithoutExampleSuppression(); - + // Mock resolver to return base profile when requested var mockResolver = new InMemoryResourceResolver(); mockResolver.Add(baseProfile); - + // Create snapshot generator var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); - + // Generate snapshot for the derived profile generator.Update(derivedProfile); - + // Assert that the derived profile inherited the example from the base Assert.IsNotNull(derivedProfile.Snapshot); var patientElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); @@ -262,25 +352,25 @@ public async System.Threading.Tasks.Task TestExampleSuppressionExtension() } } }; - + // Create a derived profile that suppresses the inherited example var derivedProfile = CreateDerivedProfileWithExampleSuppression(); - + // Mock resolver to return base profile when requested var mockResolver = new InMemoryResourceResolver(); mockResolver.Add(baseProfile); - + // Create snapshot generator var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); - + // Generate snapshot for the derived profile generator.Update(derivedProfile); - + // Assert that the derived profile did NOT inherit the example (it was suppressed) Assert.IsNotNull(derivedProfile.Snapshot); var patientElement = derivedProfile.Snapshot.Element.FirstOrDefault(e => e.Path == "Patient"); Assert.IsNotNull(patientElement); - + // The example should be absent because it was suppressed Assert.IsTrue(patientElement.Example == null || patientElement.Example.Count == 0); } @@ -350,7 +440,7 @@ private StructureDefinition CreateDerivedProfileWithExampleSuppression() return new StructureDefinition() { Id = "derived-patient-profile-with-example-suppression", - Url = "http://example.org/fhir/StructureDefinition/derived-patient-with-example-suppression", + Url = "http://example.org/fhir/StructureDefinition/derived-patient-with-example-suppression", Name = "DerivedPatientProfileWithExampleSuppression", Status = PublicationStatus.Active, Kind = StructureDefinition.StructureDefinitionKind.Resource, @@ -375,7 +465,7 @@ private StructureDefinition CreateDerivedProfileWithExampleSuppression() { new Extension() { - Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, + //Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, Value = new FhirBoolean(true) } } diff --git a/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs b/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs index dbf79f11a5..f772b04fcc 100644 --- a/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs +++ b/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs @@ -2566,6 +2566,8 @@ private static bool isAlmostExactly(ElementDefinition elem, ElementDefinition ba baseClone.RemoveAllNonInheritableExtensions(); elemClone.RemoveAllConstrainedByDiffAnnotations(); baseClone.RemoveAllConstrainedByDiffAnnotations(); + elemClone.RemoveAllAppendedTextAnnotations(); + baseClone.RemoveAllAppendedTextAnnotations(); var result = baseClone.IsExactly(elemClone); return result; From 43fb7e4989b4542560f5a27d49b4433efb292303 Mon Sep 17 00:00:00 2001 From: Rob5045 Date: Mon, 22 Sep 2025 11:11:03 +0200 Subject: [PATCH 69/78] Added RemoveAllSnapshotGeneratorAnnotations and RemoveSnapshotGeneratorAnnotation. --- .../Snapshot/SnapshotGenerator.cs | 6 +- .../Snapshot/SnapshotGeneratorAnnotations.cs | 133 ++++++++---------- .../Snapshot/SnapshotGeneratorTest.cs | 6 +- 3 files changed, 65 insertions(+), 80 deletions(-) diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGenerator.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGenerator.cs index cdea836fd0..25a8f41d2e 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGenerator.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGenerator.cs @@ -466,8 +466,7 @@ private async Tasks.Task> generate(StructureDefinition s // [WMR 20170208] Moved to *AFTER* ensureBaseComponents - emits annotations... // [WMR 20160915] Derived profiles should never inherit the ChangedByDiff extension from the base structure snapshot.RemoveAllNonInheritableExtensions(); - snapshot.Element.RemoveAllConstrainedByDiffAnnotations(); - snapshot.Element.RemoveAllAppendedTextAnnotations(); + snapshot.Element.RemoveAllSnapshotGeneratorAnnotations(); // Notify observers for (int i = 0; i < snapshot.Element.Count; i++) @@ -1510,8 +1509,7 @@ private static bool copyChildren(ElementDefinitionNavigator dest, ElementDefinit // [WMR 20160826] Never inherit Changed extension from base profile! elem.RemoveAllNonInheritableExtensions(); - elem.RemoveAllConstrainedByDiffAnnotations(); - elem.RemoveAllAppendedTextAnnotations(); + elem.RemoveAllSnapshotGeneratorAnnotations(); // [WMR 20160902] Initialize empty ElementDefinition.Base components if necessary // [WMR 20170424] Inherit existing base components from type profile diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGeneratorAnnotations.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGeneratorAnnotations.cs index b5b0ad1bbb..30db229fe6 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGeneratorAnnotations.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGeneratorAnnotations.cs @@ -20,6 +20,67 @@ namespace Hl7.Fhir.Specification.Snapshot /// Provides support for custom annotation types used by the . public static class SnapshotGeneratorAnnotations { + #region Used internally by Snapshot Generator + + internal static void RemoveSnapshotGeneratorAnnotation(this Base element) + { + element.RemoveConstrainedByDiffAnnotation(); + element.RemoveAppendedTextAnnotation(); + } + + /// Recursively remove any existing snapshot generator annotations from the specified snapshot element and all it's children. + internal static void RemoveAllSnapshotGeneratorAnnotations(this Base element) + { + if (element == null) { throw Error.ArgumentNull(nameof(element)); } + element.RemoveSnapshotGeneratorAnnotation(); + foreach (var child in element.Children) + { + child.RemoveSnapshotGeneratorAnnotation(); + } + } + + /// Recursively remove any existing snapshot generator annotations from the specified snapshot elements and all their children. + internal static void RemoveAllSnapshotGeneratorAnnotations(this IEnumerable elements) where T : Base + { + if (elements == null) { throw Error.ArgumentNull(nameof(elements)); } + foreach (var elem in elements) + { + elem.RemoveSnapshotGeneratorAnnotation(); + } + } + + #endregion + + #region Annotation: AppendedText + + /// + /// Custom annotation for properties in the + /// that have text appended with the ... notation. + /// + [Serializable] + sealed class AppendedTextAnnotation + { + // + } + + /// Annotate the specified snapshot element to indicate that text has been appended. + internal static void SetAppendedTextAnnotation(this Base element) + { + if (element == null) { throw Error.ArgumentNull(nameof(element)); } + element.AddAnnotation(new AppendedTextAnnotation()); + } + + /// Remove any existing appended text annotation from the specified snapshot element. + internal static void RemoveAppendedTextAnnotation(this Base element) + { + if (element == null) { throw Error.ArgumentNull(nameof(element)); } + element.RemoveAnnotations(); + } + + public static bool HasAppendedText(this Element elem) => elem != null && elem.HasAnnotation(); + + #endregion + #region Annotation: Created By Snapshot Generator /// Annotation to mark a generated element, so we can prevent duplicate re-generation. @@ -66,27 +127,6 @@ internal static void RemoveConstrainedByDiffAnnotation(this Base element) element.RemoveAnnotations(); } - /// Recursively remove any existing differential constraint annotations from the specified snapshot element and all it's children. - internal static void RemoveAllConstrainedByDiffAnnotations(this Base element) - { - if (element == null) { throw Error.ArgumentNull(nameof(element)); } - element.RemoveConstrainedByDiffAnnotation(); - foreach (var child in element.Children) - { - child.RemoveAllConstrainedByDiffAnnotations(); - } - } - - /// Recursively remove any existing differential constraint annotations from the specified snapshot elements and all their children. - internal static void RemoveAllConstrainedByDiffAnnotations(this IEnumerable elements) where T : Base - { - if (elements == null) { throw Error.ArgumentNull(nameof(elements)); } - foreach (var elem in elements) - { - elem.RemoveAllConstrainedByDiffAnnotations(); - } - } - /// /// Determines if the specified element is annotated as being constrained by the differential. /// Note that this method is non-recursive; only the specified element itself is inspected, child element annotations are ignored. @@ -230,56 +270,5 @@ internal static bool HasSnapshotElementAnnotation(this ElementDefinition ed) internal static void RemoveSnapshotElementAnnotations(this ElementDefinition ed) { ed?.RemoveAnnotations(); } #endregion - - #region Annotation: AppendedText - - /// - /// Custom annotation for elements and properties in the - /// that are constrained by the . - /// - [Serializable] - sealed class AppendedTextAnnotation - { - // - } - - /// Annotate the specified snapshot element to indicate that it is constrained by the differential. - internal static void SetAppendedTextAnnotation(this Base element) - { - if (element == null) { throw Error.ArgumentNull(nameof(element)); } - element.AddAnnotation(new AppendedTextAnnotation()); - } - - /// Remove any existing differential constraint annotation from the specified snapshot element. - internal static void RemoveAppendedTextAnnotation(this Base element) - { - if (element == null) { throw Error.ArgumentNull(nameof(element)); } - element.RemoveAnnotations(); - } - - /// Recursively remove any existing differential constraint annotations from the specified snapshot element and all it's children. - internal static void RemoveAllAppendedTextAnnotations(this Base element) - { - if (element == null) { throw Error.ArgumentNull(nameof(element)); } - element.RemoveAppendedTextAnnotation(); - foreach (var child in element.Children) - { - child.RemoveAllAppendedTextAnnotations(); - } - } - - /// Recursively remove any existing differential constraint annotations from the specified snapshot elements and all their children. - internal static void RemoveAllAppendedTextAnnotations(this IEnumerable elements) where T : Base - { - if (elements == null) { throw Error.ArgumentNull(nameof(elements)); } - foreach (var elem in elements) - { - elem.RemoveAllAppendedTextAnnotations(); - } - } - - public static bool HasAppendedText(this Element elem) => elem != null && elem.HasAnnotation(); - - #endregion } } diff --git a/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs b/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs index f772b04fcc..fd01df3071 100644 --- a/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs +++ b/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs @@ -2564,10 +2564,8 @@ private static bool isAlmostExactly(ElementDefinition elem, ElementDefinition ba // Also ignore any Changed extensions on base and diff elemClone.RemoveAllNonInheritableExtensions(); baseClone.RemoveAllNonInheritableExtensions(); - elemClone.RemoveAllConstrainedByDiffAnnotations(); - baseClone.RemoveAllConstrainedByDiffAnnotations(); - elemClone.RemoveAllAppendedTextAnnotations(); - baseClone.RemoveAllAppendedTextAnnotations(); + elemClone.RemoveAllSnapshotGeneratorAnnotations(); + baseClone.RemoveAllSnapshotGeneratorAnnotations(); var result = baseClone.IsExactly(elemClone); return result; From b037890f3e9392c40073435824ccc6c22f5b7bb4 Mon Sep 17 00:00:00 2001 From: brian_pos Date: Tue, 23 Sep 2025 14:07:33 +1000 Subject: [PATCH 70/78] Introduce support for CallSignatures with a dynamic or variable number of parameters (specifically for sort/coalesce) --- .../FhirPath/Expressions/CallSignature.cs | 3 ++- .../FhirPath/UnknownArgCountCallSignature.cs | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/Hl7.Fhir.Base/FhirPath/UnknownArgCountCallSignature.cs diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/CallSignature.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/CallSignature.cs index 30c40f37fc..8e763178c6 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/CallSignature.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/CallSignature.cs @@ -38,13 +38,14 @@ public bool DynamicMatches(string functionName, IEnumerable arguments) return functionName == Name && arguments.Count() == ArgumentTypes.Length && arguments.Zip(ArgumentTypes, Typecasts.CanCastTo).All(r => r == true); } + public bool DynamicExactMatches(string functionName, IEnumerable arguments) { return functionName == Name && arguments.Count() == ArgumentTypes.Length && arguments.Zip(ArgumentTypes, Typecasts.IsOfExactType).All(r => r == true); } - public bool Matches(string functionName, int argCount) + virtual public bool Matches(string functionName, int argCount) { return functionName == Name && ArgumentTypes.Length == argCount; } diff --git a/src/Hl7.Fhir.Base/FhirPath/UnknownArgCountCallSignature.cs b/src/Hl7.Fhir.Base/FhirPath/UnknownArgCountCallSignature.cs new file mode 100644 index 0000000000..0475c6a04e --- /dev/null +++ b/src/Hl7.Fhir.Base/FhirPath/UnknownArgCountCallSignature.cs @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2015, Firely (info@fire.ly) and contributors + * See the file CONTRIBUTORS for details. + * + * This file is licensed under the BSD 3-Clause license + * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Hl7.FhirPath.Expressions +{ + internal class UnknownArgCountCallSignature : CallSignature + { + public UnknownArgCountCallSignature(string name, Type returnType) + : base(name, returnType) + { + } + + override public bool Matches(string functionName, int argCount) + { + return functionName == Name; + } + } +} From 720517e89a5f7d1abb8400bc67e74d26d2f5c08a Mon Sep 17 00:00:00 2001 From: brian_pos Date: Tue, 23 Sep 2025 14:25:43 +1000 Subject: [PATCH 71/78] Include support for the newly approved coalesce and sort functionality of the fhirpath specification --- .../FhirPath/Expressions/EchoVisitor.cs | 19 +- .../FhirPath/Expressions/ExpressionNode.cs | 46 +++- .../FhirPath/Expressions/OrderedValue.cs | 33 +++ .../FhirPath/Expressions/SymbolTableInit.cs | 61 +++++ .../FhirPath/Functions/EqualityOperators.cs | 39 +++ src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs | 17 ++ .../FhirPath/Parser/Operators.cs | 22 +- .../PocoTests/FhirPathSortTests.cs | 250 ++++++++++++++++++ .../Tests/FhirPathGrammarEchoTest.cs | 14 + .../Tests/FhirPathGrammarTest.cs | 42 ++- 10 files changed, 519 insertions(+), 24 deletions(-) create mode 100644 src/Hl7.Fhir.Base/FhirPath/Expressions/OrderedValue.cs create mode 100644 src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathSortTests.cs diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/EchoVisitor.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/EchoVisitor.cs index f86665b418..b91d8ccd99 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/EchoVisitor.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/EchoVisitor.cs @@ -1,7 +1,7 @@ -/* +/* * Copyright (c) 2015, Firely (info@fire.ly) and contributors * See the file CONTRIBUTORS for details. - * + * * This file is licensed under the BSD 3-Clause license * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ @@ -17,7 +17,7 @@ namespace Hl7.FhirPath.Expressions { /// - /// + /// /// public class EchoVisitor : ExpressionVisitor { @@ -124,7 +124,7 @@ public override StringBuilder VisitConstant(ConstantExpression expression) } break; case "String": - _result.Append("'" + Functions.StringOperators.EscapeJson(t) + "'"); + _result.Append("'" + Functions.StringOperators.EscapeJson(t) + "'"); break; case "Ratio": _result.Append($"{t}"); @@ -164,6 +164,15 @@ public override StringBuilder VisitFunctionCall(FunctionCallExpression expressio OutputTrailingTokens(expression); return _result; } + if (expression is SortDirectionExpression sd) + { + sd.Focus.Accept(this); + sd.Arguments.FirstOrDefault()?.Accept(this); + OutputPrecedingTokens(sd); + _result.Append($"{sd.Op}"); + OutputTrailingTokens(expression); + return _result; + } if (expression is UnaryExpression ue) { OutputPrecedingTokens(expression); @@ -242,7 +251,7 @@ public override StringBuilder VisitVariableRef(VariableRefExpression expression) if (expression is AxisExpression ae) { // No need to output the `that` type - if (ae.AxisName == "that") + if (ae.AxisName == "that" || ae.AxisName == "this" && ae.Location == null) return _result; OutputPrecedingTokens(expression); diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/ExpressionNode.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/ExpressionNode.cs index effcf16b41..492c15d88e 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/ExpressionNode.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/ExpressionNode.cs @@ -1,7 +1,7 @@ -/* +/* * Copyright (c) 2015, Firely (info@fire.ly) and contributors * See the file CONTRIBUTORS for details. - * + * * This file is licensed under the BSD 3-Clause license * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ @@ -91,7 +91,7 @@ public virtual bool Equals(Expression other) { if (other.Location == null) return false; - var label = (ISourcePositionInfo posinfo) => + var label = (ISourcePositionInfo posinfo) => { var pi = (FhirPathExpressionLocationInfo)posinfo; return $"Line: {pi.LineNumber}, LinePos: {pi.LinePosition}, RawPos: {pi.RawPosition}, Length: {pi.Length}"; @@ -169,7 +169,7 @@ public BracketExpression(SubToken leftBrace, SubToken rightBrace, Expression ope LeftBrace = leftBrace; RightBrace = rightBrace; } - + public Expression Operand { get; private set; } /// @@ -474,6 +474,44 @@ public Expression Right BinaryExpression IPositionAware.SetPos(Position startPos, int length) => SetPos(startPos, length); } + public class SortDirectionExpression : FunctionCallExpression, Sprache.IPositionAware + { + internal const string URY_PREFIX = "unary."; + internal static readonly int URY_PREFIX_LEN = URY_PREFIX.Length; + + public SortDirectionExpression(char op, Expression operand) : this(new string(op, 1), operand) + { + } + + public SortDirectionExpression(char op, Expression operand, ISourcePositionInfo location) : this(new string(op, 1), operand, location) + { + } + + public SortDirectionExpression(string op, Expression operand) : base(AxisExpression.This, URY_PREFIX + op, TypeSpecifier.Any, operand) + { + } + + public SortDirectionExpression(string op, Expression operand, ISourcePositionInfo location) : base(AxisExpression.This, URY_PREFIX + op, TypeSpecifier.Any, operand, location) + { + } + + public string Op + { + get + { + return FunctionName.Substring(URY_PREFIX_LEN); + } + } + + public Expression Operand + { + get + { + return Focus; + } + } + SortDirectionExpression IPositionAware.SetPos(Position startPos, int length) => SetPos(startPos, length); + } public class UnaryExpression : FunctionCallExpression, Sprache.IPositionAware { diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/OrderedValue.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/OrderedValue.cs new file mode 100644 index 0000000000..90b46ce607 --- /dev/null +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/OrderedValue.cs @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2015, Firely (info@fire.ly) and contributors + * See the file CONTRIBUTORS for details. + * + * This file is licensed under the BSD 3-Clause license + * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE + */ + +using Hl7.Fhir.ElementModel; +using Hl7.Fhir.Specification; +using System.Collections; +using System.Collections.Generic; + +namespace Hl7.FhirPath.Expressions +{ + internal class OrderedValue : ITypedElement + { + public bool Descending; + public ITypedElement value; + + public string Location => value.Location; + + public IElementDefinitionSummary Definition => value.Definition; + + public string Name => value.Name; + + public string InstanceType => value.InstanceType; + + public object Value => value.Value; + + public IEnumerable Children(string name = null) => value.Children(name); + } +} diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs index 4d37e0c6ef..551c20009e 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs @@ -220,6 +220,13 @@ public static SymbolTable AddStandardFP(this SymbolTable t) t.Add(new CallSignature("defineVariable", typeof(IEnumerable), typeof(object), typeof(string)), DefineVariable); t.Add(new CallSignature("defineVariable", typeof(IEnumerable), typeof(object), typeof(string), typeof(Invokee)), DefineVariable); + // Co-alesce and sort have variable number of arguments. + t.Add(new UnknownArgCountCallSignature("coalesce", typeof(IEnumerable)), runCoalesce); + t.Add(new UnknownArgCountCallSignature("sort", typeof(IEnumerable)), runSort); + // these unary operators just inject an ordering node that includes which direction the sort if processing + t.Add("unary.asc", (object f, ITypedElement a) => ElementNode.CreateList(new OrderedValue() { value = a }), doNullProp: true); + t.Add("unary.desc", (object f, ITypedElement a) => ElementNode.CreateList(new OrderedValue() { value = a, Descending = true }), doNullProp: true); + t.Add(new CallSignature("aggregate", typeof(IEnumerable), typeof(Invokee), typeof(Invokee)), runAggregate); t.Add(new CallSignature("aggregate", typeof(IEnumerable), typeof(Invokee), typeof(Invokee), typeof(Invokee)), runAggregate); @@ -266,6 +273,60 @@ private static string getCoreValueSetUrl(string id) return "http://hl7.org/fhir/ValueSet/" + id; } + private static IEnumerable runSort(Closure ctx, IEnumerable arguments) + { + var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); + var lambda = arguments.Skip(1); + if (!lambda.Any()) + { + // Just sort using the native element comparer + // System.Linq.Enumerable.Order; + return CachedEnumerable.Create(focus.OrderBy(item => item, EqualityOperators.TypedElementComparer)); + } + + var keySelector = lambda.First(); + IOrderedEnumerable orderedResult = focus.OrderBy(item => readElement(ctx, item, keySelector).FirstOrDefault(), EqualityOperators.TypedElementComparer); + lambda = lambda.Skip(1); + while (lambda.Any()) + { + keySelector = lambda.First(); + orderedResult = orderedResult.ThenBy(item => readElement(ctx, item, keySelector).FirstOrDefault(), EqualityOperators.TypedElementComparer); + + // move onto the next item + lambda = lambda.Skip(1); + } + + return orderedResult.ToList(); + } + + private static IEnumerable readElement(Closure ctx, ITypedElement element, Invokee selectProp) + { + var newFocus = ElementNode.CreateList(element); + var newContext = ctx.Nest(newFocus); + newContext.SetThis(newFocus); + var result = selectProp(newContext, InvokeeFactory.EmptyArgs); + foreach (var resultElement in result) // implement SelectMany() + yield return resultElement; + } + + private static IEnumerable runCoalesce(Closure ctx, IEnumerable arguments) + { + var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); + var lambda = arguments.Skip(1); + + while (lambda.Any()) + { + var keySelector = lambda.First(); + var results = keySelector(ctx, InvokeeFactory.EmptyArgs); + if (results.Any()) + return results; + + // move onto the next item + lambda = lambda.Skip(1); + } + + return ElementNode.EmptyList; + } private static IEnumerable runAggregate(Closure ctx, IEnumerable arguments) { var focus = arguments.First()(ctx, InvokeeFactory.EmptyArgs); diff --git a/src/Hl7.Fhir.Base/FhirPath/Functions/EqualityOperators.cs b/src/Hl7.Fhir.Base/FhirPath/Functions/EqualityOperators.cs index 6ea56ca9c8..8fc3b00c53 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Functions/EqualityOperators.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Functions/EqualityOperators.cs @@ -243,7 +243,29 @@ public static bool IsEquivalentTo(P.Any? left, P.Any? right) } } + public static int? CompareTo(P.Any left, P.Any right) + { + // If one or both of the arguments is an empty collection, a comparison operator will return an empty collection. + // (though we might handle this more generally with the null-propagating functionality of the compiler + // framework already. + if (left == null || right == null) return null; + + // Try to convert both operands to a common type if they differ. + // When that fails, the CompareTo function on each type will itself + // report an error if they cannot handle that. + // TODO: in the end the engine/compiler will handle this and report an overload resolution fail + tryCoerce(ref left, ref right); + + if (left is P.ICqlOrderable orderable) return orderable.CompareTo(right); + + // Now, only the non-comparables are left (coding, concept, boolean). + // TODO: We should be able to retrieve the cql name of the type, not the + // dotnet type somehow. + throw new InvalidOperationException($"Values of type {left.GetType().Name} is not an ordered type and cannot be compared."); + } + public static readonly IEqualityComparer TypedElementEqualityComparer = new ValueProviderEqualityComparer(); + public static readonly IComparer TypedElementComparer = new ValueProviderComparer(); private class ValueProviderEqualityComparer : IEqualityComparer { @@ -273,5 +295,22 @@ public int GetHashCode(ITypedElement element) return result; } } + + private class ValueProviderComparer : IComparer + { + public int Compare(ITypedElement? x, ITypedElement? y) + { + if (x is null && y is null) return 0; + if (x is null) return -1; + if (y is null) return 1; + if (P.Any.TryConvert(x.Value, out var orderableX) && P.Any.TryConvert(y.Value, out var orderableY)) + { + if (x is OrderedValue ov && ov.Descending) + return -EqualityOperators.CompareTo(orderableX, orderableY) ?? 0; + return EqualityOperators.CompareTo(orderableX, orderableY) ?? 0; + } + return 0; + } + } } } diff --git a/src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs b/src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs index d4b1bf9796..55c1ee84b4 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Parser/Grammar.cs @@ -116,8 +116,25 @@ from ws4 in WhitespaceOrComments() .UsePositionFrom(n.Location); } + // Direction parser for sort function + public static readonly Parser Direction = + Parse.String("asc").Text().Or(Parse.String("desc").Text()).Named("Direction"); + public static Parser FunctionParameter(string name) { + // Special handling for sort function: allows optional direction argument + if (name == "sort") + { + return + from wsLeading in WhitespaceOrComments() + from expr in Grammar.Expression + from wsDir in WhitespaceOrComments() + from dir in Direction.Optional() + from wsTrailing in WhitespaceOrComments() + select dir.IsDefined + ? new SortDirectionExpression(dir.Get(), expr.WithLeadingWS(wsLeading)).WithLeadingWS(wsDir).WithTrailingWS(wsTrailing) + : expr.WithLeadingWS(wsLeading).WithTrailingWS(wsTrailing); + } // Make exception for is() and as() FUNCTIONS (operators are handled elsewhere), since they don't // take a normal parameter, but an identifier (which is not normally a FhirPath type) if (name != "is" && name != "as" && name != "ofType") diff --git a/src/Hl7.Fhir.Base/FhirPath/Parser/Operators.cs b/src/Hl7.Fhir.Base/FhirPath/Parser/Operators.cs index 11ad03e550..a9f53e1811 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Parser/Operators.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Parser/Operators.cs @@ -17,8 +17,26 @@ internal partial class Lexer { internal static Parser Operator(params string[] ops ) { - var first = Parse.String(ops.First()); - return ops.Skip(1).Aggregate(first, (expr, s) => expr.Or(Parse.String(s)), expr => expr.Text()); + // Need to ensure that operators don't accidentally match a part + // of an input stream. E.g. 'as' should not match 'asc'. + var parsers = ops.Select(op => + { + var baseParser = Parse.String(op); + + // For operators that are alphabetic (keywords), ensure they're followed by word boundaries + if (op.All(char.IsLetter)) + { + return from matched in baseParser + from boundary in Parse.Not(Parse.LetterOrDigit).Return("") + select matched; + } + else + { + return baseParser; + } + }); + + return parsers.Aggregate((p1, p2) => p1.Or(p2)).Text(); } internal static readonly Parser PolarityOperator = Lexer.Operator("+", "-"); diff --git a/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathSortTests.cs b/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathSortTests.cs new file mode 100644 index 0000000000..c0e949f7ad --- /dev/null +++ b/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathSortTests.cs @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2015, Firely (info@fire.ly) and contributors + * See the file CONTRIBUTORS for details. + * + * This file is licensed under the BSD 3-Clause license + * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE + */ + +// To introduce the DSTU2 FHIR specification +// extern alias dstu2; + +using FluentAssertions; +using Hl7.Fhir.ElementModel; +using Hl7.Fhir.FhirPath; +using Hl7.Fhir.Model; +using Hl7.Fhir.Specification.Source; +using Hl7.Fhir.Specification.Terminology; +using Hl7.FhirPath.Expressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks.Dataflow; +using P = Hl7.Fhir.ElementModel.Types; + +namespace Hl7.FhirPath.R4.Tests +{ + [TestClass] + public class FhirPathSortTests + { + // Initialize the test context + [TestInitialize] + public void Initialize() + { + ElementNavFhirExtensions.PrepareFhirSymbolTableFunctions(); + } + + + /// + /// Gets or sets the test context which provides + /// information about and functionality for the current test run. + /// + public TestContext TestContext { get; set; } + + + [TestMethod] + public void TestFhirPathCoalesce1() + { + FhirPathCompiler compiler = new FhirPathCompiler(); + var p = new Patient + { + Id = "pat1", + BirthDate = "1990-10-1", + Active = true, + }; + var expr = compiler.Compile("coalesce(id)"); + var result = expr(p.ToTypedElement(), new FhirEvaluationContext()); + + Assert.IsNotNull(result.FirstOrDefault()); + Assert.AreEqual(1, result.Count()); + Assert.AreEqual("pat1", result.ElementAt(0).Value); + } + + [TestMethod] + public void TestFhirPathCoalesce2() + { + FhirPathCompiler compiler = new FhirPathCompiler(); + var p = new Patient + { + Id = "pat1", + BirthDate = "1990-10-1", + Active = true, + }; + var expr = compiler.Compile("coalesce(name, id)"); + var result = expr(p.ToTypedElement(), new FhirEvaluationContext()); + + Assert.IsNotNull(result.FirstOrDefault()); + Assert.AreEqual(1, result.Count()); + Assert.AreEqual("pat1", result.ElementAt(0).Value); + } + + [TestMethod] + public void TestFhirPathCoalesce3() + { + FhirPathCompiler compiler = new FhirPathCompiler(); + var p = new Patient + { + Id = "pat1", + BirthDate = "1990-10-1", + Active = true, + }; + var expr = compiler.Compile("coalesce(name, telecom, {}, address, extension, 'five', id, birthDate)"); + var result = expr(p.ToTypedElement(), new FhirEvaluationContext()); + + Assert.IsNotNull(result.FirstOrDefault()); + Assert.AreEqual(1, result.Count()); + Assert.AreEqual("five", result.ElementAt(0).Value); + } + + [TestMethod] + public void TestFhirPathSortNone() + { + FhirPathCompiler compiler = new FhirPathCompiler(); + var expr = compiler.Compile("(1|2|3)"); + var result = expr(null, new FhirEvaluationContext()); + + Assert.IsNotNull(result.FirstOrDefault()); + Assert.AreEqual(3, result.Count()); + Assert.AreEqual(1, result.ElementAt(0).Value); + Assert.AreEqual(2, result.ElementAt(1).Value); + Assert.AreEqual(3, result.ElementAt(2).Value); + } + + [TestMethod] + public void TestFhirPathSort1() + { + FhirPathCompiler compiler = new FhirPathCompiler(); + var expr = compiler.Compile("(1|2|3).sort()"); + var result = expr(null, new FhirEvaluationContext()); + + Assert.IsNotNull(result.FirstOrDefault()); + Assert.AreEqual(3, result.Count()); + Assert.AreEqual(1, result.ElementAt(0).Value); + Assert.AreEqual(2, result.ElementAt(1).Value); + Assert.AreEqual(3, result.ElementAt(2).Value); + } + + [TestMethod] + public void TestFhirPathSort2() + { + FhirPathCompiler compiler = new FhirPathCompiler(); + var expr = compiler.Compile("(3|2|1).sort()"); + var result = expr(null, new FhirEvaluationContext()); + + Assert.IsNotNull(result.FirstOrDefault()); + Assert.AreEqual(3, result.Count()); + Assert.AreEqual(1, result.ElementAt(0).Value); + Assert.AreEqual(2, result.ElementAt(1).Value); + Assert.AreEqual(3, result.ElementAt(2).Value); + } + + [TestMethod] + public void TestFhirPathSort3() + { + FhirPathCompiler compiler = new FhirPathCompiler(); + var expr = compiler.Compile("(3|2|1).sort($this)"); + var result = expr(null, new FhirEvaluationContext()); + + Assert.IsNotNull(result.FirstOrDefault()); + Assert.AreEqual(3, result.Count()); + Assert.AreEqual(1, result.ElementAt(0).Value); + Assert.AreEqual(2, result.ElementAt(1).Value); + Assert.AreEqual(3, result.ElementAt(2).Value); + } + + [TestMethod] + public void TestFhirPathSortDescending1_numeric() + { + FhirPathCompiler compiler = new FhirPathCompiler(); + var expr = compiler.Compile("(1|2|3).sort($this desc)"); + var result = expr(null, new FhirEvaluationContext()); + + Assert.IsNotNull(result.FirstOrDefault()); + Assert.AreEqual(3, result.Count()); + Assert.AreEqual(3, result.ElementAt(0).Value); + Assert.AreEqual(2, result.ElementAt(1).Value); + Assert.AreEqual(1, result.ElementAt(2).Value); + } + + [TestMethod] + public void TestFhirPathSortDescending1_numericOddity() + { + // this isn't really using the official syntax, + // however for numerics the unary - operator works the same as the 'desc' keyword + FhirPathCompiler compiler = new FhirPathCompiler(); + var expr = compiler.Compile("(1|2|3).sort(-$this)"); + var result = expr(null, new FhirEvaluationContext()); + + Assert.IsNotNull(result.FirstOrDefault()); + Assert.AreEqual(3, result.Count()); + Assert.AreEqual(3, result.ElementAt(0).Value); + Assert.AreEqual(2, result.ElementAt(1).Value); + Assert.AreEqual(1, result.ElementAt(2).Value); + } + + [TestMethod] + public void TestFhirPathSortDescending2_alpha() + { + FhirPathCompiler compiler = new FhirPathCompiler(); + var exprString = "('a'|'b'|'c').sort($this desc)"; + var exprAST = compiler.Parse(exprString); + var expr = compiler.Compile(exprString); + var result = expr(null, new FhirEvaluationContext()); + + Assert.IsNotNull(result.FirstOrDefault()); + Assert.AreEqual(3, result.Count()); + Assert.AreEqual("c", result.ElementAt(0).Value); + Assert.AreEqual("b", result.ElementAt(1).Value); + Assert.AreEqual("a", result.ElementAt(2).Value); + } + + [TestMethod] + public void TestFhirPathSortAscending2_alpha() + { + FhirPathCompiler compiler = new FhirPathCompiler(); + var expr = compiler.Compile("('b'|'a'|'c').sort($this asc)"); + var result = expr(null, new FhirEvaluationContext()); + + Assert.IsNotNull(result.FirstOrDefault()); + Assert.AreEqual(3, result.Count()); + Assert.AreEqual("a", result.ElementAt(0).Value); + Assert.AreEqual("b", result.ElementAt(1).Value); + Assert.AreEqual("c", result.ElementAt(2).Value); + } + + [TestMethod] + public void TestFhirPathSort4() + { + var patient = new Patient() { Id = "pat1"}; + patient.Name.Add(new HumanName() { Family = "Smith", Given = new List() { "Peter", "James" } }); + FhirPathCompiler compiler = new FhirPathCompiler(); + var expr = compiler.Compile("Patient.name.given.sort()"); + var result = expr(patient.ToTypedElement(), new FhirEvaluationContext()); + + Assert.IsNotNull(result.FirstOrDefault()); + Assert.AreEqual(2, result.Count()); + Assert.AreEqual("James", result.ElementAt(0).Value); + Assert.AreEqual("Peter", result.ElementAt(1).Value); + } + + [TestMethod] + public void TestFhirPathSort5() + { + var patient = new Patient() { Id = "pat1" }; + patient.Name.Add(new HumanName() { ElementId = "1", Family = "Smith", Given = new List() { "Peter", "James" } }); + patient.Name.Add(new HumanName() { ElementId = "3", Family = "Pos", Given = new List() { "Belinda" } }); + patient.Name.Add(new HumanName() { ElementId = "2", Family = "Pos", Given = new List() { "Brian", "R" } }); + FhirPathCompiler compiler = new FhirPathCompiler(); + var expr = compiler.Compile("Patient.name.sort(family, given.first()).id"); + var result = expr(patient.ToTypedElement(), new FhirEvaluationContext()); + + Assert.IsNotNull(result.FirstOrDefault()); + Assert.AreEqual(3, result.Count()); + Assert.AreEqual("3", result.ElementAt(0).Value); + Assert.AreEqual("2", result.ElementAt(1).Value); + Assert.AreEqual("1", result.ElementAt(2).Value); + } + } +} \ No newline at end of file diff --git a/src/Hl7.FhirPath.Tests/Tests/FhirPathGrammarEchoTest.cs b/src/Hl7.FhirPath.Tests/Tests/FhirPathGrammarEchoTest.cs index 1084052367..6e0ea6c1dc 100644 --- a/src/Hl7.FhirPath.Tests/Tests/FhirPathGrammarEchoTest.cs +++ b/src/Hl7.FhirPath.Tests/Tests/FhirPathGrammarEchoTest.cs @@ -207,6 +207,20 @@ public void FhirPath_Echo_Union() AssertParser.SucceedsEcho(parser, "a| b"); } + [TestMethod] + public void FhirPath_Echo_Sort() + { + var parser = Grammar.InvocationExpression.End(); + + AssertParser.SucceedsEcho(parser, " sort ( name /* blah */ asc )"); + AssertParser.SucceedsEcho(parser, "sort()"); + AssertParser.SucceedsEcho(parser, " sort()"); + AssertParser.SucceedsEcho(parser, " sort(name)"); + AssertParser.SucceedsEcho(parser, "sort(name desc)"); + AssertParser.SucceedsEcho(parser, " sort(name asc)"); + AssertParser.SucceedsEcho(parser, " sort(name asc, tel desc)"); + } + [TestMethod] public void FhirPath_Echo_Bracket() { diff --git a/src/Hl7.FhirPath.Tests/Tests/FhirPathGrammarTest.cs b/src/Hl7.FhirPath.Tests/Tests/FhirPathGrammarTest.cs index 376128d989..0b723c749d 100644 --- a/src/Hl7.FhirPath.Tests/Tests/FhirPathGrammarTest.cs +++ b/src/Hl7.FhirPath.Tests/Tests/FhirPathGrammarTest.cs @@ -1,7 +1,7 @@ -/* +/* * Copyright (c) 2015, Firely (info@fire.ly) and contributors * See the file CONTRIBUTORS for details. - * + * * This file is licensed under the BSD 3-Clause license * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ @@ -97,7 +97,7 @@ public void FhirPath_Gramm_Term() AssertParser.SucceedsMatch(parser, "{}", NewNodeListInitExpression.Empty); AssertParser.SucceedsMatch(parser, "@2014-12-13T12:00:00+02:00", new ConstantExpression(P.DateTime.Parse("2014-12-13T12:00:00+02:00"))); AssertParser.SucceedsMatch(parser, "78 'kg'", new ConstantExpression(new P.Quantity(78m, "kg"))); - AssertParser.SucceedsMatch(parser, "10.1 'mg'", new ConstantExpression(new P.Quantity(10.1m, "mg"))); + AssertParser.SucceedsMatch(parser, "10.1 'mg'", new ConstantExpression(new P.Quantity(10.1m, "mg"))); } FhirPathExpressionLocationInfo SetLoc(int lineNo, int linePos, int rawPos, int length) @@ -143,14 +143,14 @@ public void FhirPath_LocationInfo_Function() { var parser = Grammar.Term.End(); // The length of the function includes all the way to the end of the closing brackets (not just the function name) - AssertParser.SucceedsMatch(parser, "today()", + AssertParser.SucceedsMatch(parser, "today()", new FunctionCallExpression( AxisExpression.This, - "today", + "today", new SubToken('(', SetLoc(1, 14, 13, 1)), new SubToken(')', SetLoc(1, 15, 14, 1)), - TypeSpecifier.Any, - new Expression[] { }, + TypeSpecifier.Any, + new Expression[] { }, SetLoc(1, 1, 0, 5))); } @@ -204,9 +204,9 @@ public void FhirPath_LocationInfo_Function3() public void FhirPath_LocationInfo_FunctionWithParams() { var parser = Grammar.Term.End(); - AssertParser.SucceedsMatch(parser, "doSomething('hi', 3.14)", + AssertParser.SucceedsMatch(parser, "doSomething('hi', 3.14)", new FunctionCallExpression( - AxisExpression.This, + AxisExpression.This, "doSomething", new SubToken('(', SetLoc(1, 1, 0, 23)), new SubToken(')', SetLoc(1, 1, 0, 23)), @@ -266,7 +266,7 @@ public void FhirPath_LocationInfo_Union() public void FhirPath_LocationInfo_Brackets() { var parser = Grammar.Expression.End(); - AssertParser.SucceedsMatch(parser, " ( 3 ) ", + AssertParser.SucceedsMatch(parser, " ( 3 ) ", new BracketExpression( new ConstantExpression(3, SetLoc(1, 2, 2, 1)), SetLoc(1, 3, 2, 5))); @@ -343,13 +343,13 @@ public void FhirPath_LocationInfo_ConstantDecimalQuant() public void FhirPath_LocationInfo_Identifier() { var parser = Grammar.Term.End(); - AssertParser.SucceedsMatch(parser, "ofType(Patient)", + AssertParser.SucceedsMatch(parser, "ofType(Patient)", new FunctionCallExpression( AxisExpression.This, - "ofType", + "ofType", new SubToken('(', SetLoc(1, 7, 6, 1)), new SubToken(')', SetLoc(1, 15, 14, 1)), - TypeSpecifier.Any, + TypeSpecifier.Any, new[] { new IdentifierExpression("Patient", SetLoc(1, 8, 7, 7)) }, SetLoc(1, 1, 0, 6) )); @@ -443,6 +443,22 @@ public void FhirPath_Gramm_Quantity() AssertParser.FailsMatch(parser, "4 decennia"); } + [TestMethod] + public void FhirPath_Gramm_Sort() + { + var parser = Grammar.Expression.End(); + AssertParser.SucceedsMatch(parser, "sort()", new FunctionCallExpression(AxisExpression.This, "sort", TypeSpecifier.Any)); + AssertParser.SucceedsMatch(parser, "sort(given)", new FunctionCallExpression(AxisExpression.This, "sort", TypeSpecifier.Any, [new ChildExpression(AxisExpression.This, "given")])); + AssertParser.SucceedsMatch(parser, "sort(given desc)", new FunctionCallExpression(AxisExpression.This, "sort", TypeSpecifier.Any, [new SortDirectionExpression("desc", new ChildExpression(AxisExpression.This, "given"))])); + } + + [TestMethod] + public void FhirPath_Gramm_SortAsc() + { + var parser = Grammar.Expression.End(); + AssertParser.SucceedsMatch(parser, "sort(given asc)", new FunctionCallExpression(AxisExpression.This, "sort", TypeSpecifier.Any, [new SortDirectionExpression("asc", new ChildExpression(AxisExpression.This, "given"))])); + } + [TestMethod] public void FhirPath_Gramm_Expression_Invocation() { From d495e2ba58d720154733612f8f0725ecf7c95951 Mon Sep 17 00:00:00 2001 From: Rob5045 Date: Fri, 26 Sep 2025 13:39:26 +0200 Subject: [PATCH 72/78] Moved appendTextIssueTest to shared tests. Fixed not recursively calling RemoveAllSnapshotGeneratorAnnotations. Added fix and unit test for STU3. --- .../Snapshot/ElementDefnMerger.cs | 3 +- .../Snapshot/SnapshotGeneratorAnnotations.cs | 4 +- .../Snapshot/ElementDefnMerger.cs | 37 ++++--- .../Snapshot/SnapshotGenerator.cs | 4 +- .../Snapshot/SnapshotGeneratorAnnotations.cs | 82 ++++++++++++---- ...SnapshotGeneratorMappingSuppressionTest.cs | 96 +------------------ .../Snapshot/SnapshotGeneratorTest.cs | 8 ++ .../Snapshot/SnapshotGeneratorTest.cs | 84 +++++++++++++++- .../Snapshot/TestElementBaseAnnotation.cs | 8 ++ ....Fhir.Specification.Shared.Tests.projitems | 1 + .../Snapshot/SnapshotGeneratorTest.cs | 77 +++++++++++++++ .../Snapshot/TestElementBaseAnnotation.cs | 8 ++ 12 files changed, 277 insertions(+), 135 deletions(-) create mode 100644 src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/TestElementBaseAnnotation.cs create mode 100644 src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/TestElementBaseAnnotation.cs diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs index b20723bcec..935e00eb2c 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs @@ -798,10 +798,9 @@ T mergePrimitiveElement(T snap, T diff, bool allowAppend = false) where T : P { if (diffText.StartsWith("...")) { - //var prefix = snap != null ? snap.ObjectValue as string : null; var prefix = snap?.ObjectValue as string; - if (result.HasAppendedText()) + if (snap.HasAppendedText()) { // Don't append text twice diffText = prefix; diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGeneratorAnnotations.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGeneratorAnnotations.cs index 30db229fe6..b4017293fd 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGeneratorAnnotations.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGeneratorAnnotations.cs @@ -35,7 +35,7 @@ internal static void RemoveAllSnapshotGeneratorAnnotations(this Base element) element.RemoveSnapshotGeneratorAnnotation(); foreach (var child in element.Children) { - child.RemoveSnapshotGeneratorAnnotation(); + child.RemoveAllSnapshotGeneratorAnnotations(); } } @@ -45,7 +45,7 @@ internal static void RemoveAllSnapshotGeneratorAnnotations(this IEnumerable(T snap, T diff, bool allowAppend = false) where T : { var result = (T)diff.DeepCopy(); - if (allowAppend && diff.ObjectValue is string) + var diffValue = diff.ObjectValue; + if (allowAppend && diffValue is string diffText) { - var diffText = diff.ObjectValue as string; - if (diffText.StartsWith("...")) { - // [WMR 20160719] Handle snap == null - // diffText = (snap.ObjectValue as string) + "\r\n" + diffText.Substring(3); - var prefix = snap != null ? snap.ObjectValue as string : null; - diffText = string.IsNullOrEmpty(prefix) ? - diffText.Substring(3) - : prefix + "\r\n" + diffText.Substring(3); + var prefix = snap?.ObjectValue as string; + + if (snap?.HasAppendedText() == true) + { + // Don't append text twice + diffText = prefix; + } + else + { + if (string.IsNullOrEmpty(prefix)) + { + diffText = diffText.Substring(3); + } + else + { + diffText = prefix + "\r\n" + diffText.Substring(3); + } + + // Add marker that text has been appended to prevent it being appended multiple times + // when an element has a type profile (which will result in multiple merges of the same element). + result.SetAppendedTextAnnotation(); + } } result.ObjectValue = diffText; @@ -253,9 +268,9 @@ T mergePrimitiveAttribute(T snap, T diff, bool allowAppend = false) where T : else { // Only overwrite snap value if diff actually has a value (Java validator logic) - if (diff.ObjectValue != null) + if (diffValue != null) { - result.ObjectValue = diff.ObjectValue; + result.ObjectValue = diffValue; } } // Also merge element id and extensions on primitives diff --git a/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGenerator.cs b/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGenerator.cs index 77c461537d..aed8a52c28 100644 --- a/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGenerator.cs +++ b/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGenerator.cs @@ -401,7 +401,7 @@ private async Tasks.Task> generate(StructureDefinition s // [WMR 20160915] Derived profiles should never inherit the ChangedByDiff extension from the base structure // Also remove core extensions that are not supposed to be inherited by derived profiles snapshot.RemoveAllNonInheritableExtensions(); - snapshot.Element.RemoveAllConstrainedByDiffAnnotations(); + snapshot.Element.RemoveAllSnapshotGeneratorAnnotations(); // Notify observers for (int i = 0; i < snapshot.Element.Count; i++) @@ -1317,7 +1317,7 @@ private static bool copyChildren(ElementDefinitionNavigator dest, ElementDefinit // [WMR 20160826] Never inherit Changed extension from base profile! elem.RemoveAllNonInheritableExtensions(); - elem.RemoveAllConstrainedByDiffAnnotations(); + elem.RemoveAllSnapshotGeneratorAnnotations(); // [WMR 20160902] Initialize empty ElementDefinition.Base components if necessary // [WMR 20170424] Inherit existing base components from type profile diff --git a/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGeneratorAnnotations.cs b/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGeneratorAnnotations.cs index be4257bf0e..f62c10737f 100644 --- a/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGeneratorAnnotations.cs +++ b/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGeneratorAnnotations.cs @@ -20,6 +20,67 @@ namespace Hl7.Fhir.Specification.Snapshot /// Provides support for custom annotation types used by the . public static class SnapshotGeneratorAnnotations { + #region Used internally by Snapshot Generator + + internal static void RemoveSnapshotGeneratorAnnotation(this Base element) + { + element.RemoveConstrainedByDiffAnnotation(); + element.RemoveAppendedTextAnnotation(); + } + + /// Recursively remove any existing snapshot generator annotations from the specified snapshot element and all it's children. + internal static void RemoveAllSnapshotGeneratorAnnotations(this Base element) + { + if (element == null) { throw Error.ArgumentNull(nameof(element)); } + element.RemoveSnapshotGeneratorAnnotation(); + foreach (var child in element.Children) + { + child.RemoveAllSnapshotGeneratorAnnotations(); + } + } + + /// Recursively remove any existing snapshot generator annotations from the specified snapshot elements and all their children. + internal static void RemoveAllSnapshotGeneratorAnnotations(this IEnumerable elements) where T : Base + { + if (elements == null) { throw Error.ArgumentNull(nameof(elements)); } + foreach (var elem in elements) + { + elem.RemoveAllSnapshotGeneratorAnnotations(); + } + } + + #endregion + + #region Annotation: AppendedText + + /// + /// Custom annotation for properties in the + /// that have text appended with the ... notation. + /// + [Serializable] + sealed class AppendedTextAnnotation + { + // + } + + /// Annotate the specified snapshot element to indicate that text has been appended. + internal static void SetAppendedTextAnnotation(this Base element) + { + if (element == null) { throw Error.ArgumentNull(nameof(element)); } + element.AddAnnotation(new AppendedTextAnnotation()); + } + + /// Remove any existing appended text annotation from the specified snapshot element. + internal static void RemoveAppendedTextAnnotation(this Base element) + { + if (element == null) { throw Error.ArgumentNull(nameof(element)); } + element.RemoveAnnotations(); + } + + public static bool HasAppendedText(this Element elem) => elem != null && elem.HasAnnotation(); + + #endregion + #region Annotation: Created By Snapshot Generator /// Annotation to mark a generated element, so we can prevent duplicate re-generation. @@ -66,27 +127,6 @@ internal static void RemoveConstrainedByDiffAnnotation(this Base element) element.RemoveAnnotations(); } - /// Recursively remove any existing differential constraint annotations from the specified snapshot element and all it's children. - internal static void RemoveAllConstrainedByDiffAnnotations(this Base element) - { - if (element == null) { throw Error.ArgumentNull(nameof(element)); } - element.RemoveConstrainedByDiffAnnotation(); - foreach (var child in element.Children) - { - child.RemoveAllConstrainedByDiffAnnotations(); - } - } - - /// Recursively remove any existing differential constraint annotations from the specified snapshot elements and all their children. - internal static void RemoveAllConstrainedByDiffAnnotations(this IEnumerable elements) where T : Base - { - if (elements == null) { throw Error.ArgumentNull(nameof(elements)); } - foreach (var elem in elements) - { - elem.RemoveAllConstrainedByDiffAnnotations(); - } - } - /// /// Determines if the specified element is annotated as being constrained by the differential. /// Note that this method is non-recursive; only the specified element itself is inspected, child element annotations are ignored. diff --git a/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs b/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs index 7a1375e41c..8b1097f81d 100644 --- a/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs +++ b/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs @@ -6,11 +6,9 @@ * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ -using FluentAssertions; using Hl7.Fhir.Model; using Hl7.Fhir.Specification.Snapshot; using Hl7.Fhir.Specification.Source; -using Hl7.Fhir.Utility; using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Collections.Generic; using System.Linq; @@ -20,96 +18,6 @@ namespace Hl7.Fhir.Specification.Tests [TestClass] public class SnapshotGeneratorMappingSuppressionTest { - private class ElementBaseAnnotation(ElementDefinition baseElemDef) - { - public ElementDefinition BaseElementDefinition { get; } = baseElemDef; - } - - private const string MARKDOWN_COMMENT = "Systems are not required to have markdown support, so the text should be readable without markdown processing. The markdown syntax is GFM - see https://github.github.com/gfm/"; - - [TestMethod] - public async System.Threading.Tasks.Task CodeSystemCopyrightCommentIssueTest() - { - const string copyrightComment = "... Sometimes, the copyright differs between the code system and the codes that are included. The copyright statement should clearly differentiate between these when required."; - - await copyrightCommentIssueTest("CodeSystem", mergeAppendText(MARKDOWN_COMMENT, copyrightComment)); - } - - [TestMethod] - public async System.Threading.Tasks.Task CapabilityStatementCopyrightCommentIssueTest() - { - const string copyrightComment = "..."; - - await copyrightCommentIssueTest("CapabilityStatement", mergeAppendText(MARKDOWN_COMMENT, copyrightComment)); - } - - private static async System.Threading.Tasks.Task copyrightCommentIssueTest(string resource, string expectedComment) - { - var zipSource = ZipSource.CreateValidationSource(); - var resolver = new CachedResolver(zipSource); - var settings = new SnapshotGeneratorSettings - { - ForceRegenerateSnapshots = true, - GenerateAnnotationsOnConstraints = false, - GenerateExtensionsOnConstraints = false, - GenerateElementIds = true, - GenerateSnapshotForExternalProfiles = true - }; - var sd = new StructureDefinition - { - Type = resource, - BaseDefinition = $"http://hl7.org/fhir/StructureDefinition/{resource}", - Name = $"My{resource}", - Url = $"http://example.org/fhir/StructureDefinition/My{resource}", - Derivation = StructureDefinition.TypeDerivationRule.Constraint, - Kind = StructureDefinition.StructureDefinitionKind.Resource, - Abstract = false, - FhirVersion = FHIRVersion.N5_0_0, - }; - - var generator = new SnapshotGenerator(resolver, settings); - - generator.PrepareElement += addElementBaseAnnotation; - - var elems = await generator.GenerateAsync(sd); - - generator.PrepareElement -= addElementBaseAnnotation; - - var element = elems.FirstOrDefault(e => e.ElementId == $"{resource}.copyright"); - - element.Should().NotBeNull(); - element.Comment.Should().Be(expectedComment); - - var baseElement = element.Annotation()?.BaseElementDefinition; - - baseElement.Should().NotBeNull(); - element.Comment.Should().Be(baseElement.Comment); - } - - private static string mergeAppendText(string s1, string s2) - { - if (!s2.StartsWith("...")) - return s2; - - return string.IsNullOrEmpty(s1) - ? s2[3..] - : s1 + "\r\n" + s2[3..]; - } - - private static void addElementBaseAnnotation(object sender, SnapshotElementEventArgs e) - { - var elem = e.Element; - - var ann = elem.Annotation(); - - if (ann != null) - elem.RemoveAnnotations(); - - var baseDef = e.BaseElement; - - elem.AddAnnotation(new ElementBaseAnnotation(baseDef)); - } - [TestMethod] public async System.Threading.Tasks.Task TestMappingInheritanceWithoutSuppression() { @@ -273,7 +181,7 @@ private StructureDefinition CreateDerivedProfileWithSuppressedMapping() { new Extension() { - //Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, + Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, Value = new FhirBoolean(true) } } @@ -465,7 +373,7 @@ private StructureDefinition CreateDerivedProfileWithExampleSuppression() { new Extension() { - //Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, + Url = SnapshotGeneratorExtensions.ELEMENTDEFINITION_SUPPRESS_EXT, Value = new FhirBoolean(true) } } diff --git a/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorTest.cs b/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorTest.cs index 6e4137e673..7b78ac84bf 100644 --- a/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorTest.cs +++ b/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorTest.cs @@ -8,6 +8,7 @@ using Hl7.Fhir.Model; using Hl7.Fhir.Utility; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Collections.Generic; namespace Hl7.Fhir.Specification.Tests @@ -61,5 +62,12 @@ public partial class SnapshotGeneratorTest2 } }; + [TestMethod] + public async System.Threading.Tasks.Task CapabilityStatementCopyrightCommentIssueTest() + { + const string copyrightComment = "..."; + + await appendTextIssueTest("CapabilityStatement", "copyright", mergeAppendText(MARKDOWN_COMMENT, copyrightComment)); + } } } diff --git a/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorTest.cs b/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorTest.cs index 9137710fd1..9fb53be33b 100644 --- a/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorTest.cs +++ b/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorTest.cs @@ -2435,8 +2435,8 @@ private static bool isAlmostExactly(ElementDefinition elem, ElementDefinition ba // Also ignore any Changed extensions on base and diff elemClone.RemoveAllNonInheritableExtensions(); baseClone.RemoveAllNonInheritableExtensions(); - elemClone.RemoveAllConstrainedByDiffAnnotations(); - baseClone.RemoveAllConstrainedByDiffAnnotations(); + elemClone.RemoveAllSnapshotGeneratorAnnotations(); + baseClone.RemoveAllSnapshotGeneratorAnnotations(); var result = baseClone.IsExactly(elemClone); return result; @@ -8564,6 +8564,84 @@ private static StructureDefinition changeCardinalityOnNestedContentReference() cons.Add(nestedPart); - return result; } + return result; + } + + private const string MARKDOWN_COMMENT = "Systems are not required to have markdown support, and there is considerable variation in markdown syntax, so the text should be readable without markdown processing. The preferred markdown syntax is described here: http://daringfireball.net/projects/markdown/syntax (and tests here: http://daringfireball.net/projects/downloads/MarkdownTest_1.0.zip)"; + + [TestMethod] + public async Tasks.Task CodeSystemCopyrightCommentIssueTest() + { + const string copyrightComment = "... Sometimes, the copyright differs between the code system and the codes that are included. The copyright statement should clearly differentiate between these when required."; + + await appendTextIssueTest("CodeSystem", "copyright", mergeAppendText(MARKDOWN_COMMENT, copyrightComment)); + } + + private static async Tasks.Task appendTextIssueTest(string resource, string path, string expectedComment) + { + var zipSource = ZipSource.CreateValidationSource(); + var resolver = new CachedResolver(zipSource); + var settings = new SnapshotGeneratorSettings + { + ForceRegenerateSnapshots = true, + GenerateAnnotationsOnConstraints = false, + GenerateExtensionsOnConstraints = false, + GenerateElementIds = true, + GenerateSnapshotForExternalProfiles = true + }; + var sd = new StructureDefinition + { + Type = resource, + BaseDefinition = $"http://hl7.org/fhir/StructureDefinition/{resource}", + Name = $"My{resource}", + Url = $"http://example.org/fhir/StructureDefinition/My{resource}", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + FhirVersion = ModelInfo.Version + }; + + var generator = new SnapshotGenerator(resolver, settings); + + generator.PrepareElement += addElementBaseAnnotation; + + var elems = await generator.GenerateAsync(sd); + + generator.PrepareElement -= addElementBaseAnnotation; + + var element = elems.FirstOrDefault(e => e.ElementId == $"{resource}.{path}"); + + element.Should().NotBeNull(); + element.Comment.Should().Be(expectedComment); + + var baseElement = element.Annotation()?.BaseElementDefinition; + + baseElement.Should().NotBeNull(); + element.Comment.Should().Be(baseElement.Comment); + } + + private static string mergeAppendText(string s1, string s2) + { + if (!s2.StartsWith("...")) + return s2; + + return string.IsNullOrEmpty(s1) + ? s2[3..] + : s1 + "\r\n" + s2[3..]; + } + + private static void addElementBaseAnnotation(object sender, SnapshotElementEventArgs e) + { + var elem = e.Element; + + var ann = elem.Annotation(); + + if (ann != null) + elem.RemoveAnnotations(); + + var baseDef = e.BaseElement; + + elem.AddAnnotation(new TestElementBaseAnnotation(baseDef)); + } } } \ No newline at end of file diff --git a/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/TestElementBaseAnnotation.cs b/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/TestElementBaseAnnotation.cs new file mode 100644 index 0000000000..651cab030b --- /dev/null +++ b/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/TestElementBaseAnnotation.cs @@ -0,0 +1,8 @@ +using Hl7.Fhir.Model; + +namespace Hl7.Fhir.Specification.Tests; + +public class TestElementBaseAnnotation(ElementDefinition baseElemDef) +{ + public ElementDefinition BaseElementDefinition { get; } = baseElemDef; +} \ No newline at end of file diff --git a/src/Hl7.Fhir.Specification.Shared.Tests/Hl7.Fhir.Specification.Shared.Tests.projitems b/src/Hl7.Fhir.Specification.Shared.Tests/Hl7.Fhir.Specification.Shared.Tests.projitems index d44379881d..b086bcb2ab 100644 --- a/src/Hl7.Fhir.Specification.Shared.Tests/Hl7.Fhir.Specification.Shared.Tests.projitems +++ b/src/Hl7.Fhir.Specification.Shared.Tests/Hl7.Fhir.Specification.Shared.Tests.projitems @@ -23,6 +23,7 @@ + diff --git a/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs b/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs index fd01df3071..edd47a1a84 100644 --- a/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs +++ b/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs @@ -10441,5 +10441,82 @@ public async Tasks.Task TestElementDefinitionExtensionWithoutValuePreservesBaseV Assert.IsNotNull(contentExt); Assert.AreEqual("Une nomme associe de la individuelle", (contentExt.Value as Markdown)?.Value); } + + private const string MARKDOWN_COMMENT = "Systems are not required to have markdown support, so the text should be readable without markdown processing. The markdown syntax is GFM - see https://github.github.com/gfm/"; + + [TestMethod] + public async Tasks.Task CodeSystemCopyrightCommentIssueTest() + { + const string copyrightComment = "... Sometimes, the copyright differs between the code system and the codes that are included. The copyright statement should clearly differentiate between these when required."; + + await appendTextIssueTest("CodeSystem", "copyright", mergeAppendText(MARKDOWN_COMMENT, copyrightComment)); + } + + private static async Tasks.Task appendTextIssueTest(string resource, string path, string expectedComment) + { + var zipSource = ZipSource.CreateValidationSource(); + var resolver = new CachedResolver(zipSource); + var settings = new SnapshotGeneratorSettings + { + ForceRegenerateSnapshots = true, + GenerateAnnotationsOnConstraints = false, + GenerateExtensionsOnConstraints = false, + GenerateElementIds = true, + GenerateSnapshotForExternalProfiles = true + }; + var sd = new StructureDefinition + { + Type = resource, + BaseDefinition = $"http://hl7.org/fhir/StructureDefinition/{resource}", + Name = $"My{resource}", + Url = $"http://example.org/fhir/StructureDefinition/My{resource}", + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource, + Abstract = false, + FhirVersion = EnumUtility.ParseLiteral(ModelInfo.Version) + }; + + var generator = new SnapshotGenerator(resolver, settings); + + generator.PrepareElement += addElementBaseAnnotation; + + var elems = await generator.GenerateAsync(sd); + + generator.PrepareElement -= addElementBaseAnnotation; + + var element = elems.FirstOrDefault(e => e.ElementId == $"{resource}.{path}"); + + element.Should().NotBeNull(); + element.Comment.Should().Be(expectedComment); + + var baseElement = element.Annotation()?.BaseElementDefinition; + + baseElement.Should().NotBeNull(); + element.Comment.Should().Be(baseElement.Comment); + } + + private static string mergeAppendText(string s1, string s2) + { + if (!s2.StartsWith("...")) + return s2; + + return string.IsNullOrEmpty(s1) + ? s2[3..] + : s1 + "\r\n" + s2[3..]; + } + + private static void addElementBaseAnnotation(object sender, SnapshotElementEventArgs e) + { + var elem = e.Element; + + var ann = elem.Annotation(); + + if (ann != null) + elem.RemoveAnnotations(); + + var baseDef = e.BaseElement; + + elem.AddAnnotation(new TestElementBaseAnnotation(baseDef)); + } } } \ No newline at end of file diff --git a/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/TestElementBaseAnnotation.cs b/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/TestElementBaseAnnotation.cs new file mode 100644 index 0000000000..651cab030b --- /dev/null +++ b/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/TestElementBaseAnnotation.cs @@ -0,0 +1,8 @@ +using Hl7.Fhir.Model; + +namespace Hl7.Fhir.Specification.Tests; + +public class TestElementBaseAnnotation(ElementDefinition baseElemDef) +{ + public ElementDefinition BaseElementDefinition { get; } = baseElemDef; +} \ No newline at end of file From fd0b7c0b5d6cdb3a02fd486ad87263c06622377c Mon Sep 17 00:00:00 2001 From: Rob5045 Date: Fri, 26 Sep 2025 13:44:48 +0200 Subject: [PATCH 73/78] Code cleanup --- .../Specification/Snapshot/ElementDefnMerger.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs index 935e00eb2c..961613db59 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs @@ -800,7 +800,7 @@ T mergePrimitiveElement(T snap, T diff, bool allowAppend = false) where T : P { var prefix = snap?.ObjectValue as string; - if (snap.HasAppendedText()) + if (snap?.HasAppendedText() == true) { // Don't append text twice diffText = prefix; From 5b18c0524915c6d8646921449ce5e6fc2fa442ba Mon Sep 17 00:00:00 2001 From: Rob5045 Date: Sat, 27 Sep 2025 13:19:18 +0200 Subject: [PATCH 74/78] Added check on absolute content reference when expanding elements + unit test. --- Hl7.Fhir.sln | 2 + .../Snapshot/SnapshotGenerator.cs | 38 ++++++++++--------- .../Snapshot/SnapshotGenerator.cs | 38 +++++++++++-------- .../Snapshot/SnapshotGeneratorTest.cs | 38 +++++++++++++++++++ .../Snapshot/SnapshotGeneratorTest.cs | 38 +++++++++++++++++++ 5 files changed, 121 insertions(+), 33 deletions(-) diff --git a/Hl7.Fhir.sln b/Hl7.Fhir.sln index e9dbf60882..b81e96750f 100644 --- a/Hl7.Fhir.sln +++ b/Hl7.Fhir.sln @@ -359,6 +359,8 @@ Global src\Hl7.Fhir.ElementModel.Shared.Tests\Hl7.Fhir.ElementModel.Shared.Tests.projitems*{37e47f34-d2d5-4d24-8f31-5753000bd439}*SharedItemsImports = 5 src\Hl7.Fhir.Shims.R4AndUp\Hl7.Fhir.Shims.R4AndUp.projitems*{3d696d26-c0ff-4c6c-b3b7-cffb4f74079f}*SharedItemsImports = 13 src\Hl7.Fhir.Specification.Shared.Tests\Hl7.Fhir.Specification.Shared.Tests.projitems*{3fbbe610-4595-4213-9c0c-2a6af06e5f3c}*SharedItemsImports = 5 + src\Hl7.Fhir.Shims.R4AndUp\Hl7.Fhir.Shims.R4AndUp.projitems*{41cf5ade-844c-45db-8506-181452a28f4e}*SharedItemsImports = 5 + src\Hl7.Fhir.Shims.STU3AndUp\Hl7.Fhir.Shims.STU3AndUp.projitems*{41cf5ade-844c-45db-8506-181452a28f4e}*SharedItemsImports = 5 src\Hl7.Fhir.Shims.R4AndUp\Hl7.Fhir.Shims.R4AndUp.projitems*{437ec873-7d71-497b-a7e6-7e27c020af9e}*SharedItemsImports = 5 src\Hl7.Fhir.Shims.STU3AndUp\Hl7.Fhir.Shims.STU3AndUp.projitems*{437ec873-7d71-497b-a7e6-7e27c020af9e}*SharedItemsImports = 5 src\Hl7.Fhir.ElementModel.Shared.Tests\Hl7.Fhir.ElementModel.Shared.Tests.projitems*{52a8cfd0-fc25-42db-b3aa-292f4df8b72f}*SharedItemsImports = 5 diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGenerator.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGenerator.cs index db56081b4b..1e850b6a21 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGenerator.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/SnapshotGenerator.cs @@ -2193,28 +2193,32 @@ private async Tasks.Task getStructureForContentReference(El Debug.Assert(nav != null); Debug.Assert(nav.Current != null); - var elementDef = nav.Current; - var location = elementDef.Path; + var coreType = getCoreType(nav); - var contentReference = elementDef.ContentReference; // e.g. "#Questionnaire.item" + if (string.IsNullOrEmpty(coreType)) + return null; - // [WMR 20181212] TODO: Handle logical models, where StructureDefinition.type returns an uri + var location = nav.Current.Path; - var coreType = nav.StructureDefinition?.Type - // Fall back to root element name...? - ?? ElementDefinitionNavigator.GetPathRoot(contentReference.Substring(1)); + // Try to resolve the custom element type profile reference + var coreSd = await AsyncResolver.FindStructureDefinitionForCoreTypeAsync(coreType).ConfigureAwait(false); + _ = ensureSnapshot + ? await this.ensureSnapshot(coreSd, coreType, location).ConfigureAwait(false) + : this.verifyStructure(coreSd, coreType, location); - if (!string.IsNullOrEmpty(coreType)) - { - // Try to resolve the custom element type profile reference - var coreSd = await AsyncResolver.FindStructureDefinitionForCoreTypeAsync(coreType).ConfigureAwait(false); - _ = ensureSnapshot - ? await this.ensureSnapshot(coreSd, coreType, location).ConfigureAwait(false) - : this.verifyStructure(coreSd, coreType, location); - return coreSd; - } + return coreSd; + } + + private static string getCoreType(ElementDefinitionNavigator nav) + { + if (nav.StructureDefinition?.Type != null) + return nav.StructureDefinition.Type; + + var contentReference = nav.Current.ContentReference; // e.g. "#Questionnaire.item" - return null; + return contentReference.StartsWith("#") + ? ElementDefinitionNavigator.GetPathRoot(contentReference.Substring(1)) // Fall back to root element name...? + : contentReference.Split('#').First(); // return url } private bool verifyStructure(StructureDefinition sd, string profileUrl, string location = null) diff --git a/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGenerator.cs b/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGenerator.cs index 77c461537d..7fc787a01d 100644 --- a/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGenerator.cs +++ b/src/Hl7.Fhir.STU3/Specification/Snapshot/SnapshotGenerator.cs @@ -1897,26 +1897,32 @@ private async Tasks.Task getStructureForContentReference(El Debug.Assert(nav != null); Debug.Assert(nav.Current != null); - var elementDef = nav.Current; - var location = elementDef.Path; + var coreType = getCoreType(nav); - var contentReference = elementDef.ContentReference; // e.g. "#Questionnaire.item" + if (string.IsNullOrEmpty(coreType)) + return null; - var coreType = nav.StructureDefinition?.Type - // Fall back to root element name...? - ?? ElementDefinitionNavigator.GetPathRoot(contentReference.Substring(1)); + var location = nav.Current.Path; - if (!string.IsNullOrEmpty(coreType)) - { - // Try to resolve the custom element type profile reference - var coreSd = await AsyncResolver.FindStructureDefinitionForCoreTypeAsync(coreType).ConfigureAwait(false); - _ = ensureSnapshot - ? await this.ensureSnapshot(coreSd, coreType, location).ConfigureAwait(false) - : this.verifyStructure(coreSd, coreType, location); - return coreSd; - } + // Try to resolve the custom element type profile reference + var coreSd = await AsyncResolver.FindStructureDefinitionForCoreTypeAsync(coreType).ConfigureAwait(false); + _ = ensureSnapshot + ? await this.ensureSnapshot(coreSd, coreType, location).ConfigureAwait(false) + : this.verifyStructure(coreSd, coreType, location); + + return coreSd; + } + + private static string getCoreType(ElementDefinitionNavigator nav) + { + if (nav.StructureDefinition?.Type != null) + return nav.StructureDefinition.Type; + + var contentReference = nav.Current.ContentReference; // e.g. "#Questionnaire.item" - return null; + return contentReference.StartsWith("#") + ? ElementDefinitionNavigator.GetPathRoot(contentReference.Substring(1)) // Fall back to root element name...? + : contentReference.Split('#').First(); // return url } private bool verifyStructure(StructureDefinition sd, string profileUrl, string location = null) diff --git a/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorTest.cs b/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorTest.cs index 9137710fd1..17c376e5dd 100644 --- a/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorTest.cs +++ b/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorTest.cs @@ -1340,6 +1340,44 @@ public async Tasks.Task TestExpandElement_Slice() await testExpandElement(sd, nav.Current); } + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Tasks.Task TestExpandElement_AbsoluteContentReference(bool convertToAbsolute) + { + var sd = new StructureDefinition() + { + Type = FHIRAllTypes.Composition.GetLiteral(), + BaseDefinition = ModelInfo.CanonicalUriForFhirCoreType(FHIRAllTypes.Composition), + Name = "MyComposition", + Url = $"http://example.org/fhir/StructureDefinition/MyComposition", + Abstract = false, + FhirVersion = ModelInfo.Version, + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource + }; + + var resolver = new CachedResolver(ZipSource.CreateValidationSource()); + var generator = new SnapshotGenerator(resolver, _settings); + var elements = await generator.GenerateAsync(sd); + var section = elements.FirstOrDefault(e => e.ElementId == "Composition.section.section"); + var sectionId = elements.FirstOrDefault(e => e.ElementId == "Composition.section.section.id"); + + section.Should().NotBeNull(); + sectionId.Should().BeNull(); + section.ContentReference.Should().Be("#Composition.section"); + + if (convertToAbsolute) + section.ContentReference = sd.BaseDefinition + section.ContentReference; + + var expandedElements = await generator.ExpandElementAsync(elements, section); + + expandedElements.Should().HaveCountGreaterThan(elements.Count); + + sectionId = expandedElements.FirstOrDefault(e => e.ElementId == "Composition.section.section.id"); + sectionId.Should().NotBeNull(); + } + private async Tasks.Task testExpandElement(string srcProfileUrl, string expandElemPath) { // Prepare... diff --git a/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs b/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs index dbf79f11a5..c43d31515a 100644 --- a/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs +++ b/src/Hl7.Fhir.Specification.Shared.Tests/Snapshot/SnapshotGeneratorTest.cs @@ -1455,6 +1455,44 @@ public async Tasks.Task TestExpandElement_Slice() await testExpandElement(sd, nav.Current); } + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Tasks.Task TestExpandElement_AbsoluteContentReference(bool convertToAbsolute) + { + var sd = new StructureDefinition() + { + Type = FHIRAllTypes.Composition.GetLiteral(), + BaseDefinition = ModelInfo.CanonicalUriForFhirCoreType(FHIRAllTypes.Composition), + Name = "MyComposition", + Url = $"http://example.org/fhir/StructureDefinition/MyComposition", + Abstract = false, + FhirVersion = EnumUtility.ParseLiteral(ModelInfo.Version), + Derivation = StructureDefinition.TypeDerivationRule.Constraint, + Kind = StructureDefinition.StructureDefinitionKind.Resource + }; + + var resolver = new CachedResolver(ZipSource.CreateValidationSource()); + var generator = new SnapshotGenerator(resolver, _settings); + var elements = await generator.GenerateAsync(sd); + var section = elements.FirstOrDefault(e => e.ElementId == "Composition.section.section"); + var sectionId = elements.FirstOrDefault(e => e.ElementId == "Composition.section.section.id"); + + section.Should().NotBeNull(); + sectionId.Should().BeNull(); + section.ContentReference.Should().Be("#Composition.section"); + + if (convertToAbsolute) + section.ContentReference = sd.BaseDefinition + section.ContentReference; + + var expandedElements = await generator.ExpandElementAsync(elements, section); + + expandedElements.Should().HaveCountGreaterThan(elements.Count); + + sectionId = expandedElements.FirstOrDefault(e => e.ElementId == "Composition.section.section.id"); + sectionId.Should().NotBeNull(); + } + private async Tasks.Task testExpandElement(string srcProfileUrl, string expandElemPath) { // Prepare... From 6924799c533ab3da75e9dd838d59ac652fe9f371 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 19:39:42 +0000 Subject: [PATCH 75/78] Bump BenchmarkDotNet from 0.15.2 to 0.15.4 --- updated-dependencies: - dependency-name: BenchmarkDotNet dependency-version: 0.15.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- src/Benchmarks/Benchmarks.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Benchmarks/Benchmarks.csproj b/src/Benchmarks/Benchmarks.csproj index 416da47695..c239ac97ca 100644 --- a/src/Benchmarks/Benchmarks.csproj +++ b/src/Benchmarks/Benchmarks.csproj @@ -9,7 +9,7 @@ - + From 1497277aae407e10b5533eaf13a1d8ca6a2b9a73 Mon Sep 17 00:00:00 2001 From: Kasdejong Date: Tue, 7 Oct 2025 17:11:09 +0200 Subject: [PATCH 76/78] wip --- .../FhirPath/Expressions/Invokee.cs | 6 -- .../FhirPath/FhirPathCompiler.cs | 87 +++++++++---------- 2 files changed, 43 insertions(+), 50 deletions(-) diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs index f1d3070551..ea016a8818 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs @@ -274,12 +274,6 @@ public static Invokee WrapLogic(Func, Func, bool?> func) }; } - public static Invokee Return(FocusCollection value) => (Closure ctx, IEnumerable _) => - { - ctx.focus = ctx.GetThis(); - return [value]; - }; - public static Invokee Return(FocusCollection value) => (Closure ctx, IEnumerable _) => { ctx.focus = ctx.GetThis(); diff --git a/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs b/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs index b9922ba729..60063f8bad 100644 --- a/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs +++ b/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs @@ -48,14 +48,14 @@ public Expression Parse(string expression) return parse.WasSuccessful ? parse.Value : throw new FormatException("Compilation failed: " + parse.ToString()); } - /// - /// Compiles a parsed FHIRPath expression into a delegate that can be used to evaluate the expression - /// - /// the parsed fhirpath expression to compile - /// - public CompiledExpression Compile(Expression expression) - { - Invokee inv = expression.ToEvaluator(Symbols); + /// + /// Compiles a parsed FHIRPath expression into a delegate that can be used to evaluate the expression + /// + /// the parsed fhirpath expression to compile + /// + public CompiledExpression Compile(Expression expression) + { + Invokee inv = expression.ToEvaluator(Symbols); return (focus, ctx) => { @@ -64,42 +64,41 @@ public CompiledExpression Compile(Expression expression) }; } - /// - /// Compiles a parsed FHIRPath expression into a delegate that can be used to evaluate the expression - /// - /// the parsed fhirpath expression to compile - /// Inject the required hooks into the compiled evaluator to support debug tracing via the EvaluationContext - /// - public CompiledExpression Compile(Expression expression, bool injectDebugTraceHooks) - { - Invokee inv = expression.ToEvaluator(Symbols, injectDebugTraceHooks); - - return (PocoNode focus, EvaluationContext ctx) => - { - var closure = Closure.Root(focus, ctx); - return inv(closure, InvokeeFactory.EmptyArgs); - }; - } - - /// - /// Compiles a FHIRPath expression string into a delegate that can be used to evaluate the expression - /// - /// the fhirpath expression to parse then compile - /// - public CompiledExpression Compile(string expression) - { - return Compile(Parse(expression)); - } - - /// - /// Compiles a FHIRPath expression string into a delegate that can be used to evaluate the expression - /// - /// the fhirpath expression to parse then compile - /// Inject the required hooks into the compiled evaluator to support debug tracing via the EvaluationContext - /// - public CompiledExpression Compile(string expression, bool injectDebugTraceHooks) + /// + /// Compiles a parsed FHIRPath expression into a delegate that can be used to evaluate the expression + /// + /// the parsed fhirpath expression to compile + /// Inject the required hooks into the compiled evaluator to support debug tracing via the EvaluationContext + /// + public CompiledExpression Compile(Expression expression, bool injectDebugTraceHooks) + { + Invokee inv = expression.ToEvaluator(Symbols, injectDebugTraceHooks); + + return (PocoNode focus, EvaluationContext ctx) => { - return Compile(Parse(expression), injectDebugTraceHooks); - } + var closure = Closure.Root(focus, ctx); + return inv(closure, InvokeeFactory.EmptyArgs); + }; + } + + /// + /// Compiles a FHIRPath expression string into a delegate that can be used to evaluate the expression + /// + /// the fhirpath expression to parse then compile + /// + public CompiledExpression Compile(string expression) + { + return Compile(Parse(expression)); + } + + /// + /// Compiles a FHIRPath expression string into a delegate that can be used to evaluate the expression + /// + /// the fhirpath expression to parse then compile + /// Inject the required hooks into the compiled evaluator to support debug tracing via the EvaluationContext + /// + public CompiledExpression Compile(string expression, bool injectDebugTraceHooks) + { + return Compile(Parse(expression), injectDebugTraceHooks); } } \ No newline at end of file From 639839786a84a42c212c430b3784998911469819 Mon Sep 17 00:00:00 2001 From: Kasdejong Date: Thu, 9 Oct 2025 13:51:01 +0200 Subject: [PATCH 77/78] completed forward merge --- .../FhirPath/DiagnosticsDebugTracer.cs | 34 ++++---- .../FhirPath/Expressions/Closure.cs | 8 +- .../FhirPath/Expressions/EvaluatorVisitor.cs | 2 +- .../FhirPath/Expressions/Invokee.cs | 21 +++-- .../FhirPath/Expressions/OrderedNode.cs | 20 +++++ .../FhirPath/Expressions/OrderedValue.cs | 33 -------- .../FhirPath/Expressions/SymbolTableInit.cs | 17 ++-- .../FhirPath/FhirPathCompiler.cs | 1 + .../FhirPath/Functions/EqualityOperators.cs | 2 +- src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs | 13 ++-- .../elementmodel/FhirJsonBuilder.cs | 4 +- .../elementmodel/FhirJsonNode.cs | 6 +- .../Snapshot/SnapshotGeneratorExtensions.cs | 2 +- .../Snapshot/ElementDefnMerger.cs | 2 +- src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs | 11 +-- .../Snapshot/ElementDefnMerger.cs | 4 +- .../FhirJsonParserTests.cs | 59 +------------- .../Snapshot/SnapshotGeneratorAnnotations.cs | 4 +- .../Model/ModelInfo.cs | 2 + ...SnapshotGeneratorMappingSuppressionTest.cs | 4 +- ...SnapshotGeneratorMappingSuppressionTest.cs | 4 +- ...SnapshotGeneratorMappingSuppressionTest.cs | 4 +- ...SnapshotGeneratorMappingSuppressionTest.cs | 4 +- src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs | 78 ++++++++++--------- .../PocoTests/FhirPathEvaluatorTest.cs | 4 +- .../PocoTests/FhirPathSortTests.cs | 74 +++++++++--------- .../Tests/BasicFunctionTests.cs | 9 +-- 27 files changed, 180 insertions(+), 246 deletions(-) create mode 100644 src/Hl7.Fhir.Base/FhirPath/Expressions/OrderedNode.cs delete mode 100644 src/Hl7.Fhir.Base/FhirPath/Expressions/OrderedValue.cs diff --git a/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs b/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs index ef6016f98f..efa03cb879 100644 --- a/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs +++ b/src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs @@ -9,11 +9,13 @@ #nullable enable using Hl7.Fhir.ElementModel; +using Hl7.Fhir.Model; using Hl7.FhirPath.Expressions; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; namespace Hl7.FhirPath { @@ -23,12 +25,12 @@ public class DiagnosticsDebugTracer : IDebugTracer public void TraceCall( Expression expr, int contextId, - IEnumerable? focus, - IEnumerable? thisValue, - ITypedElement? index, - IEnumerable totalValue, - IEnumerable result, - IEnumerable>> variables) + IEnumerable? focus, + IEnumerable? thisValue, + PocoNode? index, + IEnumerable totalValue, + IEnumerable result, + IEnumerable>> variables) { DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, variables); } @@ -36,12 +38,12 @@ public void TraceCall( public static void DebugTraceCall( Expression expr, int contextId, - IEnumerable? focus, - IEnumerable? thisValue, - ITypedElement? index, - IEnumerable totalValue, - IEnumerable result, - IEnumerable>> variables) + IEnumerable? focus, + IEnumerable? thisValue, + PocoNode? index, + IEnumerable totalValue, + IEnumerable result, + IEnumerable>> variables) { string exprName; @@ -144,14 +146,14 @@ public static void DebugTraceCall( } } - private static void DebugTraceValue(string exprName, ITypedElement? item) + private static void DebugTraceValue(string exprName, PocoNode? item) { if (item == null) return; // possible with a null focus to kick things off - if (item.Location == "@primitivevalue@" || item.Location == "@QuantityAsPrimitiveValue@") - Trace.WriteLine($" {exprName}:\t{item.Value}\t({item.InstanceType})"); + if (item is PrimitiveNode) + Trace.WriteLine($" {exprName}:\t{item.GetValue()}\t({item.Poco.TypeName})"); else - Trace.WriteLine($" {exprName}:\t{item.Value}\t({item.InstanceType})\t{item.Location}"); + Trace.WriteLine($" {exprName}:\t{item.GetValue()}\t({item.Poco.TypeName})\t{item.GetLocation()}"); } } } diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs index 1ab6bdcbcf..7207f1fd07 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs @@ -44,12 +44,12 @@ public Closure(Closure parent, EvaluationContext ctx) /// ensuring that argument evaluation doesn't impact the focus logged in the debug trace in other /// calls. /// - public IEnumerable focus + public IEnumerable focus { get { if (!_debugTracerActive) - return ElementNode.EmptyList; + return []; return _focus; } set @@ -60,7 +60,7 @@ public IEnumerable focus } } - private IEnumerable _focus; + private IEnumerable _focus; private bool _debugTracerActive = false; public EvaluationContext EvaluationContext { get; private set; } @@ -74,7 +74,7 @@ public static Closure Root([NotNull] PocoNodeOrList root, EvaluationContext ctx // Same thing, but we copy the resource into the root resource if we cannot infer it from the node. newContext.RootResource ??= root.GetRootResourceContext(); - var newClosure = new Closure() { EvaluationContext = ctx ?? new EvaluationContext() }; + var newClosure = new Closure(ctx ?? new EvaluationContext()); foreach (var assignment in newClosure.EvaluationContext.Environment) { diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs index 1630a7fdf9..43ff66188e 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.cs @@ -54,7 +54,7 @@ public EvaluatorVisitor(SymbolTable symbols, bool injectDebugHook) public override Invokee VisitConstant(FP.ConstantExpression expression) { - return WrapForDebugTracer(InvokeeFactory.Return(PocoNode.ForAnyPrimitive(expression.Value), expression)); + return WrapForDebugTracer(InvokeeFactory.Return(PocoNode.ForAnyPrimitive(expression.Value)), expression); } public override Invokee VisitFunctionCall(FP.FunctionCallExpression expression) diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs index ea016a8818..4628a20f4c 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/Invokee.cs @@ -67,11 +67,10 @@ public static FocusCollection GetIndex(Closure context, IEnumerable arg context.focus = context.GetThis(); return context.GetIndex(); } - - private static readonly Predicate PROPAGATE_WHEN_EMPTY = focus => !focus.Any(); + private static readonly Predicate PROPAGATE_NEVER = _ => false; - private static readonly Predicate PROPAGATE_EMPTY_PRIMITIVE = focus => + private static readonly Predicate PROPAGATE_EMPTY = focus => { var first = focus.FirstOrDefault(); return first is null or PrimitiveNode { Value: null }; @@ -97,7 +96,7 @@ public static Invokee Wrap(Func func, bool propNull) { var focus = args.First()(ctx, EmptyArgs); ctx.focus = focus; - if (getPropagator(propNull, typeof(A))(focus)) + if (getPropagator(propNull)(focus)) return []; return Typecasts.CastTo(func(Typecasts.CastTo(focus))); } @@ -124,7 +123,7 @@ internal static Invokee WrapWithPropNullForFocus(Func fu ctx.focus = focus; // Check for null propagation condition - if (getPropagator(true, typeof(A))(focus)) return []; + if (getPropagator(true)(focus)) return []; // For the actual function execution, we need a new Invokee that handles the arguments // but doesn't modify the focus for debug tracing @@ -132,12 +131,12 @@ internal static Invokee WrapWithPropNullForFocus(Func fu if (typeof(B) != typeof(EvaluationContext)) { var argA = args.Skip(1).First()(ctx, EmptyArgs); - if (getPropagator(false, typeof(B))(argA)) return []; + if (getPropagator(false)(argA)) return []; if (typeof(C) != typeof(EvaluationContext)) { var argB = args.Skip(2).First()(ctx, EmptyArgs); - if (getPropagator(false, typeof(C))(argB)) return []; + if (getPropagator(false)(argB)) return []; return Typecasts.CastTo(func(Typecasts.CastTo(focus), Typecasts.CastTo(argA), @@ -157,7 +156,7 @@ internal static Invokee WrapWithPropNullForFocus(Func fu if (typeof(C) != typeof(EvaluationContext)) { var argB = args.Skip(2).First()(ctx, EmptyArgs); - if (getPropagator(false, typeof(C))(argB)) return []; + if (getPropagator(false)(argB)) return []; return Typecasts.CastTo(func(Typecasts.CastTo(focus), argA, @@ -179,7 +178,7 @@ public static Invokee Wrap(Func func, bool propNull) { var focus = args.First()(ctx, EmptyArgs); ctx.focus = focus; - if (getPropagator(propNull, typeof(A))(focus)) return []; + if (getPropagator(propNull)(focus)) return []; if (typeof(B) != typeof(EvaluationContext)) { @@ -202,7 +201,7 @@ public static Invokee Wrap(Func func, bool propNull) { var focus = args.First()(ctx, EmptyArgs); ctx.focus = focus; - if (getPropagator(propNull,typeof(A))(focus)) return []; + if (getPropagator(propNull)(focus)) return []; var argA = args.Skip(1).First()(ctx, EmptyArgs); if (getPropagator(propNull)(argA)) return []; @@ -230,7 +229,7 @@ public static Invokee Wrap(Func func, bool propNul { var focus = args.First()(ctx, EmptyArgs); ctx.focus = focus; - if (getPropagator(propNull, typeof(A))(focus)) return []; + if (getPropagator(propNull)(focus)) return []; var argA = args.Skip(1).First()(ctx, EmptyArgs); if (getPropagator(propNull)(argA)) return []; diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/OrderedNode.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/OrderedNode.cs new file mode 100644 index 0000000000..b92f6ed194 --- /dev/null +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/OrderedNode.cs @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2015, Firely (info@fire.ly) and contributors + * See the file CONTRIBUTORS for details. + * + * This file is licensed under the BSD 3-Clause license + * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE + */ + +using Hl7.Fhir.Model; +using System.Collections.Generic; +using System.Linq; + +namespace Hl7.FhirPath.Expressions; + +internal record OrderedNode(PrimitiveType Primitive, PocoNodeOrList ParentNode, int? Index, string Name = null, bool Descending = false) : PrimitiveNode(Primitive, ParentNode, Index, Name) +{ + internal static OrderedNode FromPrimitiveNode(PocoNode primitiveNode, bool descending = false) => + new((PrimitiveType)primitiveNode.Poco, primitiveNode.Parent, primitiveNode.Index, primitiveNode.Name, descending); +} + diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/OrderedValue.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/OrderedValue.cs deleted file mode 100644 index 90b46ce607..0000000000 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/OrderedValue.cs +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2015, Firely (info@fire.ly) and contributors - * See the file CONTRIBUTORS for details. - * - * This file is licensed under the BSD 3-Clause license - * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE - */ - -using Hl7.Fhir.ElementModel; -using Hl7.Fhir.Specification; -using System.Collections; -using System.Collections.Generic; - -namespace Hl7.FhirPath.Expressions -{ - internal class OrderedValue : ITypedElement - { - public bool Descending; - public ITypedElement value; - - public string Location => value.Location; - - public IElementDefinitionSummary Definition => value.Definition; - - public string Name => value.Name; - - public string InstanceType => value.InstanceType; - - public object Value => value.Value; - - public IEnumerable Children(string name = null) => value.Children(name); - } -} diff --git a/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs b/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs index 0e43158218..f749940b67 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Expressions/SymbolTableInit.cs @@ -223,8 +223,8 @@ public static SymbolTable AddStandardFP(this SymbolTable t) t.Add(new UnknownArgCountCallSignature("coalesce", typeof(IEnumerable)), runCoalesce); t.Add(new UnknownArgCountCallSignature("sort", typeof(IEnumerable)), runSort); // these unary operators just inject an ordering node that includes which direction the sort if processing - t.Add("unary.asc", (object f, ITypedElement a) => ElementNode.CreateList(new OrderedValue() { value = a }), doNullProp: true); - t.Add("unary.desc", (object f, ITypedElement a) => ElementNode.CreateList(new OrderedValue() { value = a, Descending = true }), doNullProp: true); + t.Add("unary.asc", (object f, PocoNode a) => OrderedNode.FromPrimitiveNode(a), doNullProp: true); + t.Add("unary.desc", (object f, PocoNode a) => OrderedNode.FromPrimitiveNode(a, true), doNullProp: true); t.Add(new CallSignature("aggregate", typeof(IEnumerable), typeof(Invokee), typeof(Invokee)), runAggregate); t.Add(new CallSignature("aggregate", typeof(IEnumerable), typeof(Invokee), typeof(Invokee), typeof(Invokee)), runAggregate); @@ -249,14 +249,14 @@ internal static void AddBuiltinChildren(this SymbolTable table) { table.Add(new CallSignature("builtin.children", typeof(IEnumerable), - typeof(IEnumerable), + typeof(IEnumerable), typeof(string)), ( ctx, invokees) => { var iks = invokees.ToArray(); var focus = iks[0].Invoke(ctx, InvokeeFactory.EmptyArgs); ctx.focus = focus; - var name = (string?)iks[1].Invoke(ctx, InvokeeFactory.EmptyArgs).First().Value; + var name = (string)iks[1].Invoke(ctx, InvokeeFactory.EmptyArgs).First().GetValue(); var result= focus.Navigate(name); return result; @@ -299,11 +299,10 @@ private static IEnumerable runSort(Closure ctx, IEnumerable a return orderedResult.ToList(); } - private static IEnumerable readElement(Closure ctx, ITypedElement element, Invokee selectProp) + private static IEnumerable readElement(Closure ctx, PocoNode element, Invokee selectProp) { - var newFocus = ElementNode.CreateList(element); - var newContext = ctx.Nest(newFocus); - newContext.SetThis(newFocus); + var newContext = ctx.Nest(element); + newContext.SetThis(element); var result = selectProp(newContext, InvokeeFactory.EmptyArgs); foreach (var resultElement in result) // implement SelectMany() yield return resultElement; @@ -344,7 +343,7 @@ private static IEnumerable runAggregate(Closure ctx, IEnumerable newFocus = [element]; + IEnumerable newFocus = element; var newContext = totalContext.Nest(newFocus); newContext.focus = newFocus; newContext.SetThis(newFocus); diff --git a/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs b/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs index 60063f8bad..aeb1272d4e 100644 --- a/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs +++ b/src/Hl7.Fhir.Base/FhirPath/FhirPathCompiler.cs @@ -7,6 +7,7 @@ */ +using Hl7.Fhir.Model; using Hl7.FhirPath.Expressions; using Hl7.FhirPath.Parser; using Hl7.FhirPath.Sprache; diff --git a/src/Hl7.Fhir.Base/FhirPath/Functions/EqualityOperators.cs b/src/Hl7.Fhir.Base/FhirPath/Functions/EqualityOperators.cs index 2b69f3405c..9d86c66725 100644 --- a/src/Hl7.Fhir.Base/FhirPath/Functions/EqualityOperators.cs +++ b/src/Hl7.Fhir.Base/FhirPath/Functions/EqualityOperators.cs @@ -322,7 +322,7 @@ public int Compare(ITypedElement? x, ITypedElement? y) if (y is null) return 1; if (P.Any.TryConvert(x.Value, out var orderableX) && P.Any.TryConvert(y.Value, out var orderableY)) { - if (x is OrderedValue ov && ov.Descending) + if (x is OrderedNode opn && opn.Descending) return -EqualityOperators.CompareTo(orderableX, orderableY) ?? 0; return EqualityOperators.CompareTo(orderableX, orderableY) ?? 0; } diff --git a/src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs b/src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs index 62953a33c1..5f02be93ca 100644 --- a/src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs +++ b/src/Hl7.Fhir.Base/FhirPath/IDebugTracer.cs @@ -6,6 +6,7 @@ * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE */ using Hl7.Fhir.ElementModel; +using Hl7.Fhir.Model; using Hl7.FhirPath.Expressions; using System.Collections.Generic; @@ -18,11 +19,11 @@ public interface IDebugTracer { void TraceCall(Expression expr, int contextId, - IEnumerable focus, - IEnumerable thisValue, - ITypedElement index, - IEnumerable totalValue, - IEnumerable result, - IEnumerable>> variables); + IEnumerable focus, + IEnumerable thisValue, + PocoNode index, + IEnumerable totalValue, + IEnumerable result, + IEnumerable>> variables); } } diff --git a/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonBuilder.cs b/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonBuilder.cs index 6b8cb1a296..89fd2073aa 100644 --- a/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonBuilder.cs +++ b/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonBuilder.cs @@ -17,7 +17,7 @@ namespace Hl7.Fhir.Serialization { - internal class FhirJsonBuilder : IExceptionSource + internal class FhirJsonBuilder(bool preserveWhiteSpaceInValues = false) : IExceptionSource { private bool _roundtripMode = true; @@ -189,7 +189,7 @@ private void addChildren(ITypedElement node, JObject parent) private JValue buildValue(object value, string requiredType = null) => value switch { bool or decimal or Int32 or Int16 or ulong or double or BigInteger or float => new JValue(value), - string s => _settings.TrimWhiteSpacesInJson ? new JValue(s.Trim()) : new JValue(s), + string s => preserveWhiteSpaceInValues ? new JValue(s) : new JValue(s.Trim()), long l when requiredType is "integer" or "unsignedInt" or "positiveInt" => new JValue(l), _ => new JValue(PrimitiveTypeConverter.ConvertTo(value)), }; diff --git a/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonNode.cs b/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonNode.cs index 8c19f03ff9..91de9774d4 100644 --- a/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonNode.cs +++ b/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonNode.cs @@ -19,7 +19,7 @@ namespace Hl7.Fhir.Serialization { public partial class FhirJsonNode : ISourceNode, IResourceTypeSupplier, IAnnotated, IExceptionSource { - internal FhirJsonNode(JObject root, string nodeName, FhirJsonParsingSettings settings = null) + internal FhirJsonNode(JObject root, string nodeName, FhirJsonParsingSettings settings = null, bool preserveWhiteSpaceInValues = false) { JsonObject = root ?? throw Error.ArgumentNull(nameof(root)); Name = (nodeName ?? (string.IsNullOrEmpty(ResourceType) ? null : ResourceType)) @@ -30,6 +30,7 @@ internal FhirJsonNode(JObject root, string nodeName, FhirJsonParsingSettings set JsonValue = null; ArrayIndex = null; UsesShadow = false; + PreserveWhiteSpaceInValues = preserveWhiteSpaceInValues; _settings = settings?.Clone() ?? new FhirJsonParsingSettings(); } @@ -51,6 +52,7 @@ private FhirJsonNode(FhirJsonNode parent, string name, JValue value, JObject con public readonly JObject JsonObject; public readonly int? ArrayIndex; public readonly bool UsesShadow; + public readonly bool PreserveWhiteSpaceInValues; public string Name { get; private set; } public string Location { get; private set; } @@ -181,7 +183,7 @@ public string Text if (value is string s) { - return this._settings.PreserveWhitespaceInValues ? s : s.Trim(); + return PreserveWhiteSpaceInValues ? s : s.Trim(); } return PrimitiveTypeConverter.ConvertTo(value); diff --git a/src/Hl7.Fhir.Base/Specification/Snapshot/SnapshotGeneratorExtensions.cs b/src/Hl7.Fhir.Base/Specification/Snapshot/SnapshotGeneratorExtensions.cs index 5f50f8100b..213730bd61 100644 --- a/src/Hl7.Fhir.Base/Specification/Snapshot/SnapshotGeneratorExtensions.cs +++ b/src/Hl7.Fhir.Base/Specification/Snapshot/SnapshotGeneratorExtensions.cs @@ -71,7 +71,7 @@ public static void RemoveAllConstrainedByDiffExtensions(this Element element) { if (element == null) { throw Error.ArgumentNull(nameof(element)); } element.RemoveConstrainedByDiffExtension(); - foreach (var child in element.Children.OfType()) + foreach (var child in element.Children().OfType()) { child.RemoveAllConstrainedByDiffExtensions(); } diff --git a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs index a286f6496d..7ece85ae35 100644 --- a/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.Conformance/Specification/Snapshot/ElementDefnMerger.cs @@ -459,7 +459,7 @@ static bool matchExtensionsWithTranslation(Extension x, Extension y) where T static string getExtensionString(Extension extension, string url) { var subExtension = extension.Extension?.FirstOrDefault(e => e.Url == url); - return (subExtension?.Value as PrimitiveType)?.ObjectValue as string; + return (subExtension?.Value as PrimitiveType)?.JsonValue as string; } List mergeConstraints( diff --git a/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs b/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs index 1d164b2132..3cdd1ac735 100644 --- a/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs +++ b/src/Hl7.Fhir.R4.Tests/Model/ModelTests.cs @@ -88,16 +88,7 @@ public void ValidatePatientWithDataAbsentExtension() // This exact line was failing with "Object reference not set to an instance of an object" // in netstandard2.0 and earlier .NET versions, due to GetHashCode() being called // on primitive types with null values during validation - patient.Validate(true); // Should not throw NullReferenceException anymore - - // Ensure patient.Validate(false) still works as it did before - patient.Validate(false); // This was working before the fix - - // Also test with TryValidate to ensure both validation paths work - ICollection results = new List(); - bool isValid = DotNetAttributeValidation.TryValidate(patient, results, true); - // The validation may or may not pass (depends on other validation rules), - // but it should not throw an exception + patient.Validate(); // Should not throw NullReferenceException anymore } [TestMethod] diff --git a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs index 6a66245238..f1cfa83bae 100644 --- a/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs +++ b/src/Hl7.Fhir.STU3/Specification/Snapshot/ElementDefnMerger.cs @@ -270,7 +270,7 @@ T mergePrimitiveAttribute(T snap, T diff, bool allowAppend = false) where T : // Only overwrite snap value if diff actually has a value (Java validator logic) if (diffValue != null) { - result.ObjectValue = diffValue; + result.JsonValue = diffValue; } } // Also merge element id and extensions on primitives @@ -733,7 +733,7 @@ static bool matchExtensionsWithTranslation(Extension x, Extension y) where T static string getExtensionString(Extension extension, string url) { var subExtension = extension.Extension?.FirstOrDefault(e => e.Url == url); - return (subExtension?.Value as PrimitiveType)?.ObjectValue as string; + return (subExtension?.Value as PrimitiveType)?.JsonValue as string; } static bool isEqualString(string x, string y) => StringComparer.Ordinal.Equals(x, y); diff --git a/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs b/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs index 3abd98a773..272ed4563e 100644 --- a/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs +++ b/src/Hl7.Fhir.Serialization.Shared.Tests/FhirJsonParserTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using Hl7.Fhir.Model; using Hl7.Fhir.Serialization; +using Hl7.Fhir.Validation; using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Linq; @@ -18,62 +19,8 @@ public void FhirJsonParser_WillKeepWhitespace() "id": " whitespace " } """; - var res = new FhirJsonParser(new() - { - PreserveWhitespaceInValues = true - }).Parse(json); - - res.Id.Should().Be(" whitespace "); - } - - [TestMethod] - public void FhirJsonParser_TrimsWhitespaceByDefault() - { - string json = """ - { - "resourceType": "Patient", - "id": " whitespace " - } - """; - var res = new FhirJsonParser().Parse(json); - - res.Id.Should().Be("whitespace"); - } - - [TestMethod] - public void FhirJsonParserSerializer_KeepsWhitespace() - { - string json = """ - { - "resourceType": "Practitioner", - "id": " resourceID", - "identifier": [ - { - "use": "usual", - "type": { - "text": "INTERNAL" - }, - "system": "urn:oid:1.2.840.114350.1.13.211.3.7.2.697780", - "value": " identifier" - } - ], - "active": true, - "gender": "female" - } - """; - - var res = new FhirJsonParser(new() - { - PreserveWhitespaceInValues = true - }).Parse(json); - - res.Id.Should().Be(" resourceID"); + var res = new FhirJsonDeserializer(new DeserializerSettings().Ignoring([CodedValidationException.LITERAL_INVALID_CODE])).Deserialize(json); - var internalId = res.Identifier?.FirstOrDefault(i => i.Type?.Text == "INTERNAL")?.Value; - internalId.Should().Be(" identifier"); - var str = new FhirJsonSerializer().SerializeToString(res); - str.Should().Contain("\"value\":\"identifier\""); - str = new FhirJsonSerializer(new SerializerSettings { TrimWhiteSpacesInJson = false }).SerializeToString(res); - str.Should().Contain("\"value\":\" identifier\""); + res.Id.Should().Be(" whitespace "); } } \ No newline at end of file diff --git a/src/Hl7.Fhir.Shims.Base/Specification/Snapshot/SnapshotGeneratorAnnotations.cs b/src/Hl7.Fhir.Shims.Base/Specification/Snapshot/SnapshotGeneratorAnnotations.cs index 5b95f1e887..fce3392136 100644 --- a/src/Hl7.Fhir.Shims.Base/Specification/Snapshot/SnapshotGeneratorAnnotations.cs +++ b/src/Hl7.Fhir.Shims.Base/Specification/Snapshot/SnapshotGeneratorAnnotations.cs @@ -33,7 +33,9 @@ internal static void RemoveAllSnapshotGeneratorAnnotations(this Base element) { if (element == null) { throw Error.ArgumentNull(nameof(element)); } element.RemoveSnapshotGeneratorAnnotation(); - foreach (var child in element.Children) +#pragma warning disable CS0618 // Type or member is obsolete + foreach (var child in element.Children()) +#pragma warning restore CS0618 // Type or member is obsolete { child.RemoveAllSnapshotGeneratorAnnotations(); } diff --git a/src/Hl7.Fhir.Shims.STU3AndUp/Model/ModelInfo.cs b/src/Hl7.Fhir.Shims.STU3AndUp/Model/ModelInfo.cs index 7f1c2a517d..c800f4df50 100644 --- a/src/Hl7.Fhir.Shims.STU3AndUp/Model/ModelInfo.cs +++ b/src/Hl7.Fhir.Shims.STU3AndUp/Model/ModelInfo.cs @@ -198,7 +198,9 @@ public static bool IsInstanceTypeFor(FHIRAllTypes superclass, FHIRAllTypes subcl private static readonly Lazy _modelInspector = new(() => { +#pragma warning disable CS0618 // Type or member is obsolete var inspector = ModelInspector.ForAssembly(typeof(ModelInfo).GetTypeInfo().Assembly); +#pragma warning restore CS0618 // Type or member is obsolete if (inspector.FhirRelease != Specification.FhirRelease.STU3) { // In case of release 4 or higher, also load the assembly with common conformance resources, like StructureDefinition diff --git a/src/Hl7.Fhir.Specification.R4.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs b/src/Hl7.Fhir.Specification.R4.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs index 7fb4e42bc6..c4e36039f1 100644 --- a/src/Hl7.Fhir.Specification.R4.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs +++ b/src/Hl7.Fhir.Specification.R4.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs @@ -228,7 +228,7 @@ public async System.Threading.Tasks.Task TestExampleInheritanceWithoutSuppressio var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); // Generate snapshot for the derived profile - generator.Update(derivedProfile); + await generator.UpdateAsync(derivedProfile); // Assert that the derived profile inherited the example from the base Assert.IsNotNull(derivedProfile.Snapshot); @@ -274,7 +274,7 @@ public async System.Threading.Tasks.Task TestExampleSuppressionExtension() var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); // Generate snapshot for the derived profile - generator.Update(derivedProfile); + await generator.UpdateAsync(derivedProfile); // Assert that the derived profile did NOT inherit the example (it was suppressed) Assert.IsNotNull(derivedProfile.Snapshot); diff --git a/src/Hl7.Fhir.Specification.R4B.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs b/src/Hl7.Fhir.Specification.R4B.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs index 881e7cdca3..fb33fb412f 100644 --- a/src/Hl7.Fhir.Specification.R4B.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs +++ b/src/Hl7.Fhir.Specification.R4B.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs @@ -228,7 +228,7 @@ public async System.Threading.Tasks.Task TestExampleInheritanceWithoutSuppressio var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); // Generate snapshot for the derived profile - generator.Update(derivedProfile); + await generator.UpdateAsync(derivedProfile); // Assert that the derived profile inherited the example from the base Assert.IsNotNull(derivedProfile.Snapshot); @@ -274,7 +274,7 @@ public async System.Threading.Tasks.Task TestExampleSuppressionExtension() var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); // Generate snapshot for the derived profile - generator.Update(derivedProfile); + await generator.UpdateAsync(derivedProfile); // Assert that the derived profile did NOT inherit the example (it was suppressed) Assert.IsNotNull(derivedProfile.Snapshot); diff --git a/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs b/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs index 8b1097f81d..2101564e36 100644 --- a/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs +++ b/src/Hl7.Fhir.Specification.R5.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs @@ -226,7 +226,7 @@ public async System.Threading.Tasks.Task TestExampleInheritanceWithoutSuppressio var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); // Generate snapshot for the derived profile - generator.Update(derivedProfile); + await generator.UpdateAsync(derivedProfile); // Assert that the derived profile inherited the example from the base Assert.IsNotNull(derivedProfile.Snapshot); @@ -272,7 +272,7 @@ public async System.Threading.Tasks.Task TestExampleSuppressionExtension() var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); // Generate snapshot for the derived profile - generator.Update(derivedProfile); + await generator.UpdateAsync(derivedProfile); // Assert that the derived profile did NOT inherit the example (it was suppressed) Assert.IsNotNull(derivedProfile.Snapshot); diff --git a/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs b/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs index 881e7cdca3..fb33fb412f 100644 --- a/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs +++ b/src/Hl7.Fhir.Specification.STU3.Tests/Snapshot/SnapshotGeneratorMappingSuppressionTest.cs @@ -228,7 +228,7 @@ public async System.Threading.Tasks.Task TestExampleInheritanceWithoutSuppressio var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); // Generate snapshot for the derived profile - generator.Update(derivedProfile); + await generator.UpdateAsync(derivedProfile); // Assert that the derived profile inherited the example from the base Assert.IsNotNull(derivedProfile.Snapshot); @@ -274,7 +274,7 @@ public async System.Threading.Tasks.Task TestExampleSuppressionExtension() var generator = new SnapshotGenerator(mockResolver, SnapshotGeneratorSettings.CreateDefault()); // Generate snapshot for the derived profile - generator.Update(derivedProfile); + await generator.UpdateAsync(derivedProfile); // Assert that the derived profile did NOT inherit the example (it was suppressed) Assert.IsNotNull(derivedProfile.Snapshot); diff --git a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs index 0b44a8e243..c069a4e736 100644 --- a/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs +++ b/src/Hl7.FhirPath.R4.Tests/DebugTracerTests.cs @@ -11,6 +11,7 @@ using Hl7.Fhir.ElementModel; using Hl7.Fhir.FhirPath; +using Hl7.Fhir.FhirPath.R4.Tests; using Hl7.FhirPath.Expressions; using Hl7.FhirPath.R4.Tests; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -19,6 +20,10 @@ using System.Diagnostics; using System.Linq; using System.Runtime.ExceptionServices; +using BaseExtensions = Hl7.Fhir.Model.BaseExtensions; +using PocoNode = Hl7.Fhir.Model.PocoNode; +using PocoNodeExtensions = Hl7.Fhir.Model.PocoNodeExtensions; +using PrimitiveNode = Hl7.Fhir.Model.PrimitiveNode; namespace Hl7.FhirPath.Tests { @@ -56,12 +61,12 @@ public void Assert() public void TraceCall( Expression expr, int contextId, - IEnumerable focus, - IEnumerable thisValue, - ITypedElement index, - IEnumerable totalValue, - IEnumerable result, - IEnumerable>> variables) + IEnumerable focus, + IEnumerable thisValue, + PocoNode index, + IEnumerable totalValue, + IEnumerable result, + IEnumerable>> variables) { // DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, variables); @@ -89,11 +94,11 @@ public void TraceCall( } public delegate void TraceNodeDelegate(int n, Expression expr, int contextId, - IEnumerable focus, - IEnumerable thisValue, - ITypedElement index, - IEnumerable totalValue, - IEnumerable result); + IEnumerable focus, + IEnumerable thisValue, + PocoNode index, + IEnumerable totalValue, + IEnumerable result); public TraceNodeDelegate TraceNode { get; set; } = null; public string TraceExpressionNodeName(Expression expr) @@ -140,15 +145,12 @@ public void DumpDiagnostics() } } - public string DebugTraceValue(ITypedElement? item) + public string DebugTraceValue(PocoNode item) { if (item == null) return null; // possible with a null focus to kick things off - if (item.Location == "@primitivevalue@" || item.Location == "@QuantityAsPrimitiveValue@") - return $"{item.Value}\t({item.InstanceType})"; - - return $"{item.Value}\t({item.InstanceType})\t{item.Location}"; + return $"{PocoNodeExtensions.GetValue(item)}\t({item.Poco.TypeName})\t{PocoNodeExtensions.GetLocation(item)}"; } } @@ -156,7 +158,7 @@ public string DebugTraceValue(ITypedElement? item) public void testDebugTrace_PropertyWalking() { var expression = "Patient.birthDate.toString().substring(0, 4)"; - var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); + var input = BaseExtensions.ToPocoNode(fixture.PatientExample); var tracer = new TestDebugTracer(); tracer.TraceNode = (n, expr, contextId, focus, thisValue, index, totalValue, result) => { @@ -170,25 +172,25 @@ public void testDebugTrace_PropertyWalking() { // toString Assert.AreEqual("1974-12-25\t(date)\tPatient.birthDate[0]", vFocus); - Assert.AreEqual("1974-12-25\t(System.String)", vResult); + Assert.AreEqual("1974-12-25\t(System.String)\tSystem.String", vResult); } if (n == 3) { // constant 0 Assert.AreEqual("\t(Patient)\tPatient", vFocus); - Assert.AreEqual("0\t(System.Integer)", vResult); + Assert.AreEqual("0\t(System.Integer)\tSystem.Integer", vResult); } if (n == 4) { // constant 4 Assert.AreEqual("\t(Patient)\tPatient", vFocus); - Assert.AreEqual("4\t(System.Integer)", vResult); + Assert.AreEqual("4\t(System.Integer)\tSystem.Integer", vResult); } if (n == 5) { // substring - Assert.AreEqual("1974-12-25\t(System.String)", vFocus); - Assert.AreEqual("1974\t(System.String)", vResult); + Assert.AreEqual("1974-12-25\t(System.String)\tSystem.String", vFocus); + Assert.AreEqual("1974\t(System.String)\tSystem.String", vResult); } }; var expr = compiler.Compile(expression, true); @@ -215,7 +217,7 @@ public void testDebugTrace_PropertyWalking() public void testDebugTrace_PropertyAndFunctionCalls() { var expression = "Patient.id.indexOf('am')"; - var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); + var input = BaseExtensions.ToPocoNode(fixture.PatientExample); var tracer = new TestDebugTracer(); tracer.TraceNode = (n, expr, contextId, focus, thisValue, index, totalValue, result) => { @@ -228,13 +230,13 @@ public void testDebugTrace_PropertyAndFunctionCalls() { // the context and results of the constant 'am' call Assert.AreEqual("\t(Patient)\tPatient", vFocus); - Assert.AreEqual("am\t(System.String)", vResult); + Assert.AreEqual("am\t(System.String)\tSystem.String", vResult); } if (n == 3) { // the context and results of indexOf call Assert.AreEqual("example\t(id)\tPatient.id[0]", vFocus); - Assert.AreEqual("2\t(System.Integer)", vResult); + Assert.AreEqual("2\t(integer)\tinteger", vResult); } }; var expr = compiler.Compile(expression, true); @@ -259,7 +261,7 @@ public void testDebugTrace_PropertyAndFunctionCalls() public void testDebugTrace_Aggregate() { var expression = "(1|2).aggregate($total+$this, 0)"; - var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); + var input = BaseExtensions.ToPocoNode(fixture.PatientExample); var tracer = new TestDebugTracer(); tracer.TraceNode = (n, expr, contextId, focus, thisValue, index, totalValue, result) => { @@ -314,7 +316,7 @@ public void testDebugTrace_Aggregate() public void testDebugTrace_Operator() { var expression = "Patient.id.toString() = Patient.id"; - var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); + var input = BaseExtensions.ToPocoNode(fixture.PatientExample); var tracer = new TestDebugTracer(); tracer.TraceNode = (n, expr, contextId, focus, thisValue, index, totalValue, result) => { @@ -327,7 +329,7 @@ public void testDebugTrace_Operator() // the context and results of toString call var vResult = tracer.DebugTraceValue(result?.FirstOrDefault()); Assert.AreEqual("example\t(id)\tPatient.id[0]", vFocus); - Assert.AreEqual("example\t(System.String)", vResult); + Assert.AreEqual("example\t(System.String)\tSystem.String", vResult); } }; @@ -358,7 +360,7 @@ public void testDebugTrace_WhereClause() { var expression = "name.where(use='official' or use='usual').given"; - var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); + var input = BaseExtensions.ToPocoNode(fixture.PatientExample); var tracer = new TestDebugTracer(); tracer.TraceNode = (n, expr, contextId, focus, thisValue, index, totalValue, result) => { @@ -366,7 +368,7 @@ public void testDebugTrace_WhereClause() var vThis = tracer.DebugTraceValue(thisValue?.FirstOrDefault()); var vFocus = tracer.DebugTraceValue(focus?.FirstOrDefault()); var vResult = tracer.DebugTraceValue(result?.FirstOrDefault()); - var vIndex= index?.Value; + var vIndex= PocoNodeExtensions.GetValue(index); if (n == 0) { // name @@ -412,13 +414,13 @@ public void testDebugTrace_WhereClause() tracer.DumpDiagnostics(); Assert.AreEqual(3, results.Count()); - Assert.AreEqual("Peter", results[0].Value.ToString()); - Assert.AreEqual("James", results[1].Value.ToString()); - Assert.AreEqual("Jim", results[2].Value.ToString()); + Assert.AreEqual("Peter", PocoNodeExtensions.GetValue(results[0])?.ToString()); + Assert.AreEqual("James", PocoNodeExtensions.GetValue(results[1])?.ToString()); + Assert.AreEqual("Jim", PocoNodeExtensions.GetValue(results[2])?.ToString()); - Assert.AreEqual("Patient.name[0].given[0]", results[0].Location); - Assert.AreEqual("Patient.name[0].given[1]", results[1].Location); - Assert.AreEqual("Patient.name[1].given[0]", results[2].Location); + Assert.AreEqual("Patient.name[0].given[0]", PocoNodeExtensions.GetLocation(results[0])); + Assert.AreEqual("Patient.name[0].given[1]", PocoNodeExtensions.GetLocation(results[1])); + Assert.AreEqual("Patient.name[1].given[0]", PocoNodeExtensions.GetLocation(results[2])); Assert.AreEqual(14, tracer.traceOutput.Count()); Assert.AreEqual("0,4,name: focus=1 result=2", tracer.traceOutput[0]); @@ -445,7 +447,7 @@ public void testDebugTrace_ConstantValues() { var expression = "'42'"; - var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); + var input = BaseExtensions.ToPocoNode(fixture.PatientExample); var tracer = new TestDebugTracer(); tracer.TraceNode = (n, expr, contextId, focus, thisValue, index, totalValue, result) => { @@ -471,7 +473,7 @@ public void testDebugTrace_GroupedOr() { var expression = "id='official' or id='example'"; - var input = fixture.PatientExample.ToTypedElement().ToScopedNode(); + var input = BaseExtensions.ToPocoNode(fixture.PatientExample); var tracer = new TestDebugTracer(); tracer.TraceNode = (n, expr, contextId, focus, thisValue, index, totalValue, result) => { diff --git a/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs b/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs index 9016629f71..9ebea568e6 100644 --- a/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs +++ b/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathEvaluatorTest.cs @@ -88,9 +88,9 @@ public void IsBoolean(string expr, bool result) Assert.IsTrue(IsBoolean(TestInput, expr, result)); } - public bool IsBoolean(Base baseInput, string expression, bool value, EvaluationContext? ctx = null) + public bool IsBoolean(Base baseInput, string expression, bool value, EvaluationContext ctx = null) { - var input = baseInput.ToTypedElement().ToScopedNode(); + var input = baseInput.ToPocoNode(); // Don't use the expression cache as we need to inject the debug tracer var compiler = new FhirPathCompiler(); diff --git a/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathSortTests.cs b/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathSortTests.cs index c0e949f7ad..2d8b596d4f 100644 --- a/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathSortTests.cs +++ b/src/Hl7.FhirPath.R4.Tests/PocoTests/FhirPathSortTests.cs @@ -55,11 +55,11 @@ public void TestFhirPathCoalesce1() Active = true, }; var expr = compiler.Compile("coalesce(id)"); - var result = expr(p.ToTypedElement(), new FhirEvaluationContext()); + var result = expr(p.ToPocoNode(), new FhirEvaluationContext()); Assert.IsNotNull(result.FirstOrDefault()); Assert.AreEqual(1, result.Count()); - Assert.AreEqual("pat1", result.ElementAt(0).Value); + Assert.AreEqual("pat1", result.First().GetValue()); } [TestMethod] @@ -73,11 +73,11 @@ public void TestFhirPathCoalesce2() Active = true, }; var expr = compiler.Compile("coalesce(name, id)"); - var result = expr(p.ToTypedElement(), new FhirEvaluationContext()); + var result = expr(p.ToPocoNode(), new FhirEvaluationContext()); Assert.IsNotNull(result.FirstOrDefault()); Assert.AreEqual(1, result.Count()); - Assert.AreEqual("pat1", result.ElementAt(0).Value); + Assert.AreEqual("pat1", result.First().GetValue()); } [TestMethod] @@ -91,11 +91,11 @@ public void TestFhirPathCoalesce3() Active = true, }; var expr = compiler.Compile("coalesce(name, telecom, {}, address, extension, 'five', id, birthDate)"); - var result = expr(p.ToTypedElement(), new FhirEvaluationContext()); + var result = expr(p.ToPocoNode(), new FhirEvaluationContext()); Assert.IsNotNull(result.FirstOrDefault()); Assert.AreEqual(1, result.Count()); - Assert.AreEqual("five", result.ElementAt(0).Value); + Assert.AreEqual("five", result.ElementAt(0).GetValue()); } [TestMethod] @@ -107,9 +107,9 @@ public void TestFhirPathSortNone() Assert.IsNotNull(result.FirstOrDefault()); Assert.AreEqual(3, result.Count()); - Assert.AreEqual(1, result.ElementAt(0).Value); - Assert.AreEqual(2, result.ElementAt(1).Value); - Assert.AreEqual(3, result.ElementAt(2).Value); + Assert.AreEqual(1, result.ElementAt(0).GetValue()); + Assert.AreEqual(2, result.ElementAt(1).GetValue()); + Assert.AreEqual(3, result.ElementAt(2).GetValue()); } [TestMethod] @@ -121,9 +121,9 @@ public void TestFhirPathSort1() Assert.IsNotNull(result.FirstOrDefault()); Assert.AreEqual(3, result.Count()); - Assert.AreEqual(1, result.ElementAt(0).Value); - Assert.AreEqual(2, result.ElementAt(1).Value); - Assert.AreEqual(3, result.ElementAt(2).Value); + Assert.AreEqual(1, result.ElementAt(0).GetValue()); + Assert.AreEqual(2, result.ElementAt(1).GetValue()); + Assert.AreEqual(3, result.ElementAt(2).GetValue()); } [TestMethod] @@ -135,9 +135,9 @@ public void TestFhirPathSort2() Assert.IsNotNull(result.FirstOrDefault()); Assert.AreEqual(3, result.Count()); - Assert.AreEqual(1, result.ElementAt(0).Value); - Assert.AreEqual(2, result.ElementAt(1).Value); - Assert.AreEqual(3, result.ElementAt(2).Value); + Assert.AreEqual(1, result.ElementAt(0).GetValue()); + Assert.AreEqual(2, result.ElementAt(1).GetValue()); + Assert.AreEqual(3, result.ElementAt(2).GetValue()); } [TestMethod] @@ -149,9 +149,9 @@ public void TestFhirPathSort3() Assert.IsNotNull(result.FirstOrDefault()); Assert.AreEqual(3, result.Count()); - Assert.AreEqual(1, result.ElementAt(0).Value); - Assert.AreEqual(2, result.ElementAt(1).Value); - Assert.AreEqual(3, result.ElementAt(2).Value); + Assert.AreEqual(1, result.ElementAt(0).GetValue()); + Assert.AreEqual(2, result.ElementAt(1).GetValue()); + Assert.AreEqual(3, result.ElementAt(2).GetValue()); } [TestMethod] @@ -163,9 +163,9 @@ public void TestFhirPathSortDescending1_numeric() Assert.IsNotNull(result.FirstOrDefault()); Assert.AreEqual(3, result.Count()); - Assert.AreEqual(3, result.ElementAt(0).Value); - Assert.AreEqual(2, result.ElementAt(1).Value); - Assert.AreEqual(1, result.ElementAt(2).Value); + Assert.AreEqual(3, result.ElementAt(0).GetValue()); + Assert.AreEqual(2, result.ElementAt(1).GetValue()); + Assert.AreEqual(1, result.ElementAt(2).GetValue()); } [TestMethod] @@ -179,9 +179,9 @@ public void TestFhirPathSortDescending1_numericOddity() Assert.IsNotNull(result.FirstOrDefault()); Assert.AreEqual(3, result.Count()); - Assert.AreEqual(3, result.ElementAt(0).Value); - Assert.AreEqual(2, result.ElementAt(1).Value); - Assert.AreEqual(1, result.ElementAt(2).Value); + Assert.AreEqual(3, result.ElementAt(0).GetValue()); + Assert.AreEqual(2, result.ElementAt(1).GetValue()); + Assert.AreEqual(1, result.ElementAt(2).GetValue()); } [TestMethod] @@ -195,9 +195,9 @@ public void TestFhirPathSortDescending2_alpha() Assert.IsNotNull(result.FirstOrDefault()); Assert.AreEqual(3, result.Count()); - Assert.AreEqual("c", result.ElementAt(0).Value); - Assert.AreEqual("b", result.ElementAt(1).Value); - Assert.AreEqual("a", result.ElementAt(2).Value); + Assert.AreEqual("c", result.ElementAt(0).GetValue()); + Assert.AreEqual("b", result.ElementAt(1).GetValue()); + Assert.AreEqual("a", result.ElementAt(2).GetValue()); } [TestMethod] @@ -209,9 +209,9 @@ public void TestFhirPathSortAscending2_alpha() Assert.IsNotNull(result.FirstOrDefault()); Assert.AreEqual(3, result.Count()); - Assert.AreEqual("a", result.ElementAt(0).Value); - Assert.AreEqual("b", result.ElementAt(1).Value); - Assert.AreEqual("c", result.ElementAt(2).Value); + Assert.AreEqual("a", result.ElementAt(0).GetValue()); + Assert.AreEqual("b", result.ElementAt(1).GetValue()); + Assert.AreEqual("c", result.ElementAt(2).GetValue()); } [TestMethod] @@ -221,12 +221,12 @@ public void TestFhirPathSort4() patient.Name.Add(new HumanName() { Family = "Smith", Given = new List() { "Peter", "James" } }); FhirPathCompiler compiler = new FhirPathCompiler(); var expr = compiler.Compile("Patient.name.given.sort()"); - var result = expr(patient.ToTypedElement(), new FhirEvaluationContext()); + var result = expr(patient.ToPocoNode(), new FhirEvaluationContext()); Assert.IsNotNull(result.FirstOrDefault()); Assert.AreEqual(2, result.Count()); - Assert.AreEqual("James", result.ElementAt(0).Value); - Assert.AreEqual("Peter", result.ElementAt(1).Value); + Assert.AreEqual("James", result.ElementAt(0).GetValue()); + Assert.AreEqual("Peter", result.ElementAt(1).GetValue()); } [TestMethod] @@ -238,13 +238,13 @@ public void TestFhirPathSort5() patient.Name.Add(new HumanName() { ElementId = "2", Family = "Pos", Given = new List() { "Brian", "R" } }); FhirPathCompiler compiler = new FhirPathCompiler(); var expr = compiler.Compile("Patient.name.sort(family, given.first()).id"); - var result = expr(patient.ToTypedElement(), new FhirEvaluationContext()); + var result = expr(patient.ToPocoNode(), new FhirEvaluationContext()); Assert.IsNotNull(result.FirstOrDefault()); Assert.AreEqual(3, result.Count()); - Assert.AreEqual("3", result.ElementAt(0).Value); - Assert.AreEqual("2", result.ElementAt(1).Value); - Assert.AreEqual("1", result.ElementAt(2).Value); + Assert.AreEqual("3", result.ElementAt(0).GetValue()); + Assert.AreEqual("2", result.ElementAt(1).GetValue()); + Assert.AreEqual("1", result.ElementAt(2).GetValue()); } } } \ No newline at end of file diff --git a/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs b/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs index 01789b8b40..7451ad4efb 100644 --- a/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs +++ b/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs @@ -24,7 +24,7 @@ public class BasicFunctionsTest { private static void isB(string expr, object value = null) { - ITypedElement dummy = ElementNode.ForPrimitive(value ?? true).ToScopedNode(); + var dummy = PocoNode.ForAnyPrimitive(value ?? true); var compiler = new FhirPathCompiler(); var evaluator = compiler.Compile(expr, true); Assert.IsTrue(evaluator.IsBoolean(true, dummy, new EvaluationContext() { DebugTracer = new DiagnosticsDebugTracer() })); @@ -32,15 +32,14 @@ private static void isB(string expr, object value = null) private static object scalar(string expr) { - ITypedElement dummy = ElementNode.ForPrimitive(true).ToScopedNode(); + PocoNode dummy = PocoNode.ForAnyPrimitive(true); var compiler = new FhirPathCompiler(); var evaluator = compiler.Compile(expr, true); return evaluator.Scalar(dummy, new EvaluationContext() { DebugTracer = new DiagnosticsDebugTracer() }); } - private static object scalar(ITypedElement dummy, string expr) + private static object scalar(PocoNode dummy, string expr) { - dummy = dummy.ToScopedNode(); var compiler = new FhirPathCompiler(); var evaluator = compiler.Compile(expr, true); return evaluator.Scalar(dummy, new EvaluationContext() { DebugTracer = new DiagnosticsDebugTracer() }); @@ -54,7 +53,7 @@ public void TestDynaBinding() SourceNode.Valued("child", "Hello world!"), SourceNode.Valued("child", "4")).ToTypedElementLegacy(); #pragma warning restore CS0618 // Type or member is internal - Assert.AreEqual("ello", scalar(input, @"$this.child[0].substring(1,%context.child[1].toInteger())")); + Assert.AreEqual("ello", input.Scalar(@"$this.child[0].substring(1,%context.child[1].toInteger())")); } [TestMethod] From 91eb192f322ee9851535f29a483b292eb1586450 Mon Sep 17 00:00:00 2001 From: Kasdejong Date: Tue, 14 Oct 2025 10:51:38 +0200 Subject: [PATCH 78/78] PR requested changes --- .../ElementModel/SourceNodeExtensions.Conversions.cs | 5 +++-- .../Serialization/elementmodel/FhirJsonNode.cs | 7 ++----- .../elementmodel/FhirJsonParsingSettings.cs | 10 ++++++++-- .../Snapshot/SnapshotGeneratorAnnotations.cs | 4 +--- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/Hl7.Fhir.Base/ElementModel/SourceNodeExtensions.Conversions.cs b/src/Hl7.Fhir.Base/ElementModel/SourceNodeExtensions.Conversions.cs index b9a4a330fd..73e354a3a5 100644 --- a/src/Hl7.Fhir.Base/ElementModel/SourceNodeExtensions.Conversions.cs +++ b/src/Hl7.Fhir.Base/ElementModel/SourceNodeExtensions.Conversions.cs @@ -125,14 +125,15 @@ public static string ToJson(this ISourceNode source, bool pretty = false) [Obsolete("Async support will be removed in the next major release, please use the non-async version instead")] public static async Task ToJsonAsync(this ISourceNode source, bool pretty = false) => await SerializationUtil.WriteJsonToStringAsync(source.WriteToAsync, pretty).ConfigureAwait(false); - + /// /// Serializes an instance into a . /// /// The instance to serialize. + /// Whether to preserve whitespace in string values when serializing /// Since has no type information, this function will throw unless /// the originated from parsing using . - public static JObject ToJObject(this ISourceNode source) => new FhirJsonBuilder().Build(source); + public static JObject ToJObject(this ISourceNode source, bool preserveWhiteSpaceInValues = false) => new FhirJsonBuilder(preserveWhiteSpaceInValues).Build(source); /// /// Serializes an instance into FHIR Json. diff --git a/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonNode.cs b/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonNode.cs index 91de9774d4..6cf790c6d3 100644 --- a/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonNode.cs +++ b/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonNode.cs @@ -19,7 +19,7 @@ namespace Hl7.Fhir.Serialization { public partial class FhirJsonNode : ISourceNode, IResourceTypeSupplier, IAnnotated, IExceptionSource { - internal FhirJsonNode(JObject root, string nodeName, FhirJsonParsingSettings settings = null, bool preserveWhiteSpaceInValues = false) + internal FhirJsonNode(JObject root, string nodeName, FhirJsonParsingSettings settings = null) { JsonObject = root ?? throw Error.ArgumentNull(nameof(root)); Name = (nodeName ?? (string.IsNullOrEmpty(ResourceType) ? null : ResourceType)) @@ -30,7 +30,6 @@ internal FhirJsonNode(JObject root, string nodeName, FhirJsonParsingSettings set JsonValue = null; ArrayIndex = null; UsesShadow = false; - PreserveWhiteSpaceInValues = preserveWhiteSpaceInValues; _settings = settings?.Clone() ?? new FhirJsonParsingSettings(); } @@ -52,8 +51,6 @@ private FhirJsonNode(FhirJsonNode parent, string name, JValue value, JObject con public readonly JObject JsonObject; public readonly int? ArrayIndex; public readonly bool UsesShadow; - public readonly bool PreserveWhiteSpaceInValues; - public string Name { get; private set; } public string Location { get; private set; } @@ -183,7 +180,7 @@ public string Text if (value is string s) { - return PreserveWhiteSpaceInValues ? s : s.Trim(); + return _settings.PreserveWhitespaceInValues ? s : s.Trim(); } return PrimitiveTypeConverter.ConvertTo(value); diff --git a/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonParsingSettings.cs b/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonParsingSettings.cs index 829d69f611..af3d3cf5c2 100644 --- a/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonParsingSettings.cs +++ b/src/Hl7.Fhir.Base/Serialization/elementmodel/FhirJsonParsingSettings.cs @@ -6,7 +6,6 @@ * available at https://github.com/FirelyTeam/firely-net-sdk/blob/master/LICENSE */ -#nullable enable using Hl7.Fhir.Utility; using System; @@ -32,6 +31,12 @@ public class FhirJsonParsingSettings /// Validation of xhtml is expensive, so turned off by default. public bool ValidateFhirXhtml { get; set; } // = false; + /// + /// Serialized string elements won't be trimmed of whitespaces. + /// + /// Retains values exactly as serialized. Untrimmed content is invalid for XML serialization or validation in general. + public bool PreserveWhitespaceInValues { get; set; } // = false; + /// Default constructor. Creates a new instance with default property values. public FhirJsonParsingSettings() { } @@ -53,11 +58,12 @@ public void CopyTo(FhirJsonParsingSettings other) other.PermissiveParsing = PermissiveParsing; other.AllowJsonComments = AllowJsonComments; other.ValidateFhirXhtml = ValidateFhirXhtml; + other.PreserveWhitespaceInValues = PreserveWhitespaceInValues; } /// Creates a new object that is a copy of the current instance. public FhirJsonParsingSettings Clone() => new FhirJsonParsingSettings(this); /// Creates a new instance with default property values. - public static FhirJsonParsingSettings CreateDefault() => new(); + public static FhirJsonParsingSettings CreateDefault() => new FhirJsonParsingSettings(); } \ No newline at end of file diff --git a/src/Hl7.Fhir.Shims.Base/Specification/Snapshot/SnapshotGeneratorAnnotations.cs b/src/Hl7.Fhir.Shims.Base/Specification/Snapshot/SnapshotGeneratorAnnotations.cs index fce3392136..18643628bc 100644 --- a/src/Hl7.Fhir.Shims.Base/Specification/Snapshot/SnapshotGeneratorAnnotations.cs +++ b/src/Hl7.Fhir.Shims.Base/Specification/Snapshot/SnapshotGeneratorAnnotations.cs @@ -33,9 +33,7 @@ internal static void RemoveAllSnapshotGeneratorAnnotations(this Base element) { if (element == null) { throw Error.ArgumentNull(nameof(element)); } element.RemoveSnapshotGeneratorAnnotation(); -#pragma warning disable CS0618 // Type or member is obsolete - foreach (var child in element.Children()) -#pragma warning restore CS0618 // Type or member is obsolete + foreach (var child in element.EnumerateElements().SelectMany(kvp => kvp.Value as IEnumerable ?? [(Base)kvp.Value])) { child.RemoveAllSnapshotGeneratorAnnotations(); }