diff --git a/src/Firely.Fhir.Validation/Impl/AllValidator.cs b/src/Firely.Fhir.Validation/Impl/AllValidator.cs index e48ae7ee..31913fb1 100644 --- a/src/Firely.Fhir.Validation/Impl/AllValidator.cs +++ b/src/Firely.Fhir.Validation/Impl/AllValidator.cs @@ -28,7 +28,7 @@ namespace Firely.Fhir.Validation #else [System.Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] #endif - public class AllValidator : IGroupValidatable + public class AllValidator : IGroupValidatable, IAssertionContainer { /// /// The member assertions the instance should be validated against. @@ -77,6 +77,13 @@ public AllValidator(bool shortcircuitEvaluation, params IAssertion[] members) : { } + /// + IAssertion IAssertionContainer.WithChildren(Func rewrite) + { + var members = Members.TryRewriteMembers(AssertionStep.Member(), rewrite); + return members is null ? this : new AllValidator(members, ShortcircuitEvaluation); + } + /// ResultReport IGroupValidatable.Validate( IEnumerable input, diff --git a/src/Firely.Fhir.Validation/Impl/AnyValidator.cs b/src/Firely.Fhir.Validation/Impl/AnyValidator.cs index 3537e72c..aab44ef2 100644 --- a/src/Firely.Fhir.Validation/Impl/AnyValidator.cs +++ b/src/Firely.Fhir.Validation/Impl/AnyValidator.cs @@ -27,7 +27,7 @@ namespace Firely.Fhir.Validation #else [System.Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] #endif - public class AnyValidator : IGroupValidatable + public class AnyValidator : IGroupValidatable, IAssertionContainer { /// /// The member assertions of which at least one should hold. @@ -59,6 +59,15 @@ public AnyValidator(params IAssertion[] members) : this(members.AsEnumerable(), { } + /// + /// The is not visited: it is the error reported when all members + /// fail, not an assertion this validator validates against. + IAssertion IAssertionContainer.WithChildren(Func rewrite) + { + var members = Members.TryRewriteMembers(AssertionStep.Member(), rewrite); + return members is null ? this : new AnyValidator(members, SummaryError); + } + /// ResultReport IGroupValidatable.Validate( IEnumerable input, diff --git a/src/Firely.Fhir.Validation/Impl/ChildrenValidator.cs b/src/Firely.Fhir.Validation/Impl/ChildrenValidator.cs index 1c8040c7..08507a59 100644 --- a/src/Firely.Fhir.Validation/Impl/ChildrenValidator.cs +++ b/src/Firely.Fhir.Validation/Impl/ChildrenValidator.cs @@ -31,7 +31,7 @@ namespace Firely.Fhir.Validation #else [System.Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] #endif - public class ChildrenValidator : IValidatable, IReadOnlyDictionary + public class ChildrenValidator : IValidatable, IReadOnlyDictionary, IAssertionContainer { private readonly Dictionary _childList = new(); @@ -82,6 +82,26 @@ public ChildrenValidator(IEnumerable<(string name, IAssertion assertion)> childL public IAssertion? Lookup(string name) => ChildList.TryGetValue(name, out var child) ? child : null; + /// + IAssertion IAssertionContainer.WithChildren(Func rewrite) + { + Dictionary? updated = null; + + foreach (var (name, child) in _childList) + { + var rewritten = rewrite(AssertionStep.Child(name), child); + + if (!ReferenceEquals(rewritten, child)) + { + // Only start copying once we actually have a change to record. + updated ??= new Dictionary(_childList, _childList.Comparer); + updated[name] = rewritten; + } + } + + return updated is null ? this : new ChildrenValidator(updated, AllowAdditionalChildren); + } + /// public JToken ToJson() => new JProperty("children", new JObject() { ChildList.Select(child => diff --git a/src/Firely.Fhir.Validation/Impl/DefinitionsAssertion.cs b/src/Firely.Fhir.Validation/Impl/DefinitionsAssertion.cs index 70f8c64d..f10bd801 100644 --- a/src/Firely.Fhir.Validation/Impl/DefinitionsAssertion.cs +++ b/src/Firely.Fhir.Validation/Impl/DefinitionsAssertion.cs @@ -32,7 +32,7 @@ namespace Firely.Fhir.Validation #else [System.Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] #endif - public class DefinitionsAssertion : IAssertion + public class DefinitionsAssertion : IAssertion, IAssertionContainer { /// /// The list of subschemas. @@ -61,6 +61,38 @@ public DefinitionsAssertion(IEnumerable schemas) public ElementSchema? FindFirstByAnchor(string anchor) => Schemas.FirstOrDefault(s => s.Id == "#" + anchor); + /// + /// Since the subschemas are found by anchor, a rewrite must return an + /// that kept its id - copying a schema through + /// does so. + IAssertion IAssertionContainer.WithChildren(Func rewrite) + { + ElementSchema[]? updated = null; + + for (var index = 0; index < Schemas.Count; index++) + { + var schema = Schemas[index]; + var anchor = ((string)schema.Id).TrimStart('#'); + var rewritten = rewrite(AssertionStep.Subschema(anchor), schema); + + if (ReferenceEquals(rewritten, schema)) continue; + + if (rewritten is not ElementSchema rewrittenSchema) + throw new InvalidOperationException( + $"A rewrite of subschema '{schema.Id}' must return an {nameof(ElementSchema)}, but it returned a {rewritten.GetType().Name}."); + + if ((string?)rewrittenSchema.Id != (string?)schema.Id) + throw new InvalidOperationException( + $"A rewrite of subschema '{schema.Id}' must keep the same id, but it returned a schema with id '{rewrittenSchema.Id}'."); + + // Only start copying once we actually have a change to record. + updated ??= [.. Schemas]; + updated[index] = rewrittenSchema; + } + + return updated is null ? this : new DefinitionsAssertion(updated); + } + /// public JToken ToJson() => new JProperty("definitions", new JArray( diff --git a/src/Firely.Fhir.Validation/Impl/ElementSchema.cs b/src/Firely.Fhir.Validation/Impl/ElementSchema.cs index 1fe2b001..ae503f72 100644 --- a/src/Firely.Fhir.Validation/Impl/ElementSchema.cs +++ b/src/Firely.Fhir.Validation/Impl/ElementSchema.cs @@ -22,7 +22,7 @@ namespace Firely.Fhir.Validation /// schema to be succesful. /// [DataContract] - public class ElementSchema : IGroupValidatable + public class ElementSchema : IGroupValidatable, IAssertionContainer { /// /// The unique id for this schema. @@ -75,6 +75,15 @@ private static IReadOnlyCollection extractShortcutMembers(IEnumerabl internal virtual ElementSchema WithMembers(IEnumerable members) => new(Id, members); + /// + /// Rewriting the members goes through , so + /// the copy is of the same concrete schema type and its shortcut members are recalculated. + IAssertion IAssertionContainer.WithChildren(Func rewrite) + { + var members = Members.TryRewriteMembers(AssertionStep.Member(), rewrite); + return members is null ? this : WithMembers(members); + } + internal virtual ResultReport ValidateInternal( IEnumerable input, ValidationSettings vc, diff --git a/src/Firely.Fhir.Validation/Impl/KeyedObjectValidator.cs b/src/Firely.Fhir.Validation/Impl/KeyedObjectValidator.cs index f2d7a7f3..e4476027 100644 --- a/src/Firely.Fhir.Validation/Impl/KeyedObjectValidator.cs +++ b/src/Firely.Fhir.Validation/Impl/KeyedObjectValidator.cs @@ -29,7 +29,7 @@ namespace Firely.Fhir.Validation #else [System.Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] #endif - public class KeyedObjectValidator : IValidatable + public class KeyedObjectValidator : IValidatable, IAssertionContainer { /// /// The assertion each entry (JSON property value) of the keyed object is validated against. @@ -72,6 +72,16 @@ public KeyedObjectValidator(IAssertion entryAssertion, int? min = null, int? max Max = max; } + /// + IAssertion IAssertionContainer.WithChildren(Func rewrite) + { + var entryAssertion = rewrite(AssertionStep.Member(), EntryAssertion); + + return ReferenceEquals(entryAssertion, EntryAssertion) + ? this + : new KeyedObjectValidator(entryAssertion, Min, Max); + } + /// public JToken ToJson() => new JProperty("keyed-object", new JObject( diff --git a/src/Firely.Fhir.Validation/Impl/LogicalModelSchema.cs b/src/Firely.Fhir.Validation/Impl/LogicalModelSchema.cs index cda756c8..ac5b9952 100644 --- a/src/Firely.Fhir.Validation/Impl/LogicalModelSchema.cs +++ b/src/Firely.Fhir.Validation/Impl/LogicalModelSchema.cs @@ -36,6 +36,10 @@ public LogicalModelSchema(StructureDefinitionInformation structureDefinition, IE // nothing } + /// + internal override ElementSchema WithMembers(IEnumerable members) + => new LogicalModelSchema(StructureDefinition, members); + /// internal override ResultReport ValidateInternal(IEnumerable input, ValidationSettings vc, ValidationState state) { diff --git a/src/Firely.Fhir.Validation/Impl/PathSelectorValidator.cs b/src/Firely.Fhir.Validation/Impl/PathSelectorValidator.cs index 485738a1..9696dc75 100644 --- a/src/Firely.Fhir.Validation/Impl/PathSelectorValidator.cs +++ b/src/Firely.Fhir.Validation/Impl/PathSelectorValidator.cs @@ -12,6 +12,7 @@ using Hl7.FhirPath; using Hl7.FhirPath.Expressions; using Newtonsoft.Json.Linq; +using System; using System.ComponentModel; using System.Linq; using System.Runtime.Serialization; @@ -30,7 +31,7 @@ namespace Firely.Fhir.Validation #else [System.Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] #endif - public class PathSelectorValidator : IValidatable + public class PathSelectorValidator : IValidatable, IAssertionContainer { /// /// The FhirPath statement used to select a value to validate. @@ -53,6 +54,15 @@ public PathSelectorValidator(string path, IAssertion other) Other = other; } + /// + /// In practice a rewrite never reaches this validator, since it only occurs inside the + /// discriminators of a , which are not visited. + IAssertion IAssertionContainer.WithChildren(Func rewrite) + { + var other = rewrite(AssertionStep.Member(), Other); + return ReferenceEquals(other, Other) ? this : new PathSelectorValidator(Path, other); + } + /// /// Note that this validator is only used internally to represent the checks for /// the path-based discriminated cases in a , so this validator diff --git a/src/Firely.Fhir.Validation/Impl/ReferencedInstanceValidator.cs b/src/Firely.Fhir.Validation/Impl/ReferencedInstanceValidator.cs index 10d9738a..2c97196f 100644 --- a/src/Firely.Fhir.Validation/Impl/ReferencedInstanceValidator.cs +++ b/src/Firely.Fhir.Validation/Impl/ReferencedInstanceValidator.cs @@ -75,7 +75,7 @@ public enum ReferenceChecks #else [Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] #endif - public class ReferencedInstanceValidator : IValidatable + public class ReferencedInstanceValidator : IValidatable, IAssertionContainer { /// /// The schema to validate the target of a reference against, for targets of a given type: @@ -186,6 +186,45 @@ public ReferencedInstanceValidator(IEnumerable targetCases, private readonly TargetCase? _catchAllCase; + /// + /// Copies , replacing the schema(s) the target is validated against. + /// + /// Used to rewrite the target schemas without having to reproduce the original's + /// configuration through one of the public constructors, which do not all carry every setting. + private ReferencedInstanceValidator(ReferencedInstanceValidator original, IAssertion? schema, IReadOnlyList? targetCases) + { + Schema = schema; + TargetCases = targetCases; + AggregationRules = original.AggregationRules; + VersioningRules = original.VersioningRules; + Checks = original.Checks; + _catchAllCase = targetCases is [{ Type: "Resource" } single] ? single : null; + } + + /// + IAssertion IAssertionContainer.WithChildren(Func rewrite) + { + var schema = Schema is null ? null : rewrite(AssertionStep.ReferenceTarget(null), Schema); + TargetCase[]? updatedCases = null; + + for (var index = 0; index < TargetCases?.Count; index++) + { + var targetCase = TargetCases[index]; + var rewritten = rewrite(AssertionStep.ReferenceTarget(targetCase.Type), targetCase.Schema); + + if (!ReferenceEquals(rewritten, targetCase.Schema)) + { + // Only start copying once we actually have a change to record. + updatedCases ??= [.. TargetCases]; + updatedCases[index] = new TargetCase(targetCase.Type, rewritten); + } + } + + return ReferenceEquals(schema, Schema) && updatedCases is null + ? this + : new ReferencedInstanceValidator(this, schema, updatedCases ?? TargetCases); + } + /// /// Whether any have been specified on the constructor. /// diff --git a/src/Firely.Fhir.Validation/Impl/SliceValidator.cs b/src/Firely.Fhir.Validation/Impl/SliceValidator.cs index 5ca3ce08..4711d9f7 100644 --- a/src/Firely.Fhir.Validation/Impl/SliceValidator.cs +++ b/src/Firely.Fhir.Validation/Impl/SliceValidator.cs @@ -32,7 +32,7 @@ namespace Firely.Fhir.Validation #else [System.Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] #endif - public class SliceValidator : IGroupValidatable + public class SliceValidator : IGroupValidatable, IAssertionContainer { /// /// Represents a named, conditional assertion on a set of elements. @@ -139,6 +139,34 @@ public SliceValidator(bool ordered, bool defaultAtEnd, IAssertion @default, IEnu Slices = slices.ToArray() ?? throw new ArgumentNullException(nameof(slices)); } + /// + /// The discriminators () are deliberately not visited: + /// they decide which slice an instance belongs to, and rewriting them would change the slicing + /// itself rather than the validation performed on a slice. + IAssertion IAssertionContainer.WithChildren(Func rewrite) + { + SliceCase[]? updated = null; + + for (var index = 0; index < Slices.Count; index++) + { + var slice = Slices[index]; + var rewritten = rewrite(AssertionStep.Slice(slice.Name), slice.Assertion); + + if (!ReferenceEquals(rewritten, slice.Assertion)) + { + // Only start copying once we actually have a change to record. + updated ??= [.. Slices]; + updated[index] = new SliceCase(slice.Name, slice.Condition, rewritten, slice.Required); + } + } + + var @default = rewrite(AssertionStep.Member(), Default); + + return updated is null && ReferenceEquals(@default, Default) + ? this + : new SliceValidator(Ordered, DefaultAtEnd, @default, updated ?? Slices, MultiCase); + } + /// ResultReport IValidatable.Validate(PocoNode input, ValidationSettings vc, ValidationState state) => ((IGroupValidatable)this).Validate(input, vc, state); diff --git a/src/Firely.Fhir.Validation/Schema/IAssertionContainer.cs b/src/Firely.Fhir.Validation/Schema/IAssertionContainer.cs new file mode 100644 index 00000000..cf9226c8 --- /dev/null +++ b/src/Firely.Fhir.Validation/Schema/IAssertionContainer.cs @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2026, 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://github.com/FirelyTeam/firely-validator-api/blob/main/LICENSE + */ + +using System; +using System.Collections.Generic; + +namespace Firely.Fhir.Validation +{ + /// + /// The kind of step that leads from a container to one of its nested assertions. + /// + /// These are the definition-side counterparts of the navigation events tracked in + /// during validation. Only steps that are visible in the compiled schema + /// exist here: the instance-side events (indexes, internal references, resource starts) have no + /// meaning while rewriting a schema. + internal enum AssertionStepKind + { + /// + /// A member of the container that does not change the position within the instance: a member of an + /// , or , the default + /// of a , or one of the branches of a conditional container. + /// + Member, + + /// + /// The assertions for a named child element, i.e. an entry in a . + /// + /// The name is the name used in the instance, so it can be a choice element name + /// (value[x]), or - for logical models - a renamed json property. + Child, + + /// + /// The assertions for a named slice of a . Note that a slice does not + /// change the position within the instance, but it does add to the definition path. + /// + /// The name comes from one of two vocabularies: it is either a slice name authored in the + /// profile, or - for the slicing a choice element compiles into, discriminated on the type label of + /// the element itself - a FHIR type code. So the name is not necessarily an authored slice name, and + /// does not necessarily match the :sliceName in the element's ElementDefinition.id + /// (FHIR spells the type slices of value[x] as :valueQuantity, not :Quantity). + Slice, + + /// + /// A subschema in a , identified by its anchor. + /// + Subschema, + + /// + /// The schema that the target of a reference is validated against, for targets of the type named + /// by the step (or for targets of any type, when the step has no name). + /// + ReferenceTarget + } + + /// + /// A single step from a container to one of its nested assertions - the label on the edge between them. + /// + /// The step describes how the nested assertion is reached, not what it is: identity (canonical, + /// version, schema id) is carried by the schemas encountered along the way, not by the steps. + internal readonly record struct AssertionStep(AssertionStepKind Kind, string? Name = null) + { + /// + /// A step to a member that does not change the position within the instance. + /// + public static AssertionStep Member() => new(AssertionStepKind.Member); + + /// + /// A step to the assertions for the child element with the given name. + /// + public static AssertionStep Child(string elementName) => new(AssertionStepKind.Child, elementName); + + /// + /// A step to the assertions for the slice with the given name. + /// + public static AssertionStep Slice(string sliceName) => new(AssertionStepKind.Slice, sliceName); + + /// + /// A step to the subschema with the given anchor. + /// + public static AssertionStep Subschema(string anchor) => new(AssertionStepKind.Subschema, anchor); + + /// + /// A step to the schema for the targets of a reference of the given type, or for the targets of + /// any type when is null. + /// + public static AssertionStep ReferenceTarget(string? targetType) => new(AssertionStepKind.ReferenceTarget, targetType); + + /// + /// The name of the child element, when this is a step. + /// + public string? ElementName => Kind == AssertionStepKind.Child ? Name : null; + + /// + /// The name of the slice, when this is a step. + /// + public string? SliceName => Kind == AssertionStepKind.Slice ? Name : null; + + /// + /// The anchor of the subschema, when this is a step. + /// + public string? Anchor => Kind == AssertionStepKind.Subschema ? Name : null; + + /// + /// The type of the reference target, when this is a + /// step. null means targets of any type. + /// + public string? TargetType => Kind == AssertionStepKind.ReferenceTarget ? Name : null; + + /// + /// Renders the step the way renders its navigation events, so paths built + /// while rewriting a schema read like the definition paths reported on issues. + /// + public override string ToString() => Kind switch + { + AssertionStepKind.Child => $".{Name}", + AssertionStepKind.Slice => $"[{Name}]", + AssertionStepKind.Subschema => $"#{Name}", + AssertionStepKind.ReferenceTarget => $"->{Name ?? "*"}", + _ => string.Empty + }; + } + + /// + /// Implemented by assertions that delegate validation to nested assertions, so a schema tree can be + /// walked and rewritten without knowing the concrete container types. + /// + /// Every that keeps nested assertions as part of its state must + /// implement this interface - AssertionContainerTests enforces that for this assembly. Implement + /// it explicitly: it is an implementation detail of schema rewriting, not part of the assertion's + /// public surface. + internal interface IAssertionContainer + { + /// + /// Returns a copy of this container in which every nested assertion is replaced by the result of + /// , or this container itself when nothing changed. + /// + /// Implementations must: + /// + /// invoke exactly once for each nested assertion, eagerly and in + /// document order; + /// return this when every call returned the very assertion it was given, so an + /// unchanged subtree keeps its identity (which keeps rewriting cheap and cache-friendly); + /// preserve all of their other state in the copy. + /// + /// Nested assertions that are structural machinery rather than validation members are not visited - + /// see , whose discriminators are not rewritable. A rewrite must return + /// an assertion that fits the slot it was called for: only + /// slots are narrower than , they + /// require an . + IAssertion WithChildren(Func rewrite); + } + + internal static class AssertionContainerExtensions + { + /// + /// Enumerates the nested assertions of a container, with the step leading to each of them. + /// + /// Implemented on top of - which is required + /// to visit every nested assertion eagerly - so a container cannot have a set of children that + /// disagrees with what it rewrites. + public static IReadOnlyList<(AssertionStep Step, IAssertion Child)> Children(this IAssertionContainer container) + { + var children = new List<(AssertionStep, IAssertion)>(); + + container.WithChildren((step, child) => + { + children.Add((step, child)); + return child; + }); + + return children; + } + + /// + /// Applies to each member, returning null when every member was + /// returned unchanged, so the caller can hand out itself instead of a copy. + /// + public static IAssertion[]? TryRewriteMembers( + this IReadOnlyCollection members, + AssertionStep step, + Func rewrite) + { + IAssertion[]? updated = null; + var index = 0; + + foreach (var member in members) + { + var rewritten = rewrite(step, member); + + if (!ReferenceEquals(rewritten, member)) + { + // Only start copying once we actually have a change to record. + updated ??= [.. members]; + updated[index] = rewritten; + } + + index += 1; + } + + return updated; + } + } +} diff --git a/test/Firely.Fhir.Validation.Tests/Schema/AssertionContainerTests.cs b/test/Firely.Fhir.Validation.Tests/Schema/AssertionContainerTests.cs new file mode 100644 index 00000000..99cae6c1 --- /dev/null +++ b/test/Firely.Fhir.Validation.Tests/Schema/AssertionContainerTests.cs @@ -0,0 +1,413 @@ +/* + * Copyright (c) 2026, 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://github.com/FirelyTeam/firely-validator-api/blob/main/LICENSE + */ + +using Hl7.Fhir.Support; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.Serialization; + +namespace Firely.Fhir.Validation.Tests +{ + [TestClass] + public class AssertionContainerTests + { + private static readonly IAssertion LEAF = new FhirTypeLabelValidator("Patient"); + private static readonly IAssertion REPLACEMENT = new FhirTypeLabelValidator("Observation"); + private static readonly IAssertion OTHERLEAF = new MaxLengthValidator(10); + + private static readonly StructureDefinitionInformation SDINFO = + new("http://test.org/patientschema", null, "Patient", null, false); + + /// + /// Rewrites , replacing by + /// and leaving everything else alone. + /// + private static IAssertion replaceLeaf(IAssertionContainer container) => + container.WithChildren((_, child) => ReferenceEquals(child, LEAF) ? REPLACEMENT : child); + + private static IAssertion identity(IAssertionContainer container) => + container.WithChildren((_, child) => child); + + /// + /// Rewrites the first child of into something else that still fits its + /// slot, leaving the other children alone. + /// + private static IAssertion replaceFirstChild(IAssertionContainer container) + { + var replaced = false; + + return container.WithChildren((_, child) => + { + if (replaced) return child; + replaced = true; + + // subschema slots only take schemas, so replace a schema's members instead of the schema + return child is ElementSchema schema ? schema.WithMembers([REPLACEMENT]) : REPLACEMENT; + }); + } + + #region the guard: no container may be forgotten + + /// + /// Every assertion that keeps nested assertions as part of its (serialized) state must implement + /// , otherwise a schema rewrite would silently not descend into it. + /// + /// This covers the assertions in the core assembly - the version-specific and compilation + /// assemblies do not define assertions of their own. + [TestMethod] + public void EveryAssertionWithNestedAssertionsIsAContainer() + { + var forgotten = typeof(IAssertion).Assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && typeof(IAssertion).IsAssignableFrom(t)) + .Where(holdsNestedAssertions) + .Where(t => !typeof(IAssertionContainer).IsAssignableFrom(t)) + .Select(t => t.Name) + .ToList(); + + Assert.AreEqual(0, forgotten.Count, + $"These assertions hold nested assertions but do not implement {nameof(IAssertionContainer)}, " + + $"so a schema rewrite cannot descend into them: {string.Join(", ", forgotten)}."); + } + + /// + /// The containers we know about today, as a canary: if this list needs updating, the guard above + /// needs a look as well. + /// + [TestMethod] + public void KnownContainersAreTheContainersWeExpect() + { + var containers = typeof(IAssertion).Assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && typeof(IAssertionContainer).IsAssignableFrom(t)) + .Select(t => t.Name) + .OrderBy(n => n) + .ToList(); + + CollectionAssert.AreEqual(new[] + { + nameof(AllValidator), + nameof(AnyValidator), + nameof(ChildrenValidator), + nameof(DatatypeSchema), + nameof(DefinitionsAssertion), + nameof(ElementSchema), + nameof(ExtensionSchema), + nameof(KeyedObjectValidator), + nameof(LogicalModelSchema), + nameof(PathSelectorValidator), + nameof(ReferencedInstanceValidator), + nameof(ResourceSchema), + nameof(SliceValidator), + }, containers.ToArray(), string.Join(", ", containers)); + } + + private static bool holdsNestedAssertions(Type type) => involvesAssertions(type, []); + + /// + /// Whether has serialized state that (directly or through a helper type + /// like ) holds assertions. + /// + private static bool involvesAssertions(Type type, HashSet visited) + { + if (!visited.Add(type)) return false; + + return dataMembers(type).Any(memberType => referencesAssertions(memberType, visited)); + + static IEnumerable dataMembers(Type type) => + type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(p => p.GetCustomAttribute() is not null) + .Select(p => p.PropertyType) + .Concat(type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(f => f.GetCustomAttribute() is not null) + .Select(f => f.FieldType)); + } + + private static bool referencesAssertions(Type type, HashSet visited) => + typeof(IAssertion).IsAssignableFrom(type) + || (type.IsArray && referencesAssertions(type.GetElementType()!, visited)) + || (type.IsGenericType && type.GetGenericArguments().Any(a => referencesAssertions(a, visited))) + // A helper type of our own (SliceCase, TargetCase) can hold the assertions instead. + || (type.Assembly == typeof(IAssertion).Assembly && !type.IsEnum && involvesAssertions(type, visited)); + + #endregion + + #region steps describe the edges + + [TestMethod] + public void SchemaMembersAreNeutralSteps() + { + var schema = new ElementSchema("#test", LEAF, OTHERLEAF); + + var children = ((IAssertionContainer)schema).Children(); + + Assert.AreEqual(2, children.Count); + Assert.IsTrue(children.All(c => c.Step == AssertionStep.Member())); + Assert.AreSame(LEAF, children[0].Child); + Assert.AreSame(OTHERLEAF, children[1].Child); + } + + [TestMethod] + public void ChildrenAreNamedSteps() + { + var children = new ChildrenValidator(false, ("value[x]", LEAF)); + + var step = ((IAssertionContainer)children).Children().Single(); + + Assert.AreEqual(AssertionStepKind.Child, step.Step.Kind); + Assert.AreEqual("value[x]", step.Step.ElementName); + Assert.IsNull(step.Step.SliceName); + Assert.AreEqual(".value[x]", step.Step.ToString()); + } + + [TestMethod] + public void SlicesAreNamedStepsAndDiscriminatorsAreNotVisited() + { + var discriminator = new PathSelectorValidator("system", LEAF); + var slices = new SliceValidator(false, false, OTHERLEAF, + new SliceValidator.SliceCase("theSlice", discriminator, LEAF)); + + var children = ((IAssertionContainer)slices).Children(); + + Assert.AreEqual(2, children.Count); + Assert.AreEqual(AssertionStepKind.Slice, children[0].Step.Kind); + Assert.AreEqual("theSlice", children[0].Step.SliceName); + Assert.AreSame(LEAF, children[0].Child); + + // the default is a neutral member, the discriminator is not a child at all + Assert.AreEqual(AssertionStep.Member(), children[1].Step); + Assert.AreSame(OTHERLEAF, children[1].Child); + Assert.IsFalse(children.Any(c => ReferenceEquals(c.Child, discriminator))); + } + + [TestMethod] + public void SubschemasAreAnchoredSteps() + { + var definitions = new DefinitionsAssertion(new ElementSchema("#Observation.component", LEAF)); + + var step = ((IAssertionContainer)definitions).Children().Single().Step; + + Assert.AreEqual(AssertionStepKind.Subschema, step.Kind); + Assert.AreEqual("Observation.component", step.Anchor); + Assert.AreEqual("#Observation.component", step.ToString()); + } + + [TestMethod] + public void ReferenceTargetsCarryTheirType() + { + var typed = new ReferencedInstanceValidator([new ReferencedInstanceValidator.TargetCase("Patient", LEAF)]); + var untyped = new ReferencedInstanceValidator(LEAF); + + var typedStep = ((IAssertionContainer)typed).Children().Single().Step; + Assert.AreEqual(AssertionStepKind.ReferenceTarget, typedStep.Kind); + Assert.AreEqual("Patient", typedStep.TargetType); + Assert.AreEqual("->Patient", typedStep.ToString()); + + var untypedStep = ((IAssertionContainer)untyped).Children().Single().Step; + Assert.AreEqual(AssertionStepKind.ReferenceTarget, untypedStep.Kind); + Assert.IsNull(untypedStep.TargetType); + Assert.AreEqual("->*", untypedStep.ToString()); + } + + #endregion + + #region an unchanged container keeps its identity + + [TestMethod] + public void RewritingNothingReturnsTheContainerItself() + { + foreach (var container in allContainers()) + Assert.AreSame((object)container, identity(container), container.GetType().Name); + } + + [TestMethod] + public void RewritingAChildProducesACopy() + { + foreach (var container in allContainers()) + { + var name = container.GetType().Name; + var original = container.Children(); + var rewritten = replaceFirstChild(container); + + Assert.AreNotSame((object)container, rewritten, name); + Assert.AreEqual(container.GetType(), rewritten.GetType(), name); + + var children = ((IAssertionContainer)rewritten).Children(); + Assert.AreEqual(original.Count, children.Count, name); + Assert.AreEqual(original[0].Step, children[0].Step, name); + Assert.AreNotSame(original[0].Child, children[0].Child, name); + + // the children that were handed back unchanged keep their identity + for (var index = 1; index < original.Count; index++) + Assert.AreSame(original[index].Child, children[index].Child, name); + } + } + + private static IEnumerable allContainers() + { + yield return new ElementSchema("#test", LEAF); + yield return new ResourceSchema(SDINFO, LEAF); + yield return new DatatypeSchema(SDINFO, LEAF); + yield return new ExtensionSchema(SDINFO, LEAF); + yield return new LogicalModelSchema(SDINFO, LEAF); + yield return new ChildrenValidator(false, ("child", LEAF)); + yield return new SliceValidator(false, false, OTHERLEAF, new SliceValidator.SliceCase("s", OTHERLEAF, LEAF)); + yield return new AllValidator([LEAF, OTHERLEAF]); + yield return new AnyValidator([LEAF, OTHERLEAF]); + yield return new DefinitionsAssertion(new ElementSchema("#sub", LEAF)); + yield return new ReferencedInstanceValidator(LEAF); + yield return new ReferencedInstanceValidator([new ReferencedInstanceValidator.TargetCase("Patient", LEAF)]); + yield return new KeyedObjectValidator(LEAF); + yield return new PathSelectorValidator("system", LEAF); + } + + #endregion + + #region a copy preserves everything but the children + + [TestMethod] + public void SchemaCopiesKeepTheirTypeAndId() + { + var schema = new ResourceSchema(SDINFO, LEAF, OTHERLEAF); + + var rewritten = (ResourceSchema)replaceLeaf(schema); + + Assert.AreSame(SDINFO, rewritten.StructureDefinition); + Assert.AreEqual(schema.Id, rewritten.Id); + Assert.AreSame(OTHERLEAF, rewritten.Members.Last()); + } + + [TestMethod] + public void ChildrenValidatorCopiesKeepTheirOpenness() + { + var children = new ChildrenValidator(true, ("a", LEAF), ("b", OTHERLEAF)); + + var rewritten = (ChildrenValidator)replaceLeaf(children); + + Assert.IsTrue(rewritten.AllowAdditionalChildren); + Assert.AreSame(REPLACEMENT, rewritten.Lookup("a")); + Assert.AreSame(OTHERLEAF, rewritten.Lookup("b")); + } + + [TestMethod] + public void SliceCopiesKeepTheirSlicingRules() + { + var discriminator = new PathSelectorValidator("system", OTHERLEAF); + var slices = new SliceValidator(ordered: true, defaultAtEnd: true, @default: OTHERLEAF, + slices: [new SliceValidator.SliceCase("theSlice", discriminator, LEAF, required: true)], + multiCase: true); + + var rewritten = (SliceValidator)replaceLeaf(slices); + + Assert.IsTrue(rewritten.Ordered); + Assert.IsTrue(rewritten.DefaultAtEnd); + Assert.IsTrue(rewritten.MultiCase); + Assert.AreSame(OTHERLEAF, rewritten.Default); + + var slice = rewritten.Slices.Single(); + Assert.AreEqual("theSlice", slice.Name); + Assert.IsTrue(slice.Required); + Assert.AreSame(discriminator, slice.Condition); + Assert.AreSame(REPLACEMENT, slice.Assertion); + } + + [TestMethod] + public void AllAndAnyCopiesKeepTheirSettings() + { + var all = new AllValidator([LEAF], shortcircuitEvaluation: true); + var summaryError = new IssueAssertion(Issue.CONTENT_ELEMENT_MUST_MATCH_TYPE, "no match"); + var any = new AnyValidator([LEAF], summaryError); + + Assert.IsTrue(((AllValidator)replaceLeaf(all)).ShortcircuitEvaluation); + Assert.AreSame(summaryError, ((AnyValidator)replaceLeaf(any)).SummaryError); + + // the summary error is not a child, so it is not offered for rewriting either + Assert.IsFalse(((IAssertionContainer)any).Children().Any(c => ReferenceEquals(c.Child, summaryError))); + } + + [TestMethod] + public void ReferenceCopiesKeepTheirChecksAndRules() + { + var riv = new ReferencedInstanceValidator( + [new ReferencedInstanceValidator.TargetCase("Patient", LEAF)], + aggregationRules: [AggregationMode.Bundled], + versioningRules: ReferenceVersionRules.Independent, + checks: ReferenceChecks.Exists | ReferenceChecks.TargetType); + + var rewritten = (ReferencedInstanceValidator)replaceLeaf(riv); + + Assert.AreEqual(ReferenceChecks.Exists | ReferenceChecks.TargetType, rewritten.Checks); + Assert.AreEqual(ReferenceVersionRules.Independent, rewritten.VersioningRules); + CollectionAssert.AreEqual(new[] { AggregationMode.Bundled }, rewritten.AggregationRules!.ToArray()); + Assert.AreEqual("Patient", rewritten.TargetCases!.Single().Type); + Assert.AreSame(REPLACEMENT, rewritten.TargetCases!.Single().Schema); + } + + [TestMethod] + public void KeyedObjectCopiesKeepTheirCardinality() + { + var keyed = new KeyedObjectValidator(LEAF, min: 1, max: 3); + + var rewritten = (KeyedObjectValidator)replaceLeaf(keyed); + + Assert.AreEqual(1, rewritten.Min); + Assert.AreEqual(3, rewritten.Max); + Assert.AreSame(REPLACEMENT, rewritten.EntryAssertion); + } + + #endregion + + [TestMethod] + public void ASubschemaMustBeRewrittenToASchema() + { + var definitions = new DefinitionsAssertion(new ElementSchema("#sub", LEAF)); + + // returning a non-schema for a subschema slot would break resolution by anchor + var error = Assert.ThrowsException(() => + ((IAssertionContainer)definitions).WithChildren((_, _) => OTHERLEAF)); + + Assert.IsTrue(error.Message.Contains("#sub"), error.Message); + } + + [TestMethod] + public void ARewriteReachesEveryNestedAssertion() + { + // Patient.identifier[theSlice].value, plus a subschema and a reference target + var tree = new ResourceSchema(SDINFO, + new ChildrenValidator(false, + ("identifier", new ElementSchema("#Patient.identifier", + new SliceValidator(false, false, ResultAssertion.SUCCESS, + new SliceValidator.SliceCase("theSlice", ResultAssertion.SUCCESS, + new ChildrenValidator(false, ("value", new ElementSchema("#value", LEAF))))))), + ("managingOrganization", new ElementSchema("#Patient.managingOrganization", + new ReferencedInstanceValidator([new ReferencedInstanceValidator.TargetCase("Organization", LEAF)])))), + new DefinitionsAssertion(new ElementSchema("#Patient.contact", LEAF))); + + var visited = new List(); + var rewritten = rewrite(tree); + + Assert.AreEqual(3, visited.Count(v => v == "leaf"), string.Join(", ", visited)); + Assert.AreNotSame(tree, rewritten); + Assert.IsInstanceOfType(rewritten, typeof(ResourceSchema)); + + IAssertion rewrite(IAssertion assertion) + { + if (ReferenceEquals(assertion, LEAF)) + { + visited.Add("leaf"); + return REPLACEMENT; + } + + return assertion is IAssertionContainer container + ? container.WithChildren((_, child) => rewrite(child)) + : assertion; + } + } + } +}