Skip to content

Commit 06be44b

Browse files
lukedegruchyclaude
andauthored
Reject empty-stratifier Measures with a typed exception hierarchy (#1019)
* Reject empty-stratifier Measures with a typed exception hierarchy Measure stratifiers exported by MADIE sometimes have a `criteria` element with only `language` set (no `expression`) and no `component[]`. This is technically valid base FHIR R4 (criteria.expression is 0..1) but un-evaluable. Previously these slipped past `R4MeasureDefBuilder.getStratifierType` because `hasCriteria()` only checks element presence, producing a `CRITERIA` stratifier with `expression = null` that then NPE'd inside `PopulationBasisValidator.validateExpressionResultType` at the Kotlin non-null `EvaluationResult.get(...)` call. Tighten `getStratifierType` to require `criteria.hasExpression()`, so the existing "neither criteria nor component" branch fires for both truly-empty stratifiers and expression-less criteria. Throw a new `InvalidMeasureDefinitionException` with the offending stratifier id and measure URL. Introduce `MeasureEvaluationException` as a base for measure-evaluation runtime errors, with `InvalidMeasureDefinitionException` and the re-parented `ExpressionResultNotFoundException` as initial subclasses. Add a defensive null/blank guard in `PopulationBasisValidator.validateExpressionResultType` so `evaluationResult.get(null)` is unreachable from any future path (e.g. component criteria with null expression). Add a regression test using a real CMS165FHIR Measure exported from MADIE; the library's base64 CQL payload is extracted to a sibling `cql/ControllingHighBloodPressureFHIR.cql` and the Library JSON now references it via relative `url`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pare down measure JSON, rename to EmptyStratifier, and pare down Exception changing code. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f342456 commit 06be44b

7 files changed

Lines changed: 216 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package org.opencds.cqf.fhir.cr.measure.common;
2+
3+
/**
4+
* Thrown when a Measure resource fails structural validation at MeasureDef build time — e.g. a
5+
* stratifier with no {@code criteria.expression} and no components, or other shape errors that make
6+
* the Measure un-evaluable.
7+
*/
8+
public class InvalidMeasureDefinitionException extends RuntimeException {
9+
public InvalidMeasureDefinitionException(String message) {
10+
super(message);
11+
}
12+
}

cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/PopulationBasisValidator.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import java.util.List;
77
import java.util.Optional;
88
import java.util.Set;
9+
import org.apache.commons.lang3.StringUtils;
910
import org.opencds.cqf.cql.engine.execution.EvaluationResult;
1011
import org.opencds.cqf.fhir.cr.measure.MeasureStratifierType;
1112
import org.slf4j.Logger;
@@ -161,6 +162,10 @@ private void validateExpressionResultType(
161162
EvaluationResult evaluationResult,
162163
String url) {
163164

165+
if (StringUtils.isBlank(expression)) {
166+
return;
167+
}
168+
164169
var expressionResult = evaluationResult.get(expression);
165170

166171
if (expressionResult == null || expressionResult.getValue() == null) {

cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4MeasureDefBuilder.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import org.opencds.cqf.fhir.cr.measure.common.ConceptDef;
3939
import org.opencds.cqf.fhir.cr.measure.common.ContinuousVariableObservationAggregateMethod;
4040
import org.opencds.cqf.fhir.cr.measure.common.GroupDef;
41+
import org.opencds.cqf.fhir.cr.measure.common.InvalidMeasureDefinitionException;
4142
import org.opencds.cqf.fhir.cr.measure.common.MeasureDef;
4243
import org.opencds.cqf.fhir.cr.measure.common.MeasureDefBuilder;
4344
import org.opencds.cqf.fhir.cr.measure.common.MeasurePopulationType;
@@ -401,7 +402,8 @@ private static MeasureStratifierType getStratifierType(
401402
return null;
402403
}
403404

404-
final boolean hasCriteria = measureGroupStratifierComponent.hasCriteria();
405+
final boolean hasCriteria = measureGroupStratifierComponent.hasCriteria()
406+
&& measureGroupStratifierComponent.getCriteria().hasExpression();
405407

406408
final boolean hasAnyComponentCriteria = measureGroupStratifierComponent.getComponent().stream()
407409
.anyMatch(MeasureGroupStratifierComponentComponent::hasCriteria);
@@ -413,8 +415,9 @@ private static MeasureStratifierType getStratifierType(
413415
}
414416

415417
if (!hasCriteria && !hasAnyComponentCriteria) {
416-
throw new InvalidRequestException(
417-
"Stratifier cannot have neither criteria nor component for measure: %s".formatted(measureUrl));
418+
throw new InvalidMeasureDefinitionException(
419+
"Stratifier '%s' has no criteria.expression and no components for measure: %s"
420+
.formatted(measureGroupStratifierComponent.getId(), measureUrl));
418421
}
419422

420423
if (hasCriteria) {

cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MeasureStratifierTest.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import org.hl7.fhir.r4.model.CodeableConcept;
99
import org.hl7.fhir.r4.model.MeasureReport.MeasureReportStatus;
1010
import org.junit.jupiter.api.Test;
11+
import org.opencds.cqf.fhir.cr.measure.common.InvalidMeasureDefinitionException;
1112
import org.opencds.cqf.fhir.cr.measure.common.MeasurePopulationType;
1213
import org.opencds.cqf.fhir.cr.measure.r4.Measure.Given;
1314
import org.opencds.cqf.fhir.cr.measure.r4.Measure.When;
@@ -26,6 +27,23 @@ class MeasureStratifierTest {
2627
private static final Given GIVEN_CRITERIA_BASED_STRAT_COMPLEX =
2728
Measure.given().repositoryFor("CriteriaBasedStratifiersComplex");
2829
private static final Given GIVEN_SIMPLE = Measure.given().repositoryFor("MeasureTest");
30+
31+
@Test
32+
void emptyStratifier() {
33+
final When when = GIVEN_MEASURE_STRATIFIER_TEST
34+
.when()
35+
.measureId("EmptyStratifier")
36+
.evaluate();
37+
try {
38+
when.then();
39+
fail("expected InvalidMeasureDefinitionException for stratifier without expression or components");
40+
} catch (InvalidMeasureDefinitionException e) {
41+
assertEquals(
42+
"Stratifier 'stratifier-1' has no criteria.expression and no components for measure: https://example.com/Measure/EmptyStratifier",
43+
e.getMessage());
44+
}
45+
}
46+
2947
/**
3048
* Boolean Basis Measure with Stratifier defined by component expression that results in CodeableConcept value of 'M' or 'F' for the Measure population. For 'Individual' reportType
3149
*/
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
library EmptyStratifier
2+
3+
using FHIR version '4.0.1'
4+
5+
include FHIRHelpers version '4.0.1' called FHIRHelpers
6+
7+
parameter "Measurement Period" Interval<DateTime>
8+
default Interval[@2025-01-01T00:00:00.000Z, @2025-12-31T23:59:59.999Z]
9+
10+
context Patient
11+
12+
// Populations referenced by the Measure resource (boolean basis).
13+
// Definitions are intentionally trivial — the test exercises the stratifier
14+
// validation path, which fails before any evaluation result is consulted
15+
// because the Measure declares stratifiers with a null criteria.expression.
16+
17+
define "Initial Population":
18+
true
19+
20+
define "Denominator":
21+
"Initial Population"
22+
23+
define "Denominator Exclusions":
24+
false
25+
26+
define "Numerator":
27+
"Initial Population"
28+
29+
// Supplemental data elements referenced by Measure.supplementalData
30+
31+
define "SDE Sex":
32+
Patient.gender
33+
34+
define "SDE Race":
35+
null
36+
37+
define "SDE Ethnicity":
38+
null
39+
40+
define "SDE Payer":
41+
null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"resourceType": "Library",
3+
"id": "EmptyStratifier",
4+
"url": "https://example.com/Library/EmptyStratifier",
5+
"meta": {
6+
"profile": [
7+
"http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/computable-library-cqfm"
8+
]
9+
},
10+
"name": "EmptyStratifier",
11+
"status": "active",
12+
"type": {
13+
"coding": [
14+
{
15+
"system": "http://terminology.hl7.org/CodeSystem/library-type",
16+
"code": "logic-library"
17+
}
18+
]
19+
},
20+
"content": [
21+
{
22+
"contentType": "text/cql",
23+
"url": "../../cql/EmptyStratifier.cql"
24+
}
25+
]
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
{
2+
"resourceType": "Measure",
3+
"id": "EmptyStratifier",
4+
"url": "https://example.com/Measure/EmptyStratifier",
5+
"name": "EmptyStratifier",
6+
"library": [
7+
"https://example.com/Library/EmptyStratifier"
8+
],
9+
"group": [
10+
{
11+
"id": "group-1",
12+
"extension": [
13+
{
14+
"url": "http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-scoring",
15+
"valueCodeableConcept": {
16+
"coding": [
17+
{
18+
"system": "http://terminology.hl7.org/CodeSystem/measure-scoring",
19+
"code": "proportion",
20+
"display": "Proportion"
21+
}
22+
]
23+
}
24+
},
25+
{
26+
"url": "http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-populationBasis",
27+
"valueCode": "boolean"
28+
},
29+
{
30+
"url": "http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-improvementNotation",
31+
"valueCodeableConcept": {
32+
"coding": [
33+
{
34+
"system": "http://terminology.hl7.org/CodeSystem/measure-improvement-notation",
35+
"code": "decrease",
36+
"display": "increase"
37+
}
38+
]
39+
}
40+
}
41+
],
42+
"population": [
43+
{
44+
"id": "initial-population-1",
45+
"code": {
46+
"coding": [
47+
{
48+
"system": "http://terminology.hl7.org/CodeSystem/measure-population",
49+
"code": "initial-population",
50+
"display": "Initial Population"
51+
}
52+
]
53+
},
54+
"criteria": {
55+
"language": "text/cql-identifier",
56+
"expression": "Initial Population"
57+
}
58+
},
59+
{
60+
"id": "denominator-1",
61+
"code": {
62+
"coding": [
63+
{
64+
"system": "http://terminology.hl7.org/CodeSystem/measure-population",
65+
"code": "denominator",
66+
"display": "Denominator"
67+
}
68+
]
69+
},
70+
"criteria": {
71+
"language": "text/cql-identifier",
72+
"expression": "Denominator"
73+
}
74+
},
75+
{
76+
"id": "numerator-1",
77+
"code": {
78+
"coding": [
79+
{
80+
"system": "http://terminology.hl7.org/CodeSystem/measure-population",
81+
"code": "numerator",
82+
"display": "Numerator"
83+
}
84+
]
85+
},
86+
"criteria": {
87+
"language": "text/cql-identifier",
88+
"expression": "Numerator"
89+
}
90+
}
91+
],
92+
"stratifier": [
93+
{
94+
"id": "stratifier-1",
95+
"criteria": {
96+
"language": "text/cql-identifier"
97+
}
98+
},
99+
{
100+
"id": "stratifier-2",
101+
"criteria": {
102+
"language": "text/cql-identifier"
103+
}
104+
}
105+
]
106+
}
107+
]
108+
}

0 commit comments

Comments
 (0)