Implement Retrieve:codeComparator - #370
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #370 +/- ##
==========================================
- Coverage 88.41% 88.24% -0.17%
==========================================
Files 54 54
Lines 4832 4867 +35
Branches 1359 1372 +13
==========================================
+ Hits 4272 4295 +23
- Misses 368 377 +9
- Partials 192 195 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Thanks for the helpful description and context for this PR. You've thought through this well and I think the approach makes sense. I've made a few suggestions for your consideration. My biggest concern is that I don't think we need to break backward compatibility for this, so I'd like to adjust where necessary to ensure existing solutions (and ELMs) continue to work.
cmoesel
left a comment
There was a problem hiding this comment.
This is looking good and most of it makes sense to me. The only thing I struggled with was the actual comment about the retrieve implementation. See my individual comment for more.
cmoesel
left a comment
There was a problem hiding this comment.
This looks good to me. I regressed it against a bunch of old CDS Connect artifacts and all the tests pass. Of course I'd like confirmation that it passes fqm-execution regression too before merging.
hossenlopp
left a comment
There was a problem hiding this comment.
Tested in fqm-execution against a large set of CMS Measures. No changes to calculation as expected.
TL;DR:
This PR implements Retrieve.codeComparator, so retrieves now honor
in,=, and~semantics for ValueSets, Codes, Concepts, and code lists, including multi-valued code properties. Exact equality is now supported and compares code displays, while equivalence continues to match code/system only. CodeSystem retrieves are intentionally unsupported but wired up so that if the appropriate terminology lookups are ever implemented, they will work here.This PR implements proper support for the codeComparator property of a Retrieve (implements #293). After a lot of thinking, testing, and discussion about this, it turns out this PR doesn't change that much about how Retrieves are handled but it does tune the implementation to be a little more aligned to the spec, and fixes a few cases that were not on the happy path.
Spec / Background
For context, there are 3 key parts to a Retrieve that are relevant to this discussion. 1, the
codeProperty, 2, thecodeComparator, and 3, thecodes(orterminology). For example, in[Condition: bodySite in "myValueSet"]--codePropertyisbodySite,codeComparatorisin, andcodesismyValueSet. If thecodesis specified but thecodePropertyandcodeComparatorare not, those fields are populated in translation based on the model. (there's a defaultcodePropertyfor every resource type, and a default operator for whichever typecodesis). SocodeComparatoris always present, and currently allows 3 values (~,=, andin).Note from this point on I'll refer to the
codePropertyand the value of that field on a given resource as the "Left-Hand Side" or LHS, and I'll refer to thecodesorterminologythat it's being compared to as the "Right-Hand Side" or RHS. So eg,bodySite in myValueSet,bodySiteis the LHS andmyValueSetis the RHS. Otherwise referring to "codes" gets too confusing.Implementation
The core of the comparison logic is performed in
Retrieve.recordMatchesCodeOrVS(src/elm/external.ts). Previously, this would only look at the type of the RHS to appropriately callhasMatchon either a single ValueSet object or on the elements of a code list. Now, this branches out into 3 separate functions for each codeComparator type, with further branches based on the type of the RHS to call the appropriate function.Now, the happy path of this is pretty easy to see what should happen:
[Condition: code in "myValueSet"]or[Condition: code = "mySingleCode"]. But it turns out that the operators in a Retrieve operate differently than they do in a standalone definition, and are much more permissive. Combinations that would be generally be illegal, eg"mySingleCode" = "myCodeSystem", are allowed in a Retrieve, eg[Condition: code = "myCodeSystem"]. We discussed this a little bit on Zulip: #cql > Retrieve.codeComparator implementation detailsThe takeaway from that is that it is intentional, it's meant to be flexible but also consistent for the user, but the execution engine does have to concern itself with the different combinations.
However, while the ELM translator permits every combination of operators and operands in a Retrieve, even combinations that are not allowed standalone, for valid combinations it does try to be smart about the types, and will list-promote or expand value sets in certain cases. For example:
[Condition: code = "myValueSet"]--> the resulting Retrieve.codes is anExpandValueSeton aValueSetRefand gets passed intorecordMatchesCodeOrVSas a list of Codes[Condition: code in "myValueSet"]--> the resulting Retrieve.codes is a plainValueSetRefand gets passed intorecordMatchesCodeOrVSas a ValueSet objectSo my takeaway here is, we should generally consider a ValueSet and Code[] to be equivalent, if possible.
And beyond that, it actually turns out that because of list promotion, even if in the CQL the RHS is a single Code, it will be represented in the resulting Retrieve.codes as a ToList wrapping that code. So even if the CQL says
code = myCode, it's reallycode = { myCode }.This means that there's no way to distinguish from the ELM whether the CQL was originally written as a single code or as a list/Concept/ValueSet that contained only a single code. So that greatly reduces the number of combinations we have to consider, but it also means that some of the trivial combinations get folded up into less obvious combinations and we have to make implementation decisions.
Start with the
=operator for equality. So now implementing this I see a few options:{ code1 } = { code1 }is true, but{ code1 } = { code1, code2 }is false.{ code1 } = { code1, code2 }is now true.Let's consider a couple examples to help decide:
[Condition: code = mySingleCode][Condition: bodySite = mySingleCode]- reminder Condition.bodySite is 0..*.{ code1, code2 } = { code1 }. Should this evaluate to true? I think so.[Condition: bodySite = myConcept][Condition: bodySite = myCodeList]Approach (3) might produce a better result in some of these cases, but would also result in inconsistent and potentially unexpected behavior in others, so my preference is not to go that route.
So that leaves (2). More permissive than (1), there's no example that should return false in (2) but true in (1).
Final Implementation Approach
So after all that, I implemented the operators with the interpretation that
~and=mean "some element from the LHS matches some element from the RHS", and not "the entirety of the LHS matches the entirety of the RHS".infor CodeSystems andinfor ValueSets are both defined to have equivalence semantics, so~andinare essentially the same, and both implement essentially the same logic as previously existed. So the only thing that is really "new" here is support for the=operator, as well as more specific Error messages when calling the few illegal combinations.Supporting Changes
=to work, the code display needs to be populated, but previously there were some paths that copy code objects without copying the display. Those were fixed here, and various test fixtures and test cases were updated as well. (Possibly more than needed but only for consistency within a single file, eg, the valuesets fixture)codePropertyhad been assuming the field was a single code, but this is not necessarily true and there are many fields that are a list of codes, egCondition.bodySitewhich is a 0..* CodeableConcept. Ideally there would be a data-model-aware generic getter, but for now that doesn't exist. I wasn't sure whether it was better to updategetCodeto possibly return a list, so I added a new functiongetCodeOrCodeswhich always returns a list even if getting a single code.CodeSystem.hasMatchfunction that just throws an error for now. The InCodeSystem operation is not supported yet (Support InCodeSystem/AnyInCodeSystem #289) and too large to implement here, and it felt cleaner to add that "not yet implemented" error in aCodeSystemmember function rather than the function handling theinoperator. However, it turns out the code execution flow doesn't make it this far, since earlier on the code tries to evaluate the CodeSystem as if it were a ValueSet and that lookup fails. I didn't modify that earlier step since there's no real way to make it work without a full implementation, so the impact is just that the error message might not be the most ideal for the situation.Pull requests into cql-execution require the following.
Submitter and reviewer should ✔ when done.
For items that are not-applicable, mark "N/A" and ✔.
Submitter:
npm run test:plusto run tests, lint, and prettier)cql4browsers.jsbuilt withnpm run build:browserifyif source changed.Reviewer:
Name: