Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

computed circular reference error messages #355

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/Firely.Fhir.Validation/Impl/ResourceSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,13 @@ internal ResultReport ValidateResourceSchema(IScopedNode input, ValidationSettin
{
return state.Global.RunValidations.Start(
state,
Id.ToString(), // is the same as the canonical for resource schemas
Id.ToString(), // is the same as the canonical for resource schemas
() =>
{
state.Global.ResourcesValidated += 1;
return base.ValidateInternal(input, vc, state);
});
},
() => input.ComputeReferenceCycle(vc));
}

/// <inheritdoc/>
Expand Down
23 changes: 11 additions & 12 deletions src/Firely.Fhir.Validation/Schema/ValidationLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@

namespace Firely.Fhir.Validation
{


/// <summary>
/// This class is used to track which resources/contained resources/bundled resources are already
/// validated, and what the previous result was. Since it keeps track of running validations, it
Expand Down Expand Up @@ -68,9 +66,10 @@ public override string ToString() =>
/// <param name="state">The validation state</param>
/// <param name="profileUrl">Profile against which we are validating</param>
/// <param name="validator">Validation to start when it has not been run before.</param>
/// <param name="refCycleVisualizer">A function which visualises reference cycles when called</param>
/// <returns>The result of calling the validator, or a historic result if there is one.</returns>
#pragma warning disable CS0618 // Type or member is obsolete
public ResultReport Start(ValidationState state, string profileUrl, Func<ResultReport> validator)
public ResultReport Start(ValidationState state, string profileUrl, Func<ResultReport> validator, Func<string?> refCycleVisualizer)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I must be blind but I don't see anything in this PR that actually supplies this extra parameter to the Start() call. It cannot be, as this PR compiles, but I just don't see it.

#pragma warning restore CS0618 // Type or member is obsolete
{
var resourceUrl = state.Instance.ResourceUrl;
Expand All @@ -80,17 +79,17 @@ public ResultReport Start(ValidationState state, string profileUrl, Func<ResultR

if (_data.TryGetValue(key, out var existing))
{
if (existing.Result is null)
if (existing.Result is null && refCycleVisualizer() is { } referenceCycle) // check for loops: we do not include references to containers in the cycle
return new IssueAssertion(Issue.CONTENT_REFERENCE_CYCLE_DETECTED,
$"Detected a loop: instance data inside '{fullLocation}' refers back to itself.")
$"Detected a loop: instance data inside '{fullLocation}' refers back to itself (cycle structure: {referenceCycle}).")
.AsResult(fullLocation);
else
{
// If the validation has been run before, return an outcome with the same result.
// Note: we don't keep a copy of the original outcome since it has been included in the
// total result (at the first run) and keeping them around costs a lot of memory.
return new ResultReport(existing.Result.Value, new TraceAssertion(fullLocation, $"Repeated validation at {fullLocation} against profile {profileUrl}."));
}

// If the validation has been run before, return an outcome with the same result.
// Note: we don't keep a copy of the original outcome since it has been included in the
// total result (at the first run) and keeping them around costs a lot of memory.
return existing.Result is not null
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if existing.Result is null but the refCycleVisualizer() call returns null, it does not report anything? Maybe that makes sense after I have seen refCycleVisualizer(), but it feels wrong. Cannot this remain a string if()/else?

? new ResultReport(existing.Result.Value, new TraceAssertion(fullLocation, $"Repeated validation at {fullLocation} against profile {profileUrl}."))
: ResultReport.SUCCESS;
}
else
{
Expand Down
38 changes: 38 additions & 0 deletions src/Firely.Fhir.Validation/Support/IScopedNodeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,44 @@ internal static bool IsExactlyEqualTo(this IScopedNode left, ITypedElement right
(childL, childR) => childL.Name == childR.Name && childL.IsExactlyEqualTo(childR, ignoreOrder)).All(t => t);
}

internal static string? ComputeReferenceCycle(this IScopedNode current, ValidationSettings vc) =>
current.ToScopedNode().ComputeReferenceCycle(vc);

internal static string? ComputeReferenceCycle(this ScopedNode current, ValidationSettings vc, IList<(string, string)>? followed = null) // this is expensive, but only executed when a loop is detected. We accept this
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be a private utility method inside the validationlogger, not an internal extension methods to ensure everyone understands this should never be called by anyone other than the author of the ValidationLogger.

{
followed ??= [];

foreach (var child in current.Children())
{
var childNode = child.ToScopedNode();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the parent (current) is a ScopedNode, its children will be scoped nodes. This is a guarantee that the children will have their parent set correctly. It is better to explictly stating this knowledge (and assumption) by making this line var childName = (ScopedNode)child, instead of the call to ToScopedNode().


if (childNode.InstanceType == "Reference")
{
var target = childNode.Resolve(url => vc.ResolveExternalReference is { } resolve ? resolve(url, childNode.Location) : null)?.ToScopedNode();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same is true here

if (target is null || (childNode.Location.StartsWith(target.Location + ".contained["))) // external reference, or reference to parent container: we do not include these in the cycle
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not?

{
continue;
}

followed.Add((childNode.Location, target.Location)); // add the reference to the list of followed references

if (followed.Count(tuple => tuple == (childNode.Location, target.Location)) is 2) // if we followed the same reference twice, we have a loop
{
return string.Join(" | ", followed.Select(reference => $"{reference.Item1} -> {reference.Item2}"));
}

if(ComputeReferenceCycle(target, vc, followed) is { } result)
return result; // if multiple paths are found, we only return the first one. Rerunning will show the next one. Let's hope that never happens.
}

if (ComputeReferenceCycle(childNode, vc, followed) is { } result2) // why is result still in scope? that makes no sense
{
return result2;
}
}

return null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I don't think we should do this. This function is simply too ugly and too inefficient. It will resolve every single reference in an instance, it uses fragile string matches on location, and it just looks like a hack. Cannot we expand the ValidationLogger or the location state so we keep track of the full path instead of constructing it?

}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public class ValidationManifestTest
/// </summary>
//[Ignore]
[DataTestMethod]
[ValidationManifestDataSource(TEST_CASES_MANIFEST, singleTest: "mr-m-simple-nossystem")]
[ValidationManifestDataSource(TEST_CASES_MANIFEST, singleTest: "message-empty-entry")]
public void RunSingleTest(TestCase testCase, string baseDirectory)
=> _runner.RunTestCase(testCase, DotNetValidator.Create(), baseDirectory);

Expand All @@ -59,7 +59,7 @@ public void RunFirelySdkTests(TestCase testCase, string baseDirectory)
/// - The method `ClassCleanup` will gather all the testcases and serialize those to disk. The filename can be altered in
/// that method
/// </summary>
[Ignore("This test is only used to generate the Firely SDK results in the manifest. See the method for more info")]
// [Ignore("This test is only used to generate the Firely SDK results in the manifest. See the method for more info")]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty sure you want to re-enable this Ignore[]?

[TestMethod]
public void AddFirelySdkValidatorResults()
=> _runner.AddOrEditValidatorResults(TEST_CASES_MANIFEST, new[] { DotNetValidator.Create() });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Hl7.Fhir.ElementModel;
using Hl7.Fhir.Support;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Linq;

namespace Firely.Fhir.Validation.Tests
{
Expand Down Expand Up @@ -90,6 +91,7 @@ public void CircularInContainedResources()
var result = test(SCHEMA, pat.DictionaryToTypedElement("Patient"));
result.IsSuccessful.Should().BeTrue();
result.Evidence.Should().Contain(ass => (ass as IssueAssertion)!.IssueNumber == Issue.CONTENT_REFERENCE_CYCLE_DETECTED.Code);
result.Warnings.Should().ContainSingle(ass => ass.IssueNumber == Issue.CONTENT_REFERENCE_CYCLE_DETECTED.Code).Subject.Message.Should().ContainAll(["Patient.contained[1]", "Patient.contained[0]"]);
}

[TestMethod]
Expand Down