diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/CqlExpressionValue.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/CqlExpressionValue.java
new file mode 100644
index 0000000000..9849f3df6a
--- /dev/null
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/CqlExpressionValue.java
@@ -0,0 +1,249 @@
+package org.opencds.cqf.fhir.cr.measure.common;
+
+import jakarta.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.StreamSupport;
+import org.opencds.cqf.cql.engine.execution.EvaluationResult;
+import org.opencds.cqf.cql.engine.execution.ExpressionResult;
+
+/**
+ * Wrapper around the raw {@code Object} returned by
+ * {@link ExpressionResult#getValue()}.
+ *
+ * Centralizes the null / Boolean / Iterable / scalar normalization that was previously
+ * scattered across measure-evaluation call sites, so the contract between the CQL engine
+ * and the measure pipeline is testable in one place. The internal {@code raw} field is
+ * intentionally typed as {@code Object} so a future migration to the upstream sealed
+ * {@code Value} type touches only this class, not its callers.
+ */
+public final class CqlExpressionValue {
+
+ private static final CqlExpressionValue EMPTY = new CqlExpressionValue(null, Collections.emptySet());
+
+ private final @Nullable Object raw;
+ private final Set evaluatedResources;
+
+ private CqlExpressionValue(@Nullable Object raw, Set evaluatedResources) {
+ this.raw = raw;
+ this.evaluatedResources = evaluatedResources;
+ }
+
+ /**
+ * Wraps an {@link ExpressionResult}. Accepts a null result and yields an empty wrapper.
+ */
+ public static CqlExpressionValue of(@Nullable ExpressionResult result) {
+ if (result == null) {
+ return EMPTY;
+ }
+ Set resources = result.getEvaluatedResources();
+ return new CqlExpressionValue(result.getValue(), resources != null ? resources : Collections.emptySet());
+ }
+
+ /**
+ * Wraps a raw value plus its evaluated-resource set directly. Useful for tests and
+ * for callers that already hold the underlying value.
+ */
+ public static CqlExpressionValue ofRaw(@Nullable Object value, @Nullable Set evaluatedResources) {
+ return new CqlExpressionValue(value, evaluatedResources != null ? evaluatedResources : Collections.emptySet());
+ }
+
+ /**
+ * Returns a wrapper whose value is null and whose evaluated-resources set is empty.
+ */
+ public static CqlExpressionValue empty() {
+ return EMPTY;
+ }
+
+ public boolean isNull() {
+ return raw == null;
+ }
+
+ public boolean isBoolean() {
+ return raw instanceof Boolean;
+ }
+
+ public boolean isTrue() {
+ return Boolean.TRUE.equals(raw);
+ }
+
+ public boolean isIterable() {
+ return raw instanceof Iterable>;
+ }
+
+ public boolean isMap() {
+ return raw instanceof Map, ?>;
+ }
+
+ /**
+ * True when the underlying value is null, an empty {@link Iterable} (or {@link Collection}),
+ * or an empty {@link Map}.
+ */
+ public boolean isEmpty() {
+ if (raw == null) {
+ return true;
+ }
+ if (raw instanceof Collection> collection) {
+ return collection.isEmpty();
+ }
+ if (raw instanceof Map, ?> map) {
+ return map.isEmpty();
+ }
+ if (raw instanceof Iterable> iterable) {
+ return !iterable.iterator().hasNext();
+ }
+ return false;
+ }
+
+ public Optional asBoolean() {
+ return raw instanceof Boolean b ? Optional.of(b) : Optional.empty();
+ }
+
+ /**
+ * Returns the underlying value as a typed {@link Map} when it is one, otherwise empty.
+ * The single unchecked cast is localized here so call sites do not have to repeat it.
+ * Used for arbitrary CQL Map values (e.g. supporting evidence, formatting). For
+ * measure-observation accumulators produced by
+ * {@code FunctionEvaluationHandler.processMeasureObservation}, prefer
+ * {@link #asObservationAccumulator()}.
+ */
+ @SuppressWarnings("unchecked")
+ public Optional> asMap() {
+ return raw instanceof Map, ?> map ? Optional.of((Map) map) : Optional.empty();
+ }
+
+ /**
+ * Returns the underlying value as the {@link ObservationAccumulator} produced by
+ * {@code FunctionEvaluationHandler.processMeasureObservation}, or empty otherwise.
+ * The accumulator wraps a {@code List} in a non-Iterable record so the
+ * upstream {@link #asIterable()} path doesn't unroll it into individual entries.
+ */
+ public Optional asObservationAccumulator() {
+ return raw instanceof ObservationAccumulator acc ? Optional.of(acc) : Optional.empty();
+ }
+
+ /**
+ * Returns the underlying value as the {@link FunctionResultAccumulator} produced by
+ * {@code FunctionEvaluationHandler.processNonSubValueStratifier}, or empty otherwise.
+ * Mirrors {@link #asObservationAccumulator()}: non-Iterable record so the upstream
+ * {@link #asIterable()} path doesn't unroll it into individual entries.
+ */
+ public Optional asFunctionResultAccumulator() {
+ return raw instanceof FunctionResultAccumulator acc ? Optional.of(acc) : Optional.empty();
+ }
+
+ /**
+ * Normalizes the value to an {@link Iterable}: null becomes an empty list, an existing
+ * iterable is returned as-is, and a scalar is wrapped in a single-element list.
+ */
+ @SuppressWarnings("unchecked")
+ public Iterable asIterable() {
+ if (raw == null) {
+ return Collections.emptyList();
+ }
+ if (raw instanceof Iterable>) {
+ return (Iterable) raw;
+ }
+ return Collections.singletonList(raw);
+ }
+
+ /**
+ * Like {@link #asIterable()} but preserves a true-null result rather than coercing to
+ * an empty list. Mirrors the legacy {@code evaluateSupportingCriteria} contract where
+ * a null indicates "no result evaluated" and is meaningful to downstream consumers.
+ */
+ @SuppressWarnings("unchecked")
+ public @Nullable Iterable asIterableOrNull() {
+ if (raw == null) {
+ return null;
+ }
+ if (raw instanceof Iterable>) {
+ return (Iterable) raw;
+ }
+ return Collections.singletonList(raw);
+ }
+
+ /**
+ * Resolves a population-criterion value to an iterable of population members.
+ *
+ *
+ * If the value is null, returns an empty list.
+ * If the value is {@link Boolean#TRUE}, looks up {@code subjectType} in the
+ * provided {@link EvaluationResult} and returns its single resolved value
+ * (the subject context resource).
+ * If the value is {@link Boolean#FALSE}, returns an empty list.
+ * Otherwise, normalizes via {@link #asIterable()}.
+ *
+ * Throws {@link CqlExpressionValueException} when the {@code subjectType} lookup
+ * yields no expression result for a {@code Boolean.TRUE} criterion.
+ */
+ public Iterable resolveForPopulation(String subjectType, EvaluationResult evaluationResult) {
+ if (raw == null) {
+ return Collections.emptyList();
+ }
+ if (raw instanceof Boolean aBoolean) {
+ if (Boolean.FALSE.equals(aBoolean)) {
+ return Collections.emptyList();
+ }
+ ExpressionResult subjectResult = evaluationResult.get(subjectType);
+ if (subjectResult == null) {
+ throw new CqlExpressionValueException(
+ "expression result is null for subject type: %s".formatted(subjectType));
+ }
+ return Collections.singletonList(subjectResult.getValue());
+ }
+ return asIterable();
+ }
+
+ /**
+ * Returns the underlying value(s) as a {@link Set} that uses FHIR-resource and CQL-type
+ * identity semantics: scalars are wrapped in a single-element set, iterables are flattened
+ * into the set, and a null value yields an empty set.
+ */
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ public Set valueAsSet() {
+ if (raw == null) {
+ return new HashSetForFhirResourcesAndCqlTypes<>();
+ }
+ if (raw instanceof Iterable>) {
+ return new HashSetForFhirResourcesAndCqlTypes<>((Iterable) raw);
+ }
+ return new HashSetForFhirResourcesAndCqlTypes<>(raw);
+ }
+
+ /**
+ * Returns the underlying value(s) as a {@link List} with nulls filtered out. A scalar
+ * becomes a single-element list, an iterable is flattened (preserving order, dropping
+ * nulls), and a null value yields an empty list.
+ */
+ public List nonNullValues() {
+ if (raw == null) {
+ return Collections.emptyList();
+ }
+ if (raw instanceof Iterable> iterable) {
+ return StreamSupport.stream(iterable.spliterator(), false)
+ .filter(Objects::nonNull)
+ .collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
+ }
+ return List.of(raw);
+ }
+
+ public Set evaluatedResources() {
+ return evaluatedResources;
+ }
+
+ /**
+ * Escape hatch for callers that still need the underlying {@link Object}. Preserved
+ * during the migration from the legacy {@code Object}-typed pipeline to the eventual
+ * sealed {@code Value} type.
+ */
+ public @Nullable Object raw() {
+ return raw;
+ }
+}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/CqlExpressionValueException.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/CqlExpressionValueException.java
new file mode 100644
index 0000000000..55a0e07c28
--- /dev/null
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/CqlExpressionValueException.java
@@ -0,0 +1,12 @@
+package org.opencds.cqf.fhir.cr.measure.common;
+
+/**
+ * Thrown when a value extracted from a CQL {@code ExpressionResult} cannot be normalized
+ * by {@link CqlExpressionValue} into the shape the caller expects (for example, when a
+ * Boolean criterion's resolved subject-context lookup returns no result).
+ */
+public class CqlExpressionValueException extends RuntimeException {
+ public CqlExpressionValueException(String message) {
+ super(message);
+ }
+}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/CriteriaResult.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/CriteriaResult.java
deleted file mode 100644
index 1a6b1de6e3..0000000000
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/CriteriaResult.java
+++ /dev/null
@@ -1,77 +0,0 @@
-package org.opencds.cqf.fhir.cr.measure.common;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Objects;
-import java.util.Set;
-import java.util.stream.StreamSupport;
-
-public class CriteriaResult {
- private final Object value;
- private final Set evaluatedResources;
-
- public static final Object NULL_VALUE = new Object();
-
- public static final CriteriaResult EMPTY_RESULT = new CriteriaResult(NULL_VALUE, Collections.emptySet());
-
- public CriteriaResult(Object value, Set evaluatedResources) {
- this.value = value;
- this.evaluatedResources = new HashSet<>(evaluatedResources);
- }
-
- public Object rawValue() {
- return this.value;
- }
-
- @SuppressWarnings({"unchecked", "rawtypes"})
- public Iterable iterableValue() {
- if (this.rawValue() instanceof Iterable>) {
- return (Iterable) this.rawValue();
- } else if (this.rawValue() == null) {
- return Collections.emptyList();
- } else {
- return Collections.singletonList(this.rawValue());
- }
- }
-
- public Set valueAsSet() {
- if (this.rawValue() instanceof Iterable) {
- return buildSet(unsafeCast(this.rawValue()));
- } else if (this.rawValue() == null) {
- return new HashSetForFhirResourcesAndCqlTypes<>();
- } else {
- return new HashSetForFhirResourcesAndCqlTypes<>(this.rawValue());
- }
- }
-
- public Set evaluatedResources() {
- return this.evaluatedResources;
- }
-
- /**
- * Returns the value(s) as a list with nulls filtered out.
- * Handles single values, iterables, and null values uniformly.
- */
- public List nonNullValues() {
- if (this.value == null) {
- return Collections.emptyList();
- }
- if (this.value instanceof Iterable> iterable) {
- return StreamSupport.stream(iterable.spliterator(), false)
- .filter(Objects::nonNull)
- .collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
- }
- return List.of(this.value);
- }
-
- private Set buildSet(Iterable iterable) {
- return new HashSetForFhirResourcesAndCqlTypes<>(iterable);
- }
-
- @SuppressWarnings("unchecked")
- private static T unsafeCast(Object object) {
- return (T) object;
- }
-}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/EvaluationResultFormatter.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/EvaluationResultFormatter.java
index c76e8727d7..5b2a3693a9 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/EvaluationResultFormatter.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/EvaluationResultFormatter.java
@@ -7,6 +7,7 @@
import java.util.Collection;
import java.util.Date;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
@@ -136,26 +137,25 @@ public static String formatExpressionValue(Object value) {
* @return formatted string representation
*/
private static String formatValue(Object value) {
- if (value == null) {
+ var wrapper = CqlExpressionValue.ofRaw(value, null);
+ if (wrapper.isNull()) {
return "null";
}
// Handle iterables and collections
- if (value instanceof Iterable> iterable) {
- String items = StreamSupport.stream(iterable.spliterator(), false)
+ if (wrapper.isIterable()) {
+ String items = StreamSupport.stream(wrapper.asIterable().spliterator(), false)
.map(EvaluationResultFormatter::formatSingleValue)
.collect(Collectors.joining(", "));
return "[" + items + "]";
}
- if (value instanceof Map, ?> map) {
- return map.entrySet().stream()
- .map(entry -> "%s -> %s"
- .formatted(formatSingleValue(entry.getKey()), formatSingleValue(entry.getValue())))
- .collect(Collectors.joining(", "));
- }
-
- return formatSingleValue(value);
+ return wrapper.asMap()
+ .map(map -> map.entrySet().stream()
+ .map(entry -> "%s -> %s"
+ .formatted(formatSingleValue(entry.getKey()), formatSingleValue(entry.getValue())))
+ .collect(Collectors.joining(", ")))
+ .orElseGet(() -> formatSingleValue(value));
}
/**
@@ -255,14 +255,18 @@ public static Object printSubjectResources(PopulationDef populationDef, String s
return "{empty}";
}
- final Set resources = populationDef.getSubjectResources().get(subjectId);
+ final Set resources =
+ populationDef.getSubjectResources().get(subjectId);
if (CollectionUtils.isEmpty(resources)) {
return subjectId + ": {empty}";
}
- final String toString =
- resources.stream().map(EvaluationResultFormatter::printValue).collect(Collectors.joining(", "));
+ final String toString = resources.stream()
+ .filter(Objects::nonNull)
+ .map(CqlExpressionValue::raw)
+ .map(EvaluationResultFormatter::printValue)
+ .collect(Collectors.joining(", "));
if (StringUtils.isBlank(toString)) {
return subjectId + ": {empty}";
@@ -289,18 +293,14 @@ public static String printValue(Object value) {
return resource.getIdElement().getValueAsString();
}
- if (value instanceof Map, ?> map) {
- final String toString = map.entrySet().stream()
- .map(entry -> printValue(entry.getKey()) + " -> " + printValue(entry.getValue()))
- .collect(Collectors.joining(", "));
-
- if (StringUtils.isBlank(toString)) {
- return "{empty}";
- }
-
- return toString;
- }
-
- return value.toString();
+ return CqlExpressionValue.ofRaw(value, null)
+ .asMap()
+ .map(map -> {
+ final String toString = map.entrySet().stream()
+ .map(entry -> printValue(entry.getKey()) + " -> " + printValue(entry.getValue()))
+ .collect(Collectors.joining(", "));
+ return StringUtils.isBlank(toString) ? "{empty}" : toString;
+ })
+ .orElseGet(value::toString);
}
}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/FunctionEvaluationHandler.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/FunctionEvaluationHandler.java
index 8d6eb05c80..32cd81f445 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/FunctionEvaluationHandler.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/FunctionEvaluationHandler.java
@@ -4,8 +4,6 @@
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import java.util.ArrayList;
import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -270,9 +268,12 @@ private static EvaluationResult processMeasureObservation(
// this will be used in MeasureEvaluator
var expressionName = criteriaPopulationId + "-" + observationExpression;
- // VERY IMPORTANT: We need a custom Map to ensure remove by FHIR resource key does not
- // use object identity (AKA ==)
- final Map functionResults = new HashMapForFhirResourcesAndCqlTypes<>();
+ // Each entry pairs an input from the population with the QuantityDef that the observation
+ // function produced for it. Consumers (PopulationDef, MeasureEvaluator, MeasureScoreCalculator,
+ // MeasureObservationHandler) iterate this list and apply FHIR-identity comparisons via
+ // FhirResourceAndCqlTypeUtils.areObjectsEqual where needed; nothing in the downstream pipeline
+ // does random-access lookup by input, so a List is sufficient and self-documenting.
+ final List functionResults = new ArrayList<>();
final Set evaluatedResources = new HashSet<>();
final String exceptionMessageIfNotFunction = """
@@ -292,17 +293,11 @@ private static EvaluationResult processMeasureObservation(
exceptionMessageIfNotFunction);
var quantity = convertCqlResultToQuantityDef(observationResult.getValue());
- // add function results to existing EvaluationResult under new expression
- // name
- // need a way to capture input parameter here too, otherwise we have no way
- // to connect input objects related to output object
- // key= input parameter to function
- // value= the output Observation resource containing calculated value
- functionResults.put(result, quantity);
+ functionResults.add(new ObservationEntry(result, quantity));
Optional.ofNullable(observationResult.getEvaluatedResources()).ifPresent(evaluatedResources::addAll);
}
- return buildEvaluationResult(expressionName, functionResults, evaluatedResources);
+ return buildEvaluationResult(expressionName, new ObservationAccumulator(functionResults), evaluatedResources);
}
/**
@@ -411,7 +406,7 @@ private static void processNonSubValueStratifier(
// make new expression name for uniquely extracting results
// this will be used in MeasureEvaluator (Criteria population Id and Stratifier Expression)
var expressionName = popDef.id() + "-" + stratifierExpression;
- final Map functionResults = new HashMap<>();
+ final List functionResults = new ArrayList<>();
final Set evaluatedResources = new HashSet<>();
for (Object result : resultsIter) {
@@ -421,13 +416,10 @@ private static void processNonSubValueStratifier(
stratifierExpression,
getFunctionArguments(groupDef, result),
exceptionMessageIfNotFunction);
- // add function results to existing EvaluationResult under new expression
- // name
- // need a way to capture input parameter here too, otherwise we have no way
- // to connect input objects related to output object
- // key= input parameter to function
- // value= the output Observation resource containing calculated value
- functionResults.put(result, functionResult.getValue());
+ // Each entry pairs the input parameter passed to the stratifier function with the
+ // heterogeneous CQL value the function returned. Iteration order is the order
+ // populationDef results were iterated.
+ functionResults.add(new FunctionResultEntry(result, functionResult.getValue()));
Set evaluated = functionResult.getEvaluatedResources();
if (evaluated == null) {
throw new IllegalStateException("CQL function '" + stratifierExpression
@@ -437,7 +429,8 @@ private static void processNonSubValueStratifier(
evaluatedResources.addAll(functionResult.getEvaluatedResources());
}
// add to EvaluationResult
- addToEvaluationResult(evalResult, expressionName, functionResults, evaluatedResources);
+ addToEvaluationResult(
+ evalResult, expressionName, new FunctionResultAccumulator(functionResults), evaluatedResources);
}
}
@@ -600,32 +593,7 @@ private static Optional tryGetExpressionResult(
private static Iterable> getResultIterable(
EvaluationResult evaluationResult, ExpressionResult expressionResult, String subjectTypePart) {
- if (expressionResult.getValue() instanceof Boolean) {
- if ((Boolean.TRUE.equals(expressionResult.getValue()))) {
- // if Boolean, returns context by SubjectType
- var expressionResultForSubjectId = evaluationResult.get(subjectTypePart);
-
- if (expressionResultForSubjectId == null) {
- throw new InternalErrorException(
- "expression result is null for subject type: %s".formatted(subjectTypePart));
- }
-
- Object booleanResult = expressionResultForSubjectId.getValue();
-
- // remove evaluated resources
- return Collections.singletonList(booleanResult);
- } else {
- // false result shows nothing
- return Collections.emptyList();
- }
- }
-
- Object value = expressionResult.getValue();
- if (value instanceof Iterable> iterable) {
- return iterable;
- } else {
- return Collections.singletonList(value);
- }
+ return CqlExpressionValue.of(expressionResult).resolveForPopulation(subjectTypePart, evaluationResult);
}
private static List getFunctionArguments(GroupDef groupDef, Object result) {
@@ -676,7 +644,7 @@ private static boolean hasNonSubValueStratifier(MeasureDef measureDef) {
}
private static EvaluationResult buildEvaluationResult(
- String expressionName, Map functionResults, Set evaluatedResources) {
+ String expressionName, Object functionResults, Set evaluatedResources) {
final EvaluationResult evaluationResultToReturn = new EvaluationResult();
@@ -687,10 +655,7 @@ private static EvaluationResult buildEvaluationResult(
}
private static void addToEvaluationResult(
- EvaluationResult result,
- String expressionName,
- Map functionResults,
- Set evaluatedResources) {
+ EvaluationResult result, String expressionName, Object functionResults, Set evaluatedResources) {
result.set(
new EvaluationExpressionRef(expressionName), new ExpressionResult(functionResults, evaluatedResources));
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/FunctionResultAccumulator.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/FunctionResultAccumulator.java
new file mode 100644
index 0000000000..0e71ea6a82
--- /dev/null
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/FunctionResultAccumulator.java
@@ -0,0 +1,24 @@
+package org.opencds.cqf.fhir.cr.measure.common;
+
+import java.util.List;
+
+/**
+ * The bag of {@link FunctionResultEntry} produced for one subject by one NON_SUBJECT_VALUE
+ * stratifier component's function. A non-Iterable record wrapping the entries list so the
+ * upstream {@link CqlExpressionValue#asIterable()} path doesn't unroll it; mirrors
+ * {@link ObservationAccumulator}.
+ */
+public record FunctionResultAccumulator(List entries) {
+
+ public FunctionResultAccumulator {
+ entries = List.copyOf(entries);
+ }
+
+ public boolean isEmpty() {
+ return entries.isEmpty();
+ }
+
+ public int size() {
+ return entries.size();
+ }
+}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/FunctionResultEntry.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/FunctionResultEntry.java
new file mode 100644
index 0000000000..fd6d078f27
--- /dev/null
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/FunctionResultEntry.java
@@ -0,0 +1,17 @@
+package org.opencds.cqf.fhir.cr.measure.common;
+
+import jakarta.annotation.Nullable;
+
+/**
+ * One row of a non-subject-value stratifier function-result accumulator: an input parameter
+ * passed to the stratifier function paired with the heterogeneous CQL value the function returned.
+ *
+ * Used in place of {@code Map} so the data flow is self-documenting and
+ * downstream consumers iterate a typed {@code List} instead of {@code Map.Entry}.
+ *
+ * Both {@code input} and {@code output} are typed as {@link Object}: the input may be a FHIR resource
+ * or a primitive (depending on population basis), and the output is whatever the CQL function produced
+ * (typically a String or Number, but the contract permits any CQL value).
+ */
+public record FunctionResultEntry(
+ @Nullable Object input, @Nullable Object output) {}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/HashSetForCqlExpressionValues.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/HashSetForCqlExpressionValues.java
new file mode 100644
index 0000000000..da01c08e08
--- /dev/null
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/HashSetForCqlExpressionValues.java
@@ -0,0 +1,165 @@
+package org.opencds.cqf.fhir.cr.measure.common;
+
+import jakarta.annotation.Nonnull;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import org.hl7.fhir.instance.model.api.IBaseResource;
+
+/**
+ * A {@link HashSet} of {@link CqlExpressionValue} that compares elements by the FHIR-resource and
+ * CQL-type identity rules of their underlying value (via {@link FhirResourceAndCqlTypeUtils}).
+ *
+ * Sister type to {@link HashSetForFhirResourcesAndCqlTypes} for use when the population pipeline
+ * stores wrappers rather than raw {@link Object}s. Two wrappers around FHIR resources with the
+ * same resource type and logical ID are considered equal, even if the wrappers (or the underlying
+ * resource instances) are different object instances. Same applies to CQL types via
+ * {@link org.opencds.cqf.cql.engine.runtime.CqlType#equal}.
+ *
+ * Bucket placement still uses the wrapper's default {@code Object.hashCode()} (the wrapper
+ * doesn't implement {@code equals} / {@code hashCode}), so {@code add} / {@code remove} /
+ * {@code contains} / {@code retainAll} fall through to linear-time identity checks via
+ * {@link FhirResourceAndCqlTypeUtils#areObjectsEqual}. This is acceptable — per-subject
+ * population sets are small.
+ */
+@SuppressWarnings("squid:S3776")
+public class HashSetForCqlExpressionValues extends HashSet {
+
+ public HashSetForCqlExpressionValues() {
+ super();
+ }
+
+ public HashSetForCqlExpressionValues(Collection collection) {
+ super();
+ for (CqlExpressionValue value : collection) {
+ this.add(value);
+ }
+ }
+
+ public HashSetForCqlExpressionValues(Iterable iterable) {
+ super();
+ for (CqlExpressionValue value : iterable) {
+ this.add(value);
+ }
+ }
+
+ /**
+ * Linear-search check that any wrapper in this set has an underlying value equal — by FHIR
+ * resource / CQL type identity — to {@code other}. Accepts either a {@link CqlExpressionValue}
+ * (the typical case) or a raw object (so callers can ask "does this set contain a wrapper
+ * around resource X?" directly).
+ */
+ @Override
+ public boolean contains(Object other) {
+ return containsByIdentity(this, unwrap(other));
+ }
+
+ /**
+ * Adds {@code newElement} only if no existing wrapper in this set has an underlying value
+ * equal to {@code newElement.raw()} by FHIR identity.
+ */
+ @Override
+ public boolean add(CqlExpressionValue newElement) {
+ if (newElement == null) {
+ return super.add(null);
+ }
+ Object newRaw = newElement.raw();
+ if (newRaw == null
+ || (FhirResourceAndCqlTypeUtils.castToResourceIfApplicable(newRaw) == null
+ && FhirResourceAndCqlTypeUtils.castToCqlTypeIfApplicable(newRaw) == null)) {
+ return super.add(newElement);
+ }
+ for (CqlExpressionValue existing : this) {
+ if (existing != null && FhirResourceAndCqlTypeUtils.areObjectsEqual(existing.raw(), newRaw)) {
+ return false;
+ }
+ }
+ return super.add(newElement);
+ }
+
+ /**
+ * Removes the wrapper whose underlying value matches {@code removalCandidate} by FHIR
+ * identity. {@code removalCandidate} may be a {@link CqlExpressionValue} or a raw resource.
+ */
+ @Override
+ public boolean remove(Object removalCandidate) {
+ Object targetRaw = unwrap(removalCandidate);
+ if (targetRaw == null) {
+ return super.remove(removalCandidate);
+ }
+ if (FhirResourceAndCqlTypeUtils.castToResourceIfApplicable(targetRaw) == null
+ && FhirResourceAndCqlTypeUtils.castToCqlTypeIfApplicable(targetRaw) == null) {
+ return super.remove(removalCandidate);
+ }
+ for (CqlExpressionValue existing : this) {
+ if (existing != null && FhirResourceAndCqlTypeUtils.areObjectsEqual(existing.raw(), targetRaw)) {
+ return super.remove(existing);
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean retainAll(@Nonnull Collection> otherCollection) {
+ Objects.requireNonNull(otherCollection);
+
+ if (otherCollection instanceof HashSetForCqlExpressionValues) {
+ return super.retainAll(otherCollection);
+ }
+
+ boolean modified = false;
+ Iterator it = iterator();
+ while (it.hasNext()) {
+ CqlExpressionValue next = it.next();
+ if (!otherContains(otherCollection, next)) {
+ it.remove();
+ modified = true;
+ }
+ }
+ return modified;
+ }
+
+ private static boolean otherContains(Collection> collection, CqlExpressionValue value) {
+ Object raw = value == null ? null : value.raw();
+ for (Object other : collection) {
+ Object otherRaw = unwrap(other);
+ if (FhirResourceAndCqlTypeUtils.areObjectsEqual(raw, otherRaw)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean containsByIdentity(Iterable elements, Object targetRaw) {
+ for (CqlExpressionValue existing : elements) {
+ Object existingRaw = existing == null ? null : existing.raw();
+ if (FhirResourceAndCqlTypeUtils.areObjectsEqual(existingRaw, targetRaw)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static Object unwrap(Object o) {
+ return o instanceof CqlExpressionValue v ? v.raw() : o;
+ }
+
+ @Override
+ public String toString() {
+ if (isEmpty()) {
+ return "[]";
+ }
+ Object firstRaw = iterator().next() == null ? null : iterator().next().raw();
+ if (firstRaw instanceof IBaseResource) {
+ return stream()
+ .map(CqlExpressionValue::raw)
+ .filter(IBaseResource.class::isInstance)
+ .map(IBaseResource.class::cast)
+ .map(r -> r.getIdElement().getValueAsString())
+ .collect(Collectors.joining(",", "[", "]"));
+ }
+ return super.toString();
+ }
+}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/HashSetForFhirResourcesAndCqlTypes.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/HashSetForFhirResourcesAndCqlTypes.java
index 75723bc2e6..7d436c3093 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/HashSetForFhirResourcesAndCqlTypes.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/HashSetForFhirResourcesAndCqlTypes.java
@@ -18,6 +18,9 @@
*
* This class exists strictly to compensate for the fact that FHIR resource classes and CQL types
* do not implement equals() and hashCode().
+ *
+ *
For a wrapper-aware sister type used by {@code PopulationDef.subjectResources}, see
+ * {@link HashSetForCqlExpressionValues}.
* @param the type of elements in this set, which may or may not be a {@link IBaseResource}
* or a {@link CqlType}
*/
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureEvaluator.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureEvaluator.java
index 45bb8e6df1..d0515074c7 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureEvaluator.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureEvaluator.java
@@ -14,7 +14,6 @@
import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import jakarta.annotation.Nullable;
-import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
@@ -112,59 +111,19 @@ protected MeasureDef evaluateSubject(
return measureDef;
}
- @SuppressWarnings("unchecked")
protected Iterable evaluatePopulationCriteria(
String subjectType,
ExpressionResult expressionResult,
EvaluationResult evaluationResult,
Set outEvaluatedResources) {
- if (expressionResult != null
- && !expressionResult.getEvaluatedResources().isEmpty()) {
- outEvaluatedResources.addAll(expressionResult.getEvaluatedResources());
- }
-
- if (expressionResult == null || expressionResult.getValue() == null) {
- return Collections.emptyList();
- }
-
- if (expressionResult.getValue() instanceof Boolean) {
- if ((Boolean.TRUE.equals(expressionResult.getValue()))) {
- // if Boolean, returns context by SubjectType
- Object booleanResult = evaluationResult.get(subjectType).getValue();
- // remove evaluated resources
- return Collections.singletonList(booleanResult);
- } else {
- // false result shows nothing
- return Collections.emptyList();
- }
- }
-
- Object value = expressionResult.getValue();
- if (value instanceof Iterable>) {
- return (Iterable) value;
- } else {
- return Collections.singletonList(value);
- }
+ var wrapper = CqlExpressionValue.of(expressionResult);
+ outEvaluatedResources.addAll(wrapper.evaluatedResources());
+ return wrapper.resolveForPopulation(subjectType, evaluationResult);
}
- @SuppressWarnings("unchecked")
protected Iterable evaluateSupportingCriteria(ExpressionResult expressionResult) {
-
- // Case 1 — true null
- if (expressionResult == null || expressionResult.getValue() == null) {
- return null; // need to preserve result
- }
-
- Object value = expressionResult.getValue();
-
- // Case 2 — list
- if (value instanceof Iterable>) {
- return (Iterable) value; // may be empty or not
- }
-
- // Case 3 — scalar
- return List.of(value);
+ return CqlExpressionValue.of(expressionResult).asIterableOrNull();
}
protected PopulationDef evaluatePopulationMembership(
@@ -467,25 +426,23 @@ protected void evaluateContinuousVariable(
* Keeps Measure-Observation values found in measurePopulation
* are not found in the corresponding measurePopulation set.
*/
- @SuppressWarnings("unchecked")
public void retainObservationSubjectResourcesInPopulation(
- Map> measurePopulation, Map> measureObservation) {
+ Map> measurePopulation,
+ Map> measureObservation) {
if (measurePopulation == null || measureObservation == null) {
return;
}
- for (Iterator>> it =
+ for (Iterator>> it =
measureObservation.entrySet().iterator();
it.hasNext(); ) {
- Map.Entry> entry = it.next();
+ Map.Entry> entry = it.next();
String subjectId = entry.getKey();
-
- // Cast subject's observation set to the expected type
- Set> obsSet = (Set>) (Set>) entry.getValue();
+ Set obsSet = entry.getValue();
// get valid population values for this subject
- Set validPopulation = measurePopulation.get(subjectId);
+ Set validPopulation = measurePopulation.get(subjectId);
if (validPopulation == null || validPopulation.isEmpty()) {
// no population for this subject -> drop the whole subject
@@ -493,11 +450,18 @@ public void retainObservationSubjectResourcesInPopulation(
continue;
}
- // remove observations not matching population values
- obsSet.removeIf(obsMap -> {
- for (Object key : obsMap.keySet()) {
- if (!validPopulation.contains(key)) {
- return true; // remove this observation map
+ // remove observation accumulators whose inputs aren't all in the valid population
+ obsSet.removeIf(item -> {
+ if (item == null) {
+ return false;
+ }
+ ObservationAccumulator acc = item.asObservationAccumulator().orElse(null);
+ if (acc == null) {
+ return false; // not an observation accumulator, leave alone
+ }
+ for (ObservationEntry obsEntry : acc.entries()) {
+ if (!validPopulation.contains(obsEntry.inputResource())) {
+ return true; // remove this observation accumulator
}
}
return false;
@@ -516,23 +480,27 @@ protected void retainObservationResourcesInPopulation(
PopulationDef measurePopulationDef,
// MeasurePopulationType.MEASUREOBSERVATION
PopulationDef measureObservationDef) {
- for (Object populationResource : measureObservationDef.getResourcesForSubject(subjectId)) {
- if (populationResource instanceof Map, ?> measureObservationResourceAsMap) {
- for (Entry, ?> measureObservationResourceMapEntry : measureObservationResourceAsMap.entrySet()) {
- final Object measureObservationSubjectResourceMapKey = measureObservationResourceMapEntry.getKey();
- if (measurePopulationDef != null) {
- final Set measurePopulationResourcesForSubject =
- measurePopulationDef.getResourcesForSubject(subjectId);
- if (!measurePopulationResourcesForSubject.contains(measureObservationSubjectResourceMapKey)) {
- // remove observation results not found in measure population
- measureObservationDef
- .getResourcesForSubject(subjectId)
- .remove(populationResource);
- }
- }
+ if (measurePopulationDef == null) {
+ return;
+ }
+ Set measurePopulationResourcesForSubject =
+ measurePopulationDef.getResourcesForSubject(subjectId);
+ measureObservationDef.getResourcesForSubject(subjectId).removeIf(populationResource -> {
+ if (populationResource == null) {
+ return false;
+ }
+ ObservationAccumulator acc =
+ populationResource.asObservationAccumulator().orElse(null);
+ if (acc == null) {
+ return false;
+ }
+ for (ObservationEntry entry : acc.entries()) {
+ if (!measurePopulationResourcesForSubject.contains(entry.inputResource())) {
+ return true;
}
}
- }
+ return false;
+ });
}
/**
@@ -540,22 +508,22 @@ protected void retainObservationResourcesInPopulation(
* @param measurePopulation population results that you would like to exclude from measureObservation
* @param measureObservation population results that will have items excluded from it, if found in measurePopulation
*/
- @SuppressWarnings("unchecked")
public void removeObservationSubjectResourcesInPopulation(
- Map> measurePopulation, Map> measureObservation) {
+ Map> measurePopulation,
+ Map> measureObservation) {
if (measurePopulation == null || measureObservation == null) {
return;
}
- for (Iterator>> it =
+ for (Iterator>> it =
measureObservation.entrySet().iterator();
it.hasNext(); ) {
- Map.Entry> entry = it.next();
+ Map.Entry> entry = it.next();
String subjectId = entry.getKey();
- final Set> entryValue = entry.getValue();
+ final Set entryValue = entry.getValue();
if (CollectionUtils.isEmpty(entryValue)) {
continue;
@@ -566,25 +534,24 @@ public void removeObservationSubjectResourcesInPopulation(
}
private void removeObservatorySubjectResource(
- Map> measurePopulation,
- Set> entryValue,
+ Map> measurePopulation,
+ Set entryValue,
String subjectId,
- Iterator>> iterator) {
+ Iterator>> iterator) {
if (entryValue.isEmpty()) {
// Nothing to do
return;
}
- final Object firstEntryValue = entryValue.iterator().next();
+ final CqlExpressionValue firstEntryValue = entryValue.iterator().next();
- if (!(firstEntryValue instanceof Map, ?>)) {
- throw new InternalErrorException("Expected a Map,?> but was not: %s".formatted(firstEntryValue));
+ if (firstEntryValue == null
+ || firstEntryValue.asObservationAccumulator().isEmpty()) {
+ throw new InternalErrorException("Expected an observation accumulator but was not: %s"
+ .formatted(firstEntryValue == null ? "null" : firstEntryValue.raw()));
}
- @SuppressWarnings("unchecked")
- Set> obsSet = (Set>) entryValue;
-
// population values for this subject
- Set populationValues = measurePopulation.get(subjectId);
+ Set populationValues = measurePopulation.get(subjectId);
// If there is no population for this subject, there is nothing "to remove because iterator matches",
// so leave the observation set as-is.
@@ -593,10 +560,17 @@ private void removeObservatorySubjectResource(
}
// Remove observations that *do* match population values
- obsSet.removeIf(obsMap -> {
- for (Object key : obsMap.keySet()) {
- if (populationValues.contains(key)) {
- // This observation map is backed by a population resource -> remove iterator
+ entryValue.removeIf(item -> {
+ if (item == null) {
+ return false;
+ }
+ ObservationAccumulator acc = item.asObservationAccumulator().orElse(null);
+ if (acc == null) {
+ return false;
+ }
+ for (ObservationEntry entry : acc.entries()) {
+ if (populationValues.contains(entry.inputResource())) {
+ // This observation accumulator is backed by a population resource -> drop it
return true;
}
}
@@ -604,7 +578,7 @@ private void removeObservatorySubjectResource(
});
// If no observations remain for this subject, remove the subject entry entirely
- if (obsSet.isEmpty()) {
+ if (entryValue.isEmpty()) {
iterator.remove();
}
}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureMultiSubjectEvaluator.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureMultiSubjectEvaluator.java
index af9b50971c..bfe9ca63e1 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureMultiSubjectEvaluator.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureMultiSubjectEvaluator.java
@@ -11,6 +11,8 @@
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
+import java.util.Objects;
+import java.util.Optional;
import java.util.Set;
import java.util.stream.Collector;
import java.util.stream.Collectors;
@@ -496,19 +498,22 @@ private static Map> collectFunctionRowKeys(
for (StratifierComponentDef componentDef : componentDefs) {
for (var entry : componentDef.getResults().entrySet()) {
String subjectId = entry.getKey();
- CriteriaResult result = entry.getValue();
- Object rawValue = result == null ? null : result.rawValue();
-
- // Only process function results (Map values)
- if (rawValue instanceof Map, ?> functionResults) {
- String qualifiedSubject = FhirResourceUtils.addPatientQualifier(subjectId);
- Set rowKeys =
- functionRowKeysBySubject.computeIfAbsent(qualifiedSubject, k -> new HashSet<>());
-
- for (Object key : functionResults.keySet()) {
- String normalizedKey = normalizeResourceKey(key);
- rowKeys.add(StratifierRowKey.withInput(qualifiedSubject, normalizedKey));
- }
+ CqlExpressionValue result = entry.getValue();
+
+ // Only process function results (FunctionResultAccumulator)
+ final Optional optFunctionResults = getFunctionResultAccumulator(result);
+
+ if (optFunctionResults.isEmpty()) {
+ continue;
+ }
+
+ String qualifiedSubject = FhirResourceUtils.addPatientQualifier(subjectId);
+ Set rowKeys =
+ functionRowKeysBySubject.computeIfAbsent(qualifiedSubject, k -> new HashSet<>());
+
+ for (FunctionResultEntry fnEntry : optFunctionResults.get().entries()) {
+ String normalizedKey = normalizeResourceKey(fnEntry.input());
+ rowKeys.add(StratifierRowKey.withInput(qualifiedSubject, normalizedKey));
}
}
}
@@ -516,6 +521,14 @@ private static Map> collectFunctionRowKeys(
return functionRowKeysBySubject;
}
+ private static Optional getFunctionResultAccumulator(CqlExpressionValue result) {
+ if (result == null) {
+ return Optional.empty();
+ }
+
+ return result.asFunctionResultAccumulator();
+ }
+
private static List mapToListOfTableEntries(
StratifierComponentDef componentDef, Map> functionRowKeysBySubject) {
@@ -528,16 +541,20 @@ private static List mapToListOfTableEntries(
private record StratumTableRow(StratifierRowKey stratifierRowKey, StratumValueWrapper stratumValueWrapper) {}
private static List mapToListOfTableEntries(
- String subjectId, CriteriaResult result, Map> functionRowKeysBySubject) {
+ String subjectId, CqlExpressionValue result, Map> functionRowKeysBySubject) {
final String qualifiedSubject = FhirResourceUtils.addPatientQualifier(subjectId);
- final Object rawValue = result == null ? null : result.rawValue();
-
- if (rawValue instanceof Map, ?> functionResults) {
- return addFunctionResultRows(qualifiedSubject, functionResults);
+ final Object rawValue = result == null ? null : result.raw();
- } else if (rawValue instanceof Iterable> iterableValue) {
- return addIterableValueRows(qualifiedSubject, iterableValue);
+ if (result != null) {
+ FunctionResultAccumulator functionResults =
+ result.asFunctionResultAccumulator().orElse(null);
+ if (functionResults != null) {
+ return addFunctionResultRows(qualifiedSubject, functionResults);
+ }
+ }
+ if (result != null && result.isIterable()) {
+ return addIterableValueRows(qualifiedSubject, (Iterable>) rawValue);
}
// Scalar value: check if we need to expand to match function row keys
@@ -572,18 +589,20 @@ private static List expandScalarToMatchFunctionRowKeys(
}
/**
- * Adds rows for non-subject value stratifiers with function results (Map<inputResource, outputValue>).
+ * Adds rows for non-subject value stratifiers with function results (one entry per inputResource
+ * paired with the outputValue the stratifier function produced).
*
- * For each entry in the map:
+ *
For each entry:
*
* Build composite row key: "Patient/xxx|Resource/yyy"
* The output value becomes the stratum value (what's displayed)
* Null values are allowed - they will be grouped into a special "null" stratum
*
*/
- private static List addFunctionResultRows(String qualifiedSubject, Map, ?> functionResults) {
+ private static List addFunctionResultRows(
+ String qualifiedSubject, FunctionResultAccumulator functionResults) {
- return functionResults.entrySet().stream()
+ return functionResults.entries().stream()
.map(entry ->
// The output value becomes the stratum value (what's displayed)
// Null values are allowed - they will be grouped into a special "null" stratum
@@ -591,8 +610,8 @@ private static List addFunctionResultRows(String qualifiedSubje
StratifierRowKey.withInput(
qualifiedSubject,
// Build composite row key: "Patient/xxx|Resource/yyy"
- normalizeResourceKey(entry.getKey())),
- new StratumValueWrapper(entry.getValue())))
+ normalizeResourceKey(entry.input())),
+ new StratumValueWrapper(entry.output())))
.toList();
}
@@ -749,25 +768,35 @@ private static Map, List> groupSubjectsBy
* Intersection rules:
*
* If the stratifier result is {@code Map}, intersect using {@code map.keySet()} (the input params)
- * Otherwise, intersect using {@link CriteriaResult#valueAsSet()}
+ * Otherwise, intersect using {@link CqlExpressionValue#valueAsSet()}
*
*/
private static Set calculateCriteriaStratifierIntersection(
StratifierDef stratifierDef, PopulationDef populationDef) {
- final Map stratifierResultsBySubject = stratifierDef.getResults();
+ final Map stratifierResultsBySubject = stratifierDef.getResults();
final List allPopulationStratumIntersectingResources = new ArrayList<>();
- // For each subject, we intersect between the population and stratifier results
- for (Entry stratifierEntryBySubject : stratifierResultsBySubject.entrySet()) {
+ // For each subject, we intersect between the population (Set) and
+ // stratifier results (Set of raw resources). Iterate the population side and
+ // delegate to the stratifier set's contains(): for FunctionResultAccumulator-based
+ // stratifier results that's plain Object.equals on the entry inputs; for non-accumulator
+ // results that's FHIR-identity equality via HashSetForFhirResourcesAndCqlTypes.
+ for (Entry stratifierEntryBySubject : stratifierResultsBySubject.entrySet()) {
final Set stratifierResultsPerSubject =
- criteriaResultAsIntersectionSet(stratifierEntryBySubject.getValue());
-
- final Set populationResultsPerSubject =
+ stratifierResultAsIntersectionSet(stratifierEntryBySubject.getValue());
+ final Set populationResultsPerSubject =
populationDef.getResourcesForSubject(stratifierEntryBySubject.getKey());
- allPopulationStratumIntersectingResources.addAll(
- Sets.intersection(populationResultsPerSubject, stratifierResultsPerSubject));
+ for (CqlExpressionValue wrapper : populationResultsPerSubject) {
+ if (wrapper == null) {
+ continue;
+ }
+ Object raw = wrapper.raw();
+ if (raw != null && stratifierResultsPerSubject.contains(raw)) {
+ allPopulationStratumIntersectingResources.add(raw);
+ }
+ }
}
// We add up all the results of the intersections here:
@@ -775,19 +804,21 @@ private static Set calculateCriteriaStratifierIntersection(
}
/**
- * Convert a CriteriaResult into the set that should be used for intersection.
+ * Convert a stratifier result into the set that should be used for intersection.
*
- * For Map-based results (Map), the input parameters (map keys)
- * are the intersectable items.
+ * For function-result accumulators (one entry per input parameter / produced value), the
+ * input parameters are the intersectable items.
*/
- private static Set criteriaResultAsIntersectionSet(CriteriaResult result) {
+ private static Set stratifierResultAsIntersectionSet(CqlExpressionValue result) {
if (result == null) {
return Set.of();
}
- Object raw = result.rawValue();
- if (raw instanceof Map, ?> m) {
- return new HashSet<>(m.keySet());
+ FunctionResultAccumulator acc = result.asFunctionResultAccumulator().orElse(null);
+ if (acc != null) {
+ return acc.entries().stream()
+ .map(FunctionResultEntry::input)
+ .collect(java.util.stream.Collectors.toCollection(HashSet::new));
}
return result.valueAsSet();
@@ -876,15 +907,22 @@ private static List getResourcesForSubjects(
continue;
}
- Set resources = entry.getValue();
+ Set resources = entry.getValue();
if (resources != null) {
if (isResourceType) {
resources.stream()
+ .filter(Objects::nonNull)
+ .map(CqlExpressionValue::raw)
.map(MeasureMultiSubjectEvaluator::normalizePopulationKey)
- .filter(java.util.Objects::nonNull)
+ .filter(Objects::nonNull)
.forEach(resourceIds::add);
} else {
- resources.stream().map(Object::toString).forEach(resourceIds::add);
+ resources.stream()
+ .filter(Objects::nonNull)
+ .map(CqlExpressionValue::raw)
+ .filter(Objects::nonNull)
+ .map(Object::toString)
+ .forEach(resourceIds::add);
}
}
}
@@ -903,8 +941,8 @@ private static List getResourcesForSubjects(
* (e.g., "patient1"), but StratifierRowKey uses QUALIFIED IDs (e.g., "Patient/patient1").
* For primitive types, this method qualifies the subject ID to ensure proper matching.
*
- * For MEASUREOBSERVATION populations, the subjectResources contain Set<Map<inputResource, outputValue>>
- * so we extract the keys (input resources) from those maps.
+ *
For MEASUREOBSERVATION populations, the subjectResources hold {@link ObservationAccumulator}
+ * instances; we extract each entry's {@code inputResource} to drive stratification keys.
*/
private static Set getPopulationResourceKeySet(
FhirContext fhirContext, GroupDef groupDef, PopulationDef populationDef) {
@@ -917,30 +955,37 @@ private static Set getPopulationResourceKeySet(
String subjectId = entry.getKey();
// Qualify the subject ID to match the format used in StratifierRowKey (only needed for primitive types)
String qualifiedSubject = FhirResourceUtils.addPatientQualifier(subjectId);
- Set resources = entry.getValue();
+ Set resources = entry.getValue();
if (resources != null) {
- // For MEASUREOBSERVATION, resources are Map
- // We need to extract the keys (input resources)
+ // For MEASUREOBSERVATION, resources hold ObservationAccumulator entries.
+ // Extract the input resource of each entry to drive stratification keys.
if (populationDef.type() == MeasurePopulationType.MEASUREOBSERVATION) {
// MEASUREOBSERVATION always deals with FHIR resources, so no subject qualification needed
resources.stream()
- .filter(Map.class::isInstance)
- .map(m -> (Map, ?>) m)
- .flatMap(m -> m.keySet().stream())
+ .filter(Objects::nonNull)
+ .map(CqlExpressionValue::asObservationAccumulator)
+ .flatMap(java.util.Optional::stream)
+ .flatMap(acc -> acc.entries().stream())
+ .map(ObservationEntry::inputResource)
.map(MeasureMultiSubjectEvaluator::normalizePopulationKey)
- .filter(java.util.Objects::nonNull)
+ .filter(Objects::nonNull)
.map(SubjectResourceKey::resourceOnly)
.forEach(resourceKeys::add);
} else if (isResourceType) {
// FHIR resource types have globally unique IDs - no subject qualification needed
resources.stream()
+ .filter(Objects::nonNull)
+ .map(CqlExpressionValue::raw)
.map(MeasureMultiSubjectEvaluator::normalizePopulationKey)
- .filter(java.util.Objects::nonNull)
+ .filter(Objects::nonNull)
.map(SubjectResourceKey::resourceOnly)
.forEach(resourceKeys::add);
} else {
// Primitive types (like Date) - include subject context to preserve duplicates
resources.stream()
+ .filter(Objects::nonNull)
+ .map(CqlExpressionValue::raw)
+ .filter(Objects::nonNull)
.map(obj -> SubjectResourceKey.of(qualifiedSubject, obj.toString()))
.forEach(resourceKeys::add);
}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureObservationHandler.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureObservationHandler.java
index 400c9fc303..2addf529db 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureObservationHandler.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureObservationHandler.java
@@ -1,6 +1,5 @@
package org.opencds.cqf.fhir.cr.measure.common;
-import java.util.Map;
import java.util.Set;
import org.apache.commons.collections4.CollectionUtils;
import org.slf4j.Logger;
@@ -36,12 +35,13 @@ static void removeObservationResourcesInPopulation(
return;
}
- final Set exclusionResources = measurePopulationExclusionDef.getResourcesForSubject(subjectId);
+ final Set exclusionResources =
+ measurePopulationExclusionDef.getResourcesForSubject(subjectId);
if (CollectionUtils.isEmpty(exclusionResources)) {
return;
}
- final Set observationResources = measureObservationDef.getResourcesForSubject(subjectId);
+ final Set observationResources = measureObservationDef.getResourcesForSubject(subjectId);
if (CollectionUtils.isEmpty(observationResources)) {
return;
}
@@ -54,49 +54,46 @@ static void removeObservationResourcesInPopulation(
// Make a copy to avoid ConcurrentModificationException when removeExcludedMeasureObservationResource
// removes empty maps from the original set
- final Set observationResourcesCopy = new HashSetForFhirResourcesAndCqlTypes<>(observationResources);
+ final Set observationResourcesCopy =
+ new HashSetForCqlExpressionValues(observationResources);
- // Iterate over observation resources (which are Maps) and remove matching keys
- for (Object observationResource : observationResourcesCopy) {
- if (observationResource instanceof Map, ?> observationMap) {
- removeMatchingKeysFromObservationMap(
- observationMap, exclusionResources, measureObservationDef, subjectId);
- }
+ // Iterate observation accumulators and drop entries whose input matches any exclusion
+ for (CqlExpressionValue observationResource : observationResourcesCopy) {
+ observationResource
+ .asObservationAccumulator()
+ .ifPresent(acc -> removeMatchingEntriesFromObservationAccumulator(
+ acc, exclusionResources, measureObservationDef, subjectId));
}
}
/**
- * Removes keys from an observation map that match exclusion resources.
+ * Drops entries from an observation accumulator whose input matches an exclusion resource.
*
- * This method uses FHIR resource identity (resource type + logical ID) for matching
- * rather than object instance equality, since the exclusion resources and observation
- * map keys may be separate Java object instances representing the same FHIR resource.
- *
- * @param observationMap observation map containing Resource -> QuantityDef entries
- * @param exclusionResources set of resources to exclude
- * @param measureObservationDef the observation population definition
- * @param subjectId the subject ID
+ * Uses FHIR resource identity (resource type + logical ID) for matching rather than object
+ * instance equality, since the exclusion resources and observation entry inputs may be
+ * separate Java object instances representing the same FHIR resource.
*/
- private static void removeMatchingKeysFromObservationMap(
- Map, ?> observationMap,
- Set exclusionResources,
+ private static void removeMatchingEntriesFromObservationAccumulator(
+ ObservationAccumulator accumulator,
+ Set exclusionResources,
PopulationDef measureObservationDef,
String subjectId) {
- // Find observation map keys that match any exclusion resource
- for (Object exclusionResource : exclusionResources) {
- // Check if this exclusion resource matches any key in the observation map
- // Must use custom equality that compares FHIR resource identity, not object instance
- boolean matchFound = observationMap.keySet().stream()
- .anyMatch(mapKey -> FhirResourceAndCqlTypeUtils.areObjectsEqual(mapKey, exclusionResource));
+ for (CqlExpressionValue exclusionResource : exclusionResources) {
+ if (exclusionResource == null) {
+ continue;
+ }
+ Object exclusionRaw = exclusionResource.raw();
+ boolean matchFound = accumulator.entries().stream()
+ .anyMatch(
+ entry -> FhirResourceAndCqlTypeUtils.areObjectsEqual(entry.inputResource(), exclusionRaw));
if (matchFound) {
- logger.debug(
+ logger.atDebug().log(
"Removing observation for excluded resource: {}",
- EvaluationResultFormatter.formatResource(exclusionResource));
- // Remove the entry from the inner map using the PopulationDef's removal method
- // This ensures proper handling of the Map>> structure
- measureObservationDef.removeExcludedMeasureObservationResource(subjectId, exclusionResource);
+ EvaluationResultFormatter.formatResource(exclusionRaw));
+ // Delegate to PopulationDef so empty accumulators get purged from the subject set
+ measureObservationDef.removeExcludedMeasureObservationResource(subjectId, exclusionRaw);
}
}
}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureReportDefScorer.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureReportDefScorer.java
index 190cf6551a..899cfb6ba9 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureReportDefScorer.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureReportDefScorer.java
@@ -5,6 +5,7 @@
import java.util.Collection;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
@@ -489,7 +490,7 @@ private StratumPopulationDef getStratumPopDefFromPopDef(StratumDef stratumDef, P
* @param stratumPopulationDef the stratum population to filter by
* @return collection of resources belonging to this stratum
*/
- private static Collection getResultsForStratum(
+ private static Collection getResultsForStratum(
PopulationDef populationDef, StratumPopulationDef stratumPopulationDef) {
if (stratumPopulationDef == null || populationDef == null || populationDef.getSubjectResources() == null) {
@@ -531,45 +532,40 @@ private static Collection getResultsForStratum(
* @param stratumPopulationDef the stratum population containing resource IDs
* @return collection of resources/observations matching the stratum's resource IDs
*/
- private static Collection getResultsForStratumByResourceIds(
+ private static Collection getResultsForStratumByResourceIds(
PopulationDef populationDef, StratumPopulationDef stratumPopulationDef) {
Set stratumResourceIds = stratumPopulationDef.resourceIdsAsSet();
- // For MEASUREOBSERVATION, subjectResources contains Set>
- // MeasureScoreCalculator.collectQuantities expects Map objects and extracts values from them.
- // We need to return filtered Maps (not the values directly) so collectQuantities can process them.
+ // For MEASUREOBSERVATION, subjectResources contains observation accumulators. Filter
+ // each accumulator to only the entries whose input resource ID matches a stratum
+ // resource ID, drop empties, and re-wrap so MeasureScoreCalculator.collectQuantities
+ // sees one wrapped accumulator per subject.
if (populationDef.type() == MeasurePopulationType.MEASUREOBSERVATION) {
return populationDef.getSubjectResources().values().stream()
.flatMap(Collection::stream)
- .filter(Map.class::isInstance)
- .map(m -> (Map, ?>) m)
- .map(map -> {
- // Filter the map to only include entries matching stratum resource IDs
- Map filteredMap = new java.util.HashMap<>();
- for (var entry : map.entrySet()) {
- Object key = entry.getKey();
- if (key instanceof IBaseResource baseResource) {
- String resourceId = baseResource
- .getIdElement()
- .toVersionless()
- .getValue();
- if (stratumResourceIds.contains(resourceId)) {
- filteredMap.put(key, entry.getValue());
- }
- }
- }
- return filteredMap;
- })
- .filter(map -> !map.isEmpty()) // Only include non-empty filtered maps
+ .filter(Objects::nonNull)
+ .map(CqlExpressionValue::asObservationAccumulator)
+ .flatMap(Optional::stream)
+ .map(acc -> acc.entries().stream()
+ .filter(entry -> entry.inputResource() instanceof IBaseResource baseResource
+ && stratumResourceIds.contains(baseResource
+ .getIdElement()
+ .toVersionless()
+ .getValue()))
+ .toList())
+ .filter(entries -> !entries.isEmpty())
+ .map(entries -> CqlExpressionValue.ofRaw(new ObservationAccumulator(entries), null))
.collect(Collectors.toList());
}
// For non-MEASUREOBSERVATION populations, filter resources directly
return populationDef.getSubjectResources().values().stream()
.flatMap(Collection::stream)
- .filter(resource -> {
- if (resource instanceof IBaseResource baseResource) {
+ .filter(Objects::nonNull)
+ .filter(wrapper -> {
+ Object raw = wrapper.raw();
+ if (raw instanceof IBaseResource baseResource) {
String resourceId =
baseResource.getIdElement().toVersionless().getValue();
return stratumResourceIds.contains(resourceId);
@@ -589,7 +585,8 @@ private static Collection getResultsForStratumByResourceIds(
*/
@Nullable
private static QuantityDef calculateContinuousVariableAggregateQuantity(
- @Nullable PopulationDef populationDef, Function> popDefToResources) {
+ @Nullable PopulationDef populationDef,
+ Function> popDefToResources) {
if (populationDef == null) {
return null;
@@ -609,8 +606,8 @@ private static QuantityDef calculateContinuousVariableAggregateQuantity(
*/
@Nullable
private static QuantityDef calculateContinuousVariableAggregateQuantity(
- ContinuousVariableObservationAggregateMethod aggregateMethod, Collection qualifyingResources) {
- // Delegate to MeasureScoreCalculator for collection and aggregation
+ ContinuousVariableObservationAggregateMethod aggregateMethod,
+ Collection qualifyingResources) {
var observationQuantity = MeasureScoreCalculator.collectQuantities(qualifyingResources);
return MeasureScoreCalculator.aggregateContinuousVariable(observationQuantity, aggregateMethod);
}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureScoreCalculator.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureScoreCalculator.java
index 24093a7555..f6d3079ec8 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureScoreCalculator.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureScoreCalculator.java
@@ -7,8 +7,8 @@
import java.math.RoundingMode;
import java.util.Collection;
import java.util.List;
-import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
/**
* Pure mathematical functions for measure scoring calculations.
@@ -227,10 +227,11 @@ public static BigDecimal aggregateContinuousVariableBigDecimal(
}
/**
- * Collect QuantityDef objects from nested Map structures in resources.
+ * Collect QuantityDef objects from observation-accumulator wrappers.
*
- * Helper for continuous variable scoring. Extracts QuantityDef values from
- * resources that contain {@code Map, ?>} structures with QuantityDef values.
+ *
Helper for continuous variable scoring. Each {@link CqlExpressionValue} that
+ * wraps a {@code Map} accumulator contributes its
+ * QuantityDef-typed values; non-Map and non-QuantityDef entries are filtered out.
*
* Usage Pattern:
*
@@ -242,20 +243,17 @@ public static BigDecimal aggregateContinuousVariableBigDecimal(
* quantities, ContinuousVariableObservationAggregateMethod.SUM);
*
*
- * @param resources Collection of objects that may contain Maps with QuantityDef values
+ * @param resources Collection of CqlExpressionValue wrappers that may contain
+ * observation-accumulator Maps with QuantityDef values
* @return List of QuantityDef objects found
*/
- public static List collectQuantities(Collection resources) {
- var mapValues = resources.stream()
- .filter(x -> x instanceof Map, ?>)
- .map(x -> (Map, ?>) x)
- .map(Map::values)
- .flatMap(Collection::stream)
- .toList();
-
- return mapValues.stream()
- .filter(QuantityDef.class::isInstance)
- .map(QuantityDef.class::cast)
+ public static List collectQuantities(Collection resources) {
+ return resources.stream()
+ .map(CqlExpressionValue::asObservationAccumulator)
+ .flatMap(Optional::stream)
+ .flatMap(acc -> acc.entries().stream())
+ .map(ObservationEntry::observation)
+ .filter(Objects::nonNull)
.toList();
}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/ObservationAccumulator.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/ObservationAccumulator.java
new file mode 100644
index 0000000000..05f85d2a3d
--- /dev/null
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/ObservationAccumulator.java
@@ -0,0 +1,27 @@
+package org.opencds.cqf.fhir.cr.measure.common;
+
+import java.util.List;
+
+/**
+ * The bag of {@link ObservationEntry} produced for one subject by one MEASUREOBSERVATION
+ * population's observation function. Conceptually a single composite value (one accumulator per
+ * subject), which is why this is a record rather than a {@code List} directly:
+ * a List is {@link Iterable}, and the upstream evaluation pipeline (
+ * {@code MeasureEvaluator.evaluatePopulationCriteria} → {@code CqlExpressionValue.asIterable})
+ * unrolls Iterables when stashing values into {@code PopulationDef.subjectResources}. Wrapping in
+ * a non-Iterable record keeps the whole accumulator as one stored value.
+ */
+public record ObservationAccumulator(List entries) {
+
+ public ObservationAccumulator {
+ entries = List.copyOf(entries);
+ }
+
+ public boolean isEmpty() {
+ return entries.isEmpty();
+ }
+
+ public int size() {
+ return entries.size();
+ }
+}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/ObservationEntry.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/ObservationEntry.java
new file mode 100644
index 0000000000..facda615fd
--- /dev/null
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/ObservationEntry.java
@@ -0,0 +1,21 @@
+package org.opencds.cqf.fhir.cr.measure.common;
+
+import jakarta.annotation.Nullable;
+
+/**
+ * One row of a {@code MEASUREOBSERVATION} accumulator: an input from the population paired with
+ * the {@link QuantityDef} produced by evaluating the observation function against it.
+ *
+ * Used in place of {@code Map} so the data flow is self-documenting,
+ * the value type is statically guaranteed (no {@code QuantityDef::isInstance} filtering downstream),
+ * and the consumer sites that previously iterated {@code map.keySet()} / {@code map.values()} /
+ * {@code map.entrySet()} can iterate a typed {@code List} instead.
+ *
+ * {@code inputResource} is typed as {@link Object} rather than {@link org.hl7.fhir.instance.model.api.IBaseResource}
+ * because measure-observation population basis is not constrained to FHIR resource types —
+ * primitive bases (Date, Integer, etc.) are valid and the input there is a CQL value, not a FHIR
+ * resource. Consumers handle this via the existing {@code FhirResourceAndCqlTypeUtils.areObjectsEqual}
+ * helper and {@code instanceof IBaseResource} guards where they need to extract resource IDs.
+ */
+public record ObservationEntry(
+ @Nullable Object inputResource, @Nullable QuantityDef observation) {}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/PopulationBasisValidator.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/PopulationBasisValidator.java
index 126c1b59a3..bb7b4bc5c4 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/PopulationBasisValidator.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/PopulationBasisValidator.java
@@ -113,13 +113,13 @@ private void validateGroupPopulationBasisType(
return;
}
- var expressionResult = evaluationResult.get(populationExpression);
+ var wrapper = CqlExpressionValue.of(evaluationResult.get(populationExpression));
- if (expressionResult == null || expressionResult.getValue() == null) {
+ if (wrapper.isNull()) {
return;
}
- var resultClasses = StratifierUtils.extractClassesFromSingleOrListResult(expressionResult.getValue());
+ var resultClasses = StratifierUtils.extractClassesFromSingleOrListResult(wrapper);
var groupPopulationBasisCode = groupDef.getPopulationBasis().code();
var optResourceClass = extractResourceType(groupPopulationBasisCode);
@@ -161,18 +161,17 @@ private void validateExpressionResultType(
String expression,
EvaluationResult evaluationResult,
String url) {
-
if (StringUtils.isBlank(expression)) {
return;
}
- var expressionResult = evaluationResult.get(expression);
+ var wrapper = CqlExpressionValue.of(evaluationResult.get(expression));
- if (expressionResult == null || expressionResult.getValue() == null) {
+ if (wrapper.isNull()) {
return;
}
- var resultClasses = StratifierUtils.extractClassesFromSingleOrListResult(expressionResult.getValue());
+ var resultClasses = StratifierUtils.extractClassesFromSingleOrListResult(wrapper);
var groupPopulationBasisCode = groupDef.getPopulationBasis().code();
if (stratifierDef.isCriteriaStratifier()) {
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/PopulationDef.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/PopulationDef.java
index 98a2ac86f0..2f9042153f 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/PopulationDef.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/PopulationDef.java
@@ -29,7 +29,15 @@ public class PopulationDef {
private Double aggregationResult;
protected Set evaluatedResources;
- protected Map> subjectResources = new HashMap<>();
+
+ /**
+ * Per-subject results from CQL evaluation, stored as wrappers so the FHIR-identity / CQL-type
+ * equality rules live in one place ({@link HashSetForCqlExpressionValues}). For most
+ * population types each wrapper holds a FHIR resource or CQL value; for
+ * {@link MeasurePopulationType#MEASUREOBSERVATION} populations each wrapper holds a
+ * {@code Map} accumulator.
+ */
+ protected Map> subjectResources = new HashMap<>();
public PopulationDef(
String id,
@@ -123,20 +131,21 @@ public void removeExcludedMeasureObservationResource(String subjectId, Object me
return;
}
- final Set resourcesForSubject = subjectResources.get(subjectId);
+ final Set resourcesForSubject = subjectResources.get(subjectId);
if (resourcesForSubject == null) {
return;
}
- // Remove the key from all inner maps
- resourcesForSubject.forEach(element -> {
- if (element instanceof Map, ?> innerMap) {
- innerMap.remove(measureObservationResourceKey);
- }
- });
-
- // Remove empty inner maps - critical for correct counting
- resourcesForSubject.removeIf(element -> element instanceof Map, ?> m && m.isEmpty());
+ // Drop accumulators whose entries all match (or, after filtering, none remain).
+ // Each ObservationAccumulator is immutable, so we replace its containing wrapper with a
+ // freshly-constructed one carrying the filtered entries; if the filtered list is empty,
+ // we drop the wrapper entirely so the count stays correct.
+ Set rebuilt = new HashSetForCqlExpressionValues();
+ for (CqlExpressionValue element : resourcesForSubject) {
+ processSingleCqlExpressionValue(measureObservationResourceKey, element, rebuilt);
+ }
+ resourcesForSubject.clear();
+ resourcesForSubject.addAll(rebuilt);
// If the subject's resource set is now empty, remove the subject from the map entirely
if (resourcesForSubject.isEmpty()) {
@@ -144,6 +153,25 @@ public void removeExcludedMeasureObservationResource(String subjectId, Object me
}
}
+ private static void processSingleCqlExpressionValue(
+ Object measureObservationResourceKey, CqlExpressionValue element, Set rebuilt) {
+ if (element == null) {
+ return;
+ }
+ ObservationAccumulator acc = element.asObservationAccumulator().orElse(null);
+ if (acc == null) {
+ rebuilt.add(element); // not an observation accumulator, leave alone
+ return;
+ }
+ List filtered = acc.entries().stream()
+ .filter(e ->
+ !FhirResourceAndCqlTypeUtils.areObjectsEqual(e.inputResource(), measureObservationResourceKey))
+ .toList();
+ if (!filtered.isEmpty()) {
+ rebuilt.add(CqlExpressionValue.ofRaw(new ObservationAccumulator(filtered), null));
+ }
+ }
+
public void retainAllResources(String subjectId, PopulationDef otherPopulationDef) {
getResourcesForSubject(subjectId).retainAll(otherPopulationDef.getResourcesForSubject(subjectId));
}
@@ -179,7 +207,7 @@ public void removeAllSubjects(PopulationDef otherPopulationDef) {
*
*
*/
- public List getAllSubjectResources() {
+ public List getAllSubjectResources() {
return subjectResources.values().stream()
.flatMap(Collection::stream)
.filter(Objects::nonNull)
@@ -188,14 +216,10 @@ public List getAllSubjectResources() {
// Extracted from R4MeasureReportBuilder.countObservations() by Claude Sonnet 4.5
public int countObservations() {
- if (this.getAllSubjectResources() == null) {
- return 0;
- }
-
return this.getAllSubjectResources().stream()
- .filter(Map.class::isInstance)
- .map(Map.class::cast)
- .mapToInt(Map::size)
+ .map(CqlExpressionValue::asObservationAccumulator)
+ .flatMap(Optional::stream)
+ .mapToInt(ObservationAccumulator::size)
.sum();
}
@@ -209,19 +233,23 @@ public String expression() {
}
// Getter method
- public Map> getSubjectResources() {
+ public Map> getSubjectResources() {
return subjectResources;
}
- public Set getResourcesForSubject(String subjectId) {
- return subjectResources.getOrDefault(subjectId, new HashSetForFhirResourcesAndCqlTypes<>());
+ public Set getResourcesForSubject(String subjectId) {
+ return subjectResources.getOrDefault(subjectId, new HashSetForCqlExpressionValues());
}
- // Add an element to Set under a key (Creates a new set if key is missing)
+ /**
+ * The single insertion point for population results. Wraps raw {@link Object} in a
+ * {@link CqlExpressionValue} so the underlying Set ({@link HashSetForCqlExpressionValues})
+ * can dedupe by FHIR-resource / CQL-type identity rather than Java object identity.
+ */
public void addResource(String key, Object value) {
subjectResources
- .computeIfAbsent(key, k -> new HashSetForFhirResourcesAndCqlTypes<>())
- .add(value);
+ .computeIfAbsent(key, k -> new HashSetForCqlExpressionValues())
+ .add(CqlExpressionValue.ofRaw(value, null));
}
@Nullable
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/SdeDef.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/SdeDef.java
index 3b3c87a13a..c324c13bdb 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/SdeDef.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/SdeDef.java
@@ -13,7 +13,7 @@ public class SdeDef {
private final ConceptDef code;
private final String expression;
private final String description;
- private final Map results = new HashMap<>();
+ private final Map results = new HashMap<>();
// Pre-accumulated state (populated by MeasureMultiSubjectEvaluator)
private final Map accumulatedValues = new HashMap<>();
@@ -47,7 +47,7 @@ public String description() {
}
public void putResult(String subject, Object value, Set evaluatedResources) {
- this.results.put(subject, new CriteriaResult(value, evaluatedResources));
+ this.results.put(subject, CqlExpressionValue.ofRaw(value, evaluatedResources));
}
public Map getAccumulatedValues() {
@@ -64,7 +64,7 @@ public Set getAllEvaluatedResources() {
*/
public void accumulate() {
// Merge all evaluated resources across subjects
- for (CriteriaResult result : results.values()) {
+ for (CqlExpressionValue result : results.values()) {
allEvaluatedResources.addAll(result.evaluatedResources());
}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratifierComponentDef.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratifierComponentDef.java
index 53a7a01590..6346b5a289 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratifierComponentDef.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratifierComponentDef.java
@@ -9,7 +9,7 @@ public class StratifierComponentDef {
private final ConceptDef code;
private final String expression;
- private Map results;
+ private Map results;
public StratifierComponentDef(String id, ConceptDef code, String expression) {
this.id = id;
@@ -30,10 +30,10 @@ public ConceptDef code() {
}
public void putResult(String subject, Object value, Set evaluatedResources) {
- this.getResults().put(subject, new CriteriaResult(value, evaluatedResources));
+ this.getResults().put(subject, CqlExpressionValue.ofRaw(value, evaluatedResources));
}
- public Map getResults() {
+ public Map getResults() {
if (this.results == null) {
this.results = new HashMap<>();
}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratifierDef.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratifierDef.java
index 27ca2bd965..354980b751 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratifierDef.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratifierDef.java
@@ -9,7 +9,6 @@
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
-import java.util.stream.StreamSupport;
import org.opencds.cqf.fhir.cr.measure.MeasureStratifierType;
public class StratifierDef {
@@ -23,7 +22,7 @@ public class StratifierDef {
private final List stratum = new ArrayList<>();
@Nullable
- private Map results;
+ private Map results;
public StratifierDef(String id, ConceptDef code, String expression, MeasureStratifierType stratifierType) {
this(id, code, expression, stratifierType, Collections.emptyList());
@@ -76,10 +75,12 @@ public List components() {
public void putResult(String subject, Object value, Set evaluatedResources) {
this.getResults()
- .put(subject, new CriteriaResult(value, new HashSetForFhirResourcesAndCqlTypes<>(evaluatedResources)));
+ .put(
+ subject,
+ CqlExpressionValue.ofRaw(value, new HashSetForFhirResourcesAndCqlTypes<>(evaluatedResources)));
}
- public Map getResults() {
+ public Map getResults() {
if (this.results == null) {
this.results = new HashMap<>();
}
@@ -90,8 +91,7 @@ public Map getResults() {
// Ensure we handle FHIR resource identity properly
public Set getAllCriteriaResultValues() {
return new HashSetForFhirResourcesAndCqlTypes<>(this.getResults().values().stream()
- .map(CriteriaResult::rawValue)
- .map(this::toSet)
+ .map(CqlExpressionValue::valueAsSet)
.flatMap(Collection::stream)
.collect(Collectors.toUnmodifiableSet()));
}
@@ -99,16 +99,4 @@ public Set getAllCriteriaResultValues() {
public MeasureStratifierType getStratifierType() {
return stratifierType;
}
-
- private Set toSet(Object value) {
- if (value == null) {
- return Set.of();
- }
-
- if (value instanceof Iterable> iterable) {
- return StreamSupport.stream(iterable.spliterator(), false).collect(Collectors.toUnmodifiableSet());
- } else {
- return Set.of(value);
- }
- }
}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratifierUtils.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratifierUtils.java
index bb47e9c394..fceb7bae77 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratifierUtils.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratifierUtils.java
@@ -15,22 +15,23 @@ private StratifierUtils() {
// Static utility class
}
- public static List> extractClassesFromSingleOrListResult(Object result) {
- if (result == null) {
+ public static List> extractClassesFromSingleOrListResult(CqlExpressionValue value) {
+ if (value.isNull()) {
return Collections.emptyList();
}
- if (result instanceof Class> clazz) {
+ Object raw = value.raw();
+ if (raw instanceof Class> clazz) {
return List.of(clazz);
}
- if (!(result instanceof Iterable> iterable)) {
- return List.of(result.getClass());
+ if (!value.isIterable()) {
+ return List.of(raw.getClass());
}
// Need to this to return List> and get rid of Sonar warnings.
final Stream> classStream =
- getStream(iterable).filter(Objects::nonNull).map(Object::getClass);
+ getStream(value.asIterable()).filter(Objects::nonNull).map(Object::getClass);
return classStream.toList();
}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratumValueWrapper.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratumValueWrapper.java
index 5cd9f5f10c..2f191e6b9c 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratumValueWrapper.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/StratumValueWrapper.java
@@ -2,8 +2,6 @@
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
-import java.util.Collection;
-import java.util.Map;
import java.util.StringJoiner;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
@@ -77,13 +75,14 @@ public String toString() {
private static final String EMPTY_STRATUM_VALUE = "empty";
public String getKey() {
+ var wrapper = CqlExpressionValue.ofRaw(value, null);
// Handle null values - group them into a special "null" stratum
- if (value == null) {
+ if (wrapper.isNull()) {
return NULL_STRATUM_VALUE;
}
// Handle empty collections - group them into a special "empty" stratum
- if (isEmptyCollection(value)) {
+ if (wrapper.isEmpty()) {
return EMPTY_STRATUM_VALUE;
}
@@ -124,10 +123,11 @@ public String getValueAsString() {
}
public String getDescription() {
- if (value == null) {
+ var wrapper = CqlExpressionValue.ofRaw(value, null);
+ if (wrapper.isNull()) {
return NULL_STRATUM_VALUE;
}
- if (isEmptyCollection(value)) {
+ if (wrapper.isEmpty()) {
return EMPTY_STRATUM_VALUE;
}
if (value instanceof IBaseCoding) {
@@ -169,29 +169,13 @@ private String joinValues(String... elements) {
return String.join("-", elements);
}
- /**
- * Check if the value is an empty collection (List, Set, Map, or other Iterable).
- * CQL's empty list "{}" evaluates to an empty collection, which should be treated
- * as a distinct stratum value rather than causing an error.
- */
- private static boolean isEmptyCollection(Object value) {
- if (value instanceof Collection> collection) {
- return collection.isEmpty();
- }
- if (value instanceof Map, ?> map) {
- return map.isEmpty();
- }
- if (value instanceof Iterable> iterable) {
- return !iterable.iterator().hasNext();
- }
- return false;
- }
-
private String getValueAsString(Object valueInner) {
- if (valueInner == null) {
+ var wrapper = CqlExpressionValue.ofRaw(valueInner, null);
+ if (wrapper.isNull()) {
return NULL_STRATUM_VALUE;
}
- if (isEmptyCollection(valueInner)) {
+ // CQL's empty list "{}" should be a distinct stratum value, not an error
+ if (wrapper.isEmpty()) {
return EMPTY_STRATUM_VALUE;
}
if (valueInner instanceof IBaseCoding) {
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/dstu3/Dstu3MeasureReportBuilder.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/dstu3/Dstu3MeasureReportBuilder.java
index dbdaf15a77..6f7f3b0ab7 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/dstu3/Dstu3MeasureReportBuilder.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/dstu3/Dstu3MeasureReportBuilder.java
@@ -35,6 +35,7 @@
import org.opencds.cqf.cql.engine.runtime.Date;
import org.opencds.cqf.cql.engine.runtime.DateTime;
import org.opencds.cqf.cql.engine.runtime.Interval;
+import org.opencds.cqf.fhir.cr.measure.common.CqlExpressionValue;
import org.opencds.cqf.fhir.cr.measure.common.GroupDef;
import org.opencds.cqf.fhir.cr.measure.common.MeasureDef;
import org.opencds.cqf.fhir.cr.measure.common.MeasureInfo;
@@ -201,8 +202,10 @@ protected void buildStratifier(
// equals
// the StratumValueWrapper does it for them.
Map> subjectsByValue = subjectValues.keySet().stream()
- .collect(Collectors.groupingBy(
- x -> new StratumValueWrapper(subjectValues.get(x).rawValue())));
+ .collect(Collectors.groupingBy(x -> {
+ var wrapper = subjectValues.get(x);
+ return new StratumValueWrapper(wrapper == null ? null : wrapper.raw());
+ }));
for (Map.Entry> stratValue : subjectsByValue.entrySet()) {
buildStratum(
@@ -324,7 +327,7 @@ protected void buildPopulation(
}
}
- protected void buildMeasureObservations(String observationName, Collection resources) {
+ protected void buildMeasureObservations(String observationName, Collection resources) {
for (int i = 0; i < resources.size(); i++) {
// TODO: Do something with the resource...
Observation observation =
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4MeasureReportBuilder.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4MeasureReportBuilder.java
index dcfb1da01d..a2c10f0493 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4MeasureReportBuilder.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4MeasureReportBuilder.java
@@ -41,6 +41,7 @@
import org.opencds.cqf.cql.engine.runtime.Interval;
import org.opencds.cqf.fhir.cr.measure.common.CodeDef;
import org.opencds.cqf.fhir.cr.measure.common.ConceptDef;
+import org.opencds.cqf.fhir.cr.measure.common.CqlExpressionValue;
import org.opencds.cqf.fhir.cr.measure.common.FhirResourceUtils;
import org.opencds.cqf.fhir.cr.measure.common.GroupDef;
import org.opencds.cqf.fhir.cr.measure.common.MeasureDef;
@@ -197,7 +198,9 @@ private void buildGroup(
if (docPopDef != null
&& docPopDef.getAllSubjectResources() != null
&& !docPopDef.getAllSubjectResources().isEmpty()) {
- var docValue = docPopDef.getAllSubjectResources().iterator().next();
+ var firstWrapper =
+ docPopDef.getAllSubjectResources().iterator().next();
+ var docValue = firstWrapper == null ? null : firstWrapper.raw();
if (docValue != null) {
assert docValue instanceof Interval;
Interval docInterval = (Interval) docValue;
@@ -227,8 +230,8 @@ private void addMeasureDescription(MeasureReportGroupComponent reportGroup, Meas
}
}
- private String getPopulationResourceIds(Object resourceObject) {
- if (resourceObject instanceof IBaseResource resource) {
+ private String getPopulationResourceIds(CqlExpressionValue wrapper) {
+ if (wrapper != null && wrapper.raw() instanceof IBaseResource resource) {
return resource.getIdElement().toVersionless().getValueAsString();
}
return null;
@@ -270,7 +273,7 @@ private void buildPopulation(
.collect(Collectors.toSet());
} else {
populationSet = populationDef.getAllSubjectResources().stream()
- .filter(Resource.class::isInstance)
+ .filter(wrapper -> wrapper != null && wrapper.raw() instanceof Resource)
.map(this::getPopulationResourceIds)
.collect(Collectors.toSet());
}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4SupportingEvidenceExtension.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4SupportingEvidenceExtension.java
index 4650ea3627..f63386c4c5 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4SupportingEvidenceExtension.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4SupportingEvidenceExtension.java
@@ -22,6 +22,7 @@
import org.opencds.cqf.cql.engine.runtime.Tuple;
import org.opencds.cqf.fhir.cr.measure.common.CodeDef;
import org.opencds.cqf.fhir.cr.measure.common.ConceptDef;
+import org.opencds.cqf.fhir.cr.measure.common.CqlExpressionValue;
import org.opencds.cqf.fhir.cr.measure.common.SupportingEvidenceDef;
import org.opencds.cqf.fhir.cr.measure.r4.utils.R4DateHelper;
@@ -194,15 +195,16 @@ private enum ValueKind {
* - NORMAL: everything else
*/
private static ValueKind classifyValue(Object value) {
- if (value == null) {
+ var wrapper = CqlExpressionValue.ofRaw(value, null);
+ if (wrapper.isNull()) {
return ValueKind.NULL_RESULT;
}
- if (value instanceof Iterable> it) {
+ if (wrapper.isIterable()) {
boolean sawAny = false;
boolean sawNonNull = false;
- for (Object o : it) {
+ for (Object o : wrapper.asIterable()) {
sawAny = true;
if (o != null) {
sawNonNull = true;
@@ -220,8 +222,8 @@ private static ValueKind classifyValue(Object value) {
return ValueKind.NORMAL;
}
- if (value instanceof Map, ?> m) {
- return m.isEmpty() ? ValueKind.EMPTY_LIST : ValueKind.NORMAL;
+ if (wrapper.isMap()) {
+ return wrapper.isEmpty() ? ValueKind.EMPTY_LIST : ValueKind.NORMAL;
}
return ValueKind.NORMAL;
@@ -273,17 +275,20 @@ private static void collectLeavesInto(Object value, List out, int depth)
return;
}
+ var wrapper = CqlExpressionValue.ofRaw(value, null);
+
// Flatten lists & sets
- if (value instanceof Iterable> it) {
- for (Object item : it) {
+ if (wrapper.isIterable()) {
+ for (Object item : wrapper.asIterable()) {
collectLeavesInto(item, out, depth + 1);
}
return;
}
// Optional: flatten map values (if you still want)
- if (value instanceof Map, ?> map) {
- for (Object v : map.values()) {
+ var asMap = wrapper.asMap();
+ if (asMap.isPresent()) {
+ for (Object v : asMap.get().values()) {
collectLeavesInto(v, out, depth + 1);
}
return;
diff --git a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/common/CqlExpressionValueTest.java b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/common/CqlExpressionValueTest.java
new file mode 100644
index 0000000000..f8784f7234
--- /dev/null
+++ b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/common/CqlExpressionValueTest.java
@@ -0,0 +1,481 @@
+package org.opencds.cqf.fhir.cr.measure.common;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.StreamSupport;
+import org.hl7.fhir.r4.model.Encounter;
+import org.hl7.fhir.r4.model.Patient;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.opencds.cqf.cql.engine.execution.EvaluationExpressionRef;
+import org.opencds.cqf.cql.engine.execution.EvaluationResult;
+import org.opencds.cqf.cql.engine.execution.ExpressionResult;
+
+class CqlExpressionValueTest {
+
+ @Test
+ void of_nullExpressionResult_returnsEmpty() {
+ CqlExpressionValue wrapper = CqlExpressionValue.of(null);
+
+ assertTrue(wrapper.isNull());
+ assertTrue(wrapper.isEmpty());
+ assertSame(CqlExpressionValue.empty(), wrapper);
+ assertEquals(Set.of(), wrapper.evaluatedResources());
+ }
+
+ @Test
+ void of_expressionResult_propagatesValueAndResources() {
+ Patient patient = new Patient();
+ patient.setId("p1");
+ Set resources = new HashSet<>(List.of(patient));
+ ExpressionResult result = new ExpressionResult(patient, resources);
+
+ CqlExpressionValue wrapper = CqlExpressionValue.of(result);
+
+ assertSame(patient, wrapper.raw());
+ assertEquals(resources, wrapper.evaluatedResources());
+ }
+
+ @Test
+ void of_expressionResultWithNullResources_substitutesEmptySet() {
+ ExpressionResult result = new ExpressionResult("v", null);
+
+ CqlExpressionValue wrapper = CqlExpressionValue.of(result);
+
+ assertEquals(Set.of(), wrapper.evaluatedResources());
+ }
+
+ @Test
+ void ofRaw_acceptsNullResources() {
+ CqlExpressionValue wrapper = CqlExpressionValue.ofRaw(42, null);
+
+ assertEquals(42, wrapper.raw());
+ assertEquals(Set.of(), wrapper.evaluatedResources());
+ }
+
+ // -- predicates --------------------------------------------------------------
+
+ @Test
+ void isBooleanAndIsTrue_onlyForBooleanRawValues() {
+ assertTrue(CqlExpressionValue.ofRaw(true, null).isBoolean());
+ assertTrue(CqlExpressionValue.ofRaw(true, null).isTrue());
+ assertTrue(CqlExpressionValue.ofRaw(false, null).isBoolean());
+ assertFalse(CqlExpressionValue.ofRaw(false, null).isTrue());
+ assertFalse(CqlExpressionValue.ofRaw("string", null).isBoolean());
+ assertFalse(CqlExpressionValue.ofRaw(null, null).isTrue());
+ }
+
+ @Test
+ void isIterable_trueForCollectionsAndOtherIterables() {
+ assertTrue(CqlExpressionValue.ofRaw(List.of(1, 2), null).isIterable());
+ assertTrue(CqlExpressionValue.ofRaw(Set.of(1, 2), null).isIterable());
+ assertFalse(CqlExpressionValue.ofRaw("string", null).isIterable());
+ assertFalse(CqlExpressionValue.ofRaw(null, null).isIterable());
+ }
+
+ @ParameterizedTest
+ @MethodSource("emptyValues")
+ void isEmpty_recognizesNullEmptyIterableEmptyMap(Object raw) {
+ assertTrue(CqlExpressionValue.ofRaw(raw, null).isEmpty());
+ }
+
+ static java.util.stream.Stream emptyValues() {
+ return java.util.stream.Stream.of(null, Collections.emptyList(), Collections.emptySet(), Map.of());
+ }
+
+ @Test
+ void isEmpty_falseForNonEmptyContainersAndScalars() {
+ assertFalse(CqlExpressionValue.ofRaw(List.of(1), null).isEmpty());
+ assertFalse(CqlExpressionValue.ofRaw(Map.of("k", "v"), null).isEmpty());
+ assertFalse(CqlExpressionValue.ofRaw("anything", null).isEmpty());
+ assertFalse(CqlExpressionValue.ofRaw(false, null).isEmpty());
+ }
+
+ // -- asBoolean ---------------------------------------------------------------
+
+ @Test
+ void asBoolean_presentOnlyForBooleanValues() {
+ assertEquals(
+ java.util.Optional.of(true),
+ CqlExpressionValue.ofRaw(true, null).asBoolean());
+ assertEquals(
+ java.util.Optional.of(false),
+ CqlExpressionValue.ofRaw(false, null).asBoolean());
+ assertEquals(
+ java.util.Optional.empty(),
+ CqlExpressionValue.ofRaw("not-bool", null).asBoolean());
+ assertEquals(
+ java.util.Optional.empty(), CqlExpressionValue.ofRaw(null, null).asBoolean());
+ }
+
+ // -- asIterable / asIterableOrNull ------------------------------------------
+
+ @ParameterizedTest
+ @MethodSource("asIterableCases")
+ void asIterable_normalizesAllShapes(Object raw, List expected) {
+ Iterable actual = CqlExpressionValue.ofRaw(raw, null).asIterable();
+ assertEquals(expected, toList(actual));
+ }
+
+ static java.util.stream.Stream asIterableCases() {
+ Patient patient = new Patient();
+ patient.setId("p1");
+ Encounter encounter = new Encounter();
+ encounter.setId("e1");
+ return java.util.stream.Stream.of(
+ Arguments.of(null, List.of()),
+ Arguments.of(true, List.of(true)),
+ Arguments.of(false, List.of(false)),
+ Arguments.of(List.of(), List.of()),
+ Arguments.of(List.of(patient), List.of(patient)),
+ Arguments.of(List.of(patient, encounter), List.of(patient, encounter)),
+ Arguments.of("string", List.of("string")),
+ Arguments.of(BigDecimal.ONE, List.of(BigDecimal.ONE)),
+ Arguments.of(42, List.of(42)),
+ Arguments.of(Map.of("k", "v"), List.of(Map.of("k", "v"))));
+ }
+
+ @Test
+ void asIterable_passesThroughIterableInstance() {
+ ArrayList source = new ArrayList<>(List.of("a", "b"));
+ Iterable result = CqlExpressionValue.ofRaw(source, null).asIterable();
+
+ // Same iterable instance is returned (no copying)
+ assertSame(source, result);
+ }
+
+ @Test
+ void asIterableOrNull_preservesNullForTrueNullValue() {
+ assertNull(CqlExpressionValue.ofRaw(null, null).asIterableOrNull());
+ }
+
+ @Test
+ void asIterableOrNull_normalizesScalarToSingletonList() {
+ Iterable result = CqlExpressionValue.ofRaw("scalar", null).asIterableOrNull();
+
+ assertNotNull(result);
+ assertEquals(List.of("scalar"), toList(result));
+ }
+
+ @Test
+ void asIterableOrNull_passesThroughIterable() {
+ List source = List.of("a", "b");
+ assertSame(source, CqlExpressionValue.ofRaw(source, null).asIterableOrNull());
+ }
+
+ // -- resolveForPopulation ----------------------------------------------------
+
+ @Test
+ void resolveForPopulation_nullValueReturnsEmpty() {
+ EvaluationResult evaluationResult = new EvaluationResult();
+
+ Iterable result =
+ CqlExpressionValue.ofRaw(null, null).resolveForPopulation("Patient", evaluationResult);
+
+ assertEquals(List.of(), toList(result));
+ }
+
+ @Test
+ void resolveForPopulation_falseReturnsEmpty() {
+ EvaluationResult evaluationResult = new EvaluationResult();
+ evaluationResult.set(new EvaluationExpressionRef("Patient"), new ExpressionResult(new Patient(), Set.of()));
+
+ Iterable result =
+ CqlExpressionValue.ofRaw(false, null).resolveForPopulation("Patient", evaluationResult);
+
+ assertEquals(List.of(), toList(result));
+ }
+
+ @Test
+ void resolveForPopulation_trueLooksUpSubjectContextValue() {
+ Patient patient = new Patient();
+ patient.setId("p1");
+ EvaluationResult evaluationResult = new EvaluationResult();
+ evaluationResult.set(new EvaluationExpressionRef("Patient"), new ExpressionResult(patient, Set.of()));
+
+ Iterable result =
+ CqlExpressionValue.ofRaw(true, null).resolveForPopulation("Patient", evaluationResult);
+
+ List resolved = toList(result);
+ assertEquals(1, resolved.size());
+ assertSame(patient, resolved.get(0));
+ }
+
+ @Test
+ void resolveForPopulation_trueButNoSubjectResultThrows() {
+ EvaluationResult evaluationResult = new EvaluationResult();
+
+ final CqlExpressionValue cqlExpressionValue = CqlExpressionValue.ofRaw(true, null);
+
+ CqlExpressionValueException ex = assertThrows(
+ CqlExpressionValueException.class,
+ () -> cqlExpressionValue.resolveForPopulation("Patient", evaluationResult));
+
+ assertTrue(ex.getMessage().contains("Patient"));
+ }
+
+ @Test
+ void resolveForPopulation_iterableReturnedAsIs() {
+ Patient p1 = new Patient();
+ p1.setId("p1");
+ Patient p2 = new Patient();
+ p2.setId("p2");
+ List source = List.of(p1, p2);
+
+ Iterable result =
+ CqlExpressionValue.ofRaw(source, null).resolveForPopulation("Patient", new EvaluationResult());
+
+ assertSame(source, result);
+ }
+
+ @Test
+ void resolveForPopulation_scalarWrappedInSingletonList() {
+ Encounter encounter = new Encounter();
+ encounter.setId("e1");
+
+ Iterable result =
+ CqlExpressionValue.ofRaw(encounter, null).resolveForPopulation("Patient", new EvaluationResult());
+
+ assertEquals(List.of(encounter), toList(result));
+ }
+
+ // -- isMap / asMap -----------------------------------------------------------
+
+ @Test
+ void isMap_trueOnlyForMapValues() {
+ assertTrue(CqlExpressionValue.ofRaw(Map.of("k", "v"), null).isMap());
+ assertTrue(CqlExpressionValue.ofRaw(Map.of(), null).isMap());
+ assertFalse(CqlExpressionValue.ofRaw(List.of(), null).isMap());
+ assertFalse(CqlExpressionValue.ofRaw("string", null).isMap());
+ assertFalse(CqlExpressionValue.ofRaw(null, null).isMap());
+ }
+
+ @Test
+ void asMap_emptyOptionalForNonMapInputs() {
+ assertEquals(
+ java.util.Optional.empty(), CqlExpressionValue.ofRaw(null, null).asMap());
+ assertEquals(
+ java.util.Optional.empty(),
+ CqlExpressionValue.ofRaw("scalar", null).asMap());
+ assertEquals(
+ java.util.Optional.empty(),
+ CqlExpressionValue.ofRaw(List.of(1, 2), null).asMap());
+ }
+
+ @Test
+ void asMap_returnsTypedMapForMapInputs() {
+ Patient patient = new Patient();
+ patient.setId("p1");
+ Map source = new HashMap<>();
+ source.put(patient, 42);
+
+ java.util.Optional> opt =
+ CqlExpressionValue.ofRaw(source, null).asMap();
+
+ assertTrue(opt.isPresent());
+ assertSame(source, opt.get());
+ assertEquals(42, opt.get().get(patient));
+ }
+
+ @Test
+ void asMap_emptyMapInputYieldsEmptyMap() {
+ java.util.Optional> opt =
+ CqlExpressionValue.ofRaw(Map.of(), null).asMap();
+
+ assertTrue(opt.isPresent());
+ assertTrue(opt.get().isEmpty());
+ }
+
+ // -- asObservationAccumulator ------------------------------------------------
+
+ @Test
+ void asObservationAccumulator_emptyOptionalForNonAccumulatorInputs() {
+ assertEquals(
+ java.util.Optional.empty(), CqlExpressionValue.ofRaw(null, null).asObservationAccumulator());
+ assertEquals(
+ java.util.Optional.empty(),
+ CqlExpressionValue.ofRaw("scalar", null).asObservationAccumulator());
+ assertEquals(
+ java.util.Optional.empty(),
+ CqlExpressionValue.ofRaw(Map.of("k", "v"), null).asObservationAccumulator());
+ assertEquals(
+ java.util.Optional.empty(),
+ CqlExpressionValue.ofRaw(List.of(), null).asObservationAccumulator());
+ }
+
+ @Test
+ void asObservationAccumulator_returnsAccumulatorWhenWrapped() {
+ Encounter enc = new Encounter();
+ enc.setId("Encounter/1");
+ ObservationAccumulator acc =
+ new ObservationAccumulator(List.of(new ObservationEntry(enc, new QuantityDef(42.0))));
+
+ java.util.Optional opt =
+ CqlExpressionValue.ofRaw(acc, null).asObservationAccumulator();
+
+ assertTrue(opt.isPresent());
+ assertSame(acc, opt.get());
+ assertEquals(1, opt.get().size());
+ assertSame(enc, opt.get().entries().get(0).inputResource());
+ }
+
+ @Test
+ void asObservationAccumulator_emptyAccumulatorYieldsEmptyAccumulator() {
+ ObservationAccumulator empty = new ObservationAccumulator(List.of());
+
+ java.util.Optional opt =
+ CqlExpressionValue.ofRaw(empty, null).asObservationAccumulator();
+
+ assertTrue(opt.isPresent());
+ assertTrue(opt.get().isEmpty());
+ assertEquals(0, opt.get().size());
+ }
+
+ // -- asFunctionResultAccumulator ---------------------------------------------
+
+ @Test
+ void asFunctionResultAccumulator_emptyOptionalForNonAccumulatorInputs() {
+ assertEquals(
+ java.util.Optional.empty(), CqlExpressionValue.ofRaw(null, null).asFunctionResultAccumulator());
+ assertEquals(
+ java.util.Optional.empty(),
+ CqlExpressionValue.ofRaw("scalar", null).asFunctionResultAccumulator());
+ assertEquals(
+ java.util.Optional.empty(),
+ CqlExpressionValue.ofRaw(Map.of("k", "v"), null).asFunctionResultAccumulator());
+ assertEquals(
+ java.util.Optional.empty(),
+ CqlExpressionValue.ofRaw(List.of(), null).asFunctionResultAccumulator());
+ }
+
+ @Test
+ void asFunctionResultAccumulator_returnsAccumulatorWhenWrapped() {
+ Encounter enc = new Encounter();
+ enc.setId("Encounter/1");
+ FunctionResultAccumulator acc =
+ new FunctionResultAccumulator(List.of(new FunctionResultEntry(enc, "stratum-value")));
+
+ java.util.Optional opt =
+ CqlExpressionValue.ofRaw(acc, null).asFunctionResultAccumulator();
+
+ assertTrue(opt.isPresent());
+ assertSame(acc, opt.get());
+ assertEquals(1, opt.get().size());
+ assertSame(enc, opt.get().entries().get(0).input());
+ assertEquals("stratum-value", opt.get().entries().get(0).output());
+ }
+
+ @Test
+ void asFunctionResultAccumulator_isNotConfusedWithObservationAccumulator() {
+ ObservationAccumulator obsAcc =
+ new ObservationAccumulator(List.of(new ObservationEntry("k", new QuantityDef(1.0))));
+
+ // Same wrapper held only as the OTHER accumulator type returns the right narrowing
+ CqlExpressionValue wrapper = CqlExpressionValue.ofRaw(obsAcc, null);
+ assertTrue(wrapper.asObservationAccumulator().isPresent());
+ assertEquals(java.util.Optional.empty(), wrapper.asFunctionResultAccumulator());
+ }
+
+ // -- valueAsSet --------------------------------------------------------------
+
+ @Test
+ void valueAsSet_nullValueYieldsEmptySet() {
+ Set set = CqlExpressionValue.ofRaw(null, null).valueAsSet();
+
+ assertTrue(set instanceof HashSetForFhirResourcesAndCqlTypes);
+ assertTrue(set.isEmpty());
+ }
+
+ @Test
+ void valueAsSet_scalarYieldsSingletonSet() {
+ Patient patient = new Patient();
+ patient.setId("p1");
+
+ Set set = CqlExpressionValue.ofRaw(patient, null).valueAsSet();
+
+ assertTrue(set instanceof HashSetForFhirResourcesAndCqlTypes);
+ assertEquals(1, set.size());
+ assertTrue(set.contains(patient));
+ }
+
+ @Test
+ void valueAsSet_iterableFlattensIntoSet() {
+ Patient p1 = new Patient();
+ p1.setId("p1");
+ Patient p2 = new Patient();
+ p2.setId("p2");
+
+ Set set = CqlExpressionValue.ofRaw(List.of(p1, p2), null).valueAsSet();
+
+ assertTrue(set instanceof HashSetForFhirResourcesAndCqlTypes);
+ assertEquals(2, set.size());
+ }
+
+ // -- nonNullValues -----------------------------------------------------------
+
+ @Test
+ void nonNullValues_nullValueYieldsEmptyList() {
+ assertEquals(List.of(), CqlExpressionValue.ofRaw(null, null).nonNullValues());
+ }
+
+ @Test
+ void nonNullValues_scalarYieldsSingletonList() {
+ assertEquals(List.of("v"), CqlExpressionValue.ofRaw("v", null).nonNullValues());
+ }
+
+ @Test
+ void nonNullValues_iterableFiltersOutNullElements() {
+ ArrayList source = new ArrayList<>();
+ source.add("a");
+ source.add(null);
+ source.add("b");
+ source.add(null);
+
+ assertEquals(List.of("a", "b"), CqlExpressionValue.ofRaw(source, null).nonNullValues());
+ }
+
+ @Test
+ void nonNullValues_emptyIterableYieldsEmptyList() {
+ assertEquals(List.of(), CqlExpressionValue.ofRaw(List.of(), null).nonNullValues());
+ }
+
+ // -- evaluatedResources / raw ------------------------------------------------
+
+ @Test
+ void evaluatedResources_returnsTheBackingSet() {
+ Set resources = new HashSet<>(List.of("r1", "r2"));
+ CqlExpressionValue wrapper = CqlExpressionValue.ofRaw("v", resources);
+
+ assertSame(resources, wrapper.evaluatedResources());
+ }
+
+ @Test
+ void raw_returnsUnderlyingObject() {
+ Map accumulator = new HashMap<>();
+ accumulator.put("k", 1);
+
+ CqlExpressionValue wrapper = CqlExpressionValue.ofRaw(accumulator, null);
+
+ assertSame(accumulator, wrapper.raw());
+ }
+
+ private static List toList(Iterable it) {
+ return StreamSupport.stream(it.spliterator(), false).toList();
+ }
+}
diff --git a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/common/MeasureObservationHandlerTest.java b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/common/MeasureObservationHandlerTest.java
index 37801655ec..36738587c9 100644
--- a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/common/MeasureObservationHandlerTest.java
+++ b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/common/MeasureObservationHandlerTest.java
@@ -6,13 +6,11 @@
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
-import java.util.Map;
import java.util.Set;
import org.hl7.fhir.r4.model.Encounter;
import org.junit.jupiter.api.Test;
@@ -58,12 +56,11 @@ void removeObservationResourcesInPopulation_removesMatchingResources_withSeparat
encounter1InExclusion.getIdElement(),
"Encounter IDs should be equal");
- // Create measure observation map: Map
- Map observationMap1 = new HashMapForFhirResourcesAndCqlTypes<>();
- observationMap1.put(encounter1InObservation, new QuantityDef(120.0));
-
- Map observationMap2 = new HashMapForFhirResourcesAndCqlTypes<>();
- observationMap2.put(encounter2InObservation, new QuantityDef(180.0));
+ // Create observation accumulators
+ var observationMap1 = new ObservationAccumulator(
+ List.of(new ObservationEntry(encounter1InObservation, new QuantityDef(120.0))));
+ var observationMap2 = new ObservationAccumulator(
+ List.of(new ObservationEntry(encounter2InObservation, new QuantityDef(180.0))));
// Create MEASUREOBSERVATION population with the maps
measureObservationDef = new PopulationDef(
@@ -76,10 +73,8 @@ void removeObservationResourcesInPopulation_removesMatchingResources_withSeparat
ContinuousVariableObservationAggregateMethod.SUM,
List.of());
- Set