Skip to content

Commit e467425

Browse files
authored
Merge pull request #931 from FirelyTeam/copilot/fix-856
Fix equivalence operator exception when comparing FHIR Code with CqlCode and implement type prioritization
2 parents 705dcff + 79b4bd4 commit e467425

File tree

6 files changed

+133
-10
lines changed

6 files changed

+133
-10
lines changed

Cql/CoreTests/FhirCodeEqualityTests.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,42 @@ public void Convert_StringToFhirCode_ThrowNoConversionIsDefined()
158158
}
159159

160160
#endregion
161+
162+
#region Equivalent
163+
164+
[TestMethod]
165+
public void Equivalent_FhirCodeAndCqlCode_MustBeEquivalent()
166+
{
167+
// Arrange
168+
var rtx = GetNewContext();
169+
170+
var enumVal = Encounter.EncounterStatus.Cancelled;
171+
var fhirCode = new Code<Encounter.EncounterStatus>(enumVal);
172+
var cqlCode = new CqlCode("cancelled", null, null, null); // This represents the string "CancelledObservationStatus" converted to CqlCode
173+
174+
// Act
175+
var isEquivalent = rtx.Operators.Equivalent(fhirCode, cqlCode);
176+
177+
// Assert
178+
isEquivalent.Should().BeTrue("FHIR Code and equivalent CqlCode should be equivalent");
179+
}
180+
181+
[TestMethod]
182+
public void Equivalent_CqlCodeAndFhirCode_MustBeEquivalent()
183+
{
184+
// Arrange
185+
var rtx = GetNewContext();
186+
187+
var enumVal = Encounter.EncounterStatus.Cancelled;
188+
var fhirCode = new Code<Encounter.EncounterStatus>(enumVal);
189+
var cqlCode = new CqlCode("cancelled", null, null, null);
190+
191+
// Act
192+
var isEquivalent = rtx.Operators.Equivalent(cqlCode, fhirCode);
193+
194+
// Assert
195+
isEquivalent.Should().BeTrue("CqlCode and equivalent FHIR Code should be equivalent");
196+
}
197+
198+
#endregion
161199
}

Cql/Cql.Firely/Comparers/PrimitiveTypeAgainstStringComparer.cs renamed to Cql/Cql.Firely/Comparers/CodeOfTComparer.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@
77
*/
88

99
using Hl7.Cql.Comparers;
10-
using Hl7.Fhir.Model;
10+
using Hl7.Cql.Primitives;
11+
12+
// We always expect x to be a Code<T> but we can access ObjectValue from the base type PrimitiveType.
13+
using CodeOfT = Hl7.Fhir.Model.PrimitiveType;
1114

1215
namespace Hl7.Cql.Fhir.Comparers;
1316

14-
internal class PrimitiveTypeAgainstStringComparer(ICqlComparer inner) :
17+
internal class CodeOfTComparer(ICqlComparer inner) :
1518
CqlComparer<object>(
1619
equalsImplementation: CqlComparerEqualsImplementation.Compare,
1720
equivalentImplementation: CqlComparerEquivalentImplementation.Compare)
@@ -23,15 +26,31 @@ internal class PrimitiveTypeAgainstStringComparer(ICqlComparer inner) :
2326
{
2427
switch (x, y)
2528
{
26-
// We always expect x to be a Code<T> but we only need the ObjectValue on the non-generic base type PrimitiveType.
27-
case (PrimitiveType xCode, string yString):
29+
case (CodeOfT xCode, string yString):
2830
{
31+
32+
Debug.Assert(xCode.GetType().Name == "Code`1");
33+
2934
if (precision != null)
3035
throw new InvalidOperationException(
3136
$"Precision '{precision}' is not supported for comparing Code<T> to string.");
3237

3338
return StringComparer.Ordinal.Compare(xCode.ObjectValue, yString);
3439
}
40+
41+
case (CodeOfT xCode, CqlCode yCqlCode):
42+
{
43+
// We always expect x to be a Code<T> but we can access ObjectValue from the base type PrimitiveType.
44+
// Note the reverse is handled in CqlCodeComparer.
45+
Debug.Assert(xCode.GetType().Name == "Code`1");
46+
47+
if (precision != null)
48+
throw new InvalidOperationException(
49+
$"Precision '{precision}' is not supported for comparing Code<T> to CqlCode.");
50+
51+
return StringComparer.Ordinal.Compare(xCode.ObjectValue, yCqlCode.code);
52+
}
53+
3554
default:
3655
return inner.CompareValues(x, y, precision);
3756
}

Cql/Cql.Firely/Extensions/CqlComparersExtensions.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,19 @@ public static CqlComparers AddFhirComparers(this CqlComparers comparers)
5555
nameof(CodeOfEnumCqlComparer<DummyEnum>.Instance),
5656
BindingFlags.Static | BindingFlags.Public | BindingFlags.GetProperty,
5757
null, null, [])!;
58-
PrimitiveTypeAgainstStringComparer primitiveTypeAgainstStringComparer = new PrimitiveTypeAgainstStringComparer(codeOfEnumCqlComparerInstance);
59-
return primitiveTypeAgainstStringComparer;
58+
59+
CodeOfTComparer codeOfTComparer = new CodeOfTComparer(codeOfEnumCqlComparerInstance);
60+
return codeOfTComparer;
6061
});
6162

63+
// This is important to ensure the step above works correctly.
64+
comparers.ConfigureTypeSwapPredicates(typeSwapPredicates =>
65+
{
66+
// This ensures that the FHIR Code type is considered higher priority (comparand on left) than the CQL Code type (comparand of right).
67+
return typeSwapPredicates.Add(("FhirTypeHigherPriorityThanCqlType", ShouldSwap: (xType, yType) => xType.Namespace!.StartsWith("Hl7.Cql") && yType.Namespace!.StartsWith("Hl7.Fhir")));
68+
});
69+
70+
6271
return comparers;
6372
}
6473

Cql/Cql.Runtime/Comparers/CqlComparers.CqlComparerImplementation.cs

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,25 @@
66
* available at https://raw.githubusercontent.com/FirelyTeam/firely-cql-sdk/main/LICENSE
77
*/
88

9+
using Hl7.Cql.Abstractions;
10+
911
namespace Hl7.Cql.Comparers;
1012

1113
partial class CqlComparers : CqlComparer<object>
1214
{
15+
private ImmutableList<(string Name, Func<Type, Type, bool> ShouldSwap)> _shouldTypeSwapPredicates =
16+
[
17+
// Any type in the System namespace is considered less than any type not in the System namespace.
18+
("SystemTypesLowestPriority", (xType, yType) => xType.Namespace == "System" && yType.Namespace != "System"),
19+
];
20+
21+
internal CqlComparers ConfigureTypeSwapPredicates(
22+
Mutator<ImmutableList<(string Name, Func<Type, Type, bool> ShouldSwap)>> configure)
23+
{
24+
_shouldTypeSwapPredicates = configure(_shouldTypeSwapPredicates);
25+
return this;
26+
}
27+
1328
protected override int? CompareValues(
1429
object x,
1530
object y,
@@ -21,13 +36,11 @@ partial class CqlComparers : CqlComparer<object>
2136
var yType = GetKeyTypeForComparers(y);
2237
if (xType != yType)
2338
{
24-
// if x and y are not the same type, we prioritize them based on the following order:
25-
// 1. If only one type is in the System namespace, we prioritize the other type
26-
if (xType.Namespace == "System" && yType.Namespace != "System")
39+
if (ShouldSwapTypes(xType, yType))
2740
{
2841
xySwapped = true;
2942
(x, y) = (y, x);
30-
xType = yType; // yType won't be used again, so no need to swap it
43+
xType = yType; // yType won't be used again
3144
}
3245
}
3346
}
@@ -66,12 +79,30 @@ partial class CqlComparers : CqlComparer<object>
6679
throw new ArgumentException($"Cannot compare type {xType.Name}");
6780
}
6881

82+
private bool ShouldSwapTypes(Type xType, Type yType)
83+
{
84+
Debug.Assert(xType != yType, "xType and yType must not be the same.");
85+
var shouldSwapTypes = _shouldTypeSwapPredicates.Any(p => p.ShouldSwap(xType, yType));
86+
return shouldSwapTypes;
87+
}
88+
6989
protected override bool EquivalentValues(
7090
object x,
7191
object y,
7292
string? precision)
7393
{
7494
var xType = GetKeyTypeForComparers(x);
95+
{
96+
var yType = GetKeyTypeForComparers(y);
97+
if (xType != yType)
98+
{
99+
if (ShouldSwapTypes(xType, yType))
100+
{
101+
(x, y) = (y, x);
102+
xType = yType; // yType won't be used again
103+
}
104+
}
105+
}
75106

76107
if (Comparers.TryGetValue(xType, out var comparer))
77108
{
@@ -98,6 +129,17 @@ protected override bool EquivalentValues(
98129
string? precision)
99130
{
100131
var xType = GetKeyTypeForComparers(x);
132+
{
133+
var yType = GetKeyTypeForComparers(y);
134+
if (xType != yType)
135+
{
136+
if (ShouldSwapTypes(xType, yType))
137+
{
138+
(x, y) = (y, x);
139+
xType = yType; // yType won't be used again
140+
}
141+
}
142+
}
101143

102144
if (Comparers.TryGetValue(xType, out var comparer))
103145
{

Cql/Cql.Runtime/GlobalUsings.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
global using System.Collections;
1313
global using System.Collections.Concurrent;
1414
global using System.Collections.Generic;
15+
global using System.Collections.Immutable;
1516
global using System.ComponentModel;
1617
global using System.Diagnostics;
1718
global using System.Diagnostics.CodeAnalysis;

Cql/CqlToElmTests/(tests)/EquivalentTest.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1551,6 +1551,20 @@ include FHIRHelpers version '4.0.1'
15511551
Assert.IsNotNull(library.statements);
15521552
}
15531553

1554+
[TestMethod]
1555+
public void FhirCode_EquivalentTo_CqlCode_String()
1556+
{
1557+
var cqlToolkit = CreateCqlToolkit();
1558+
var library = cqlToolkit.MakeLibrary("""
1559+
library EqualsTest version '1.0.0'
1560+
using FHIR version '4.0.1'
1561+
1562+
define "Test Code Equivalence": true
1563+
""");
1564+
Assert.IsNotNull(library.statements);
1565+
Assert.AreEqual(1, library.statements.Length);
1566+
}
1567+
15541568
#endregion
15551569
}
15561570
}

0 commit comments

Comments
 (0)