Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
663 changes: 663 additions & 0 deletions PRPs/prp-sde-stratum-value-lazy-conversion.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,40 @@ object ClassInstanceHelper {
val DSTU3_RESOURCE_TYPE_NAMES =
org.hl7.fhir.dstu3.model.ResourceType.entries.map { obj -> obj.name }
val R4_RESOURCE_TYPE_NAMES = org.hl7.fhir.r4.model.ResourceType.entries.map { obj -> obj.name }
val R4B_RESOURCE_TYPE_NAMES =
org.hl7.fhir.r4b.model.ResourceType.entries.map { obj -> obj.name }
val R5_RESOURCE_TYPE_NAMES = org.hl7.fhir.r5.model.ResourceType.entries.map { obj -> obj.name }

/** Every name that is a resource type in some modelled FHIR version. See [isFhirResource]. */
private val ALL_RESOURCE_TYPE_NAMES: Set<kotlin.String> =
(DSTU3_RESOURCE_TYPE_NAMES +
R4_RESOURCE_TYPE_NAMES +
R4B_RESOURCE_TYPE_NAMES +
R5_RESOURCE_TYPE_NAMES)
.toSet()

@JvmStatic
fun getId(classInstance: ClassInstance): kotlin.String? {
val idPart = getIdPart(classInstance) ?: return null
return "${classInstance.type.localPart}/$idPart"
}

/**
* The bare `id.value` of a FHIR [ClassInstance], unqualified by resource type, or null when the
* instance carries no id.
*
* This is the id that a HAPI resource converted from the same instance reports from
* `getIdElement()`: the conversion copies `id.value` and nothing else, so the resource type
* that [getId] prepends is not part of it. Callers that need to agree with a converted resource
* want this; callers building a reference want [getId].
*/
@JvmStatic
fun getIdPart(classInstance: ClassInstance): kotlin.String? {
if (classInstance.type.namespaceURI == fhirModelNamespaceUri && classInstance.has("id")) {
val resourceIdInstance = classInstance["id"] as ClassInstance?
val resourceIdValue = resourceIdInstance?.get("value")
if (resourceIdValue != null) {
val type = classInstance.type.localPart
return "$type/${plainStringValue(resourceIdValue)}"
return plainStringValue(resourceIdValue)
}
}
return null
Expand Down Expand Up @@ -75,4 +100,21 @@ object ClassInstanceHelper {
}
return false
}

/**
* Whether the instance is a FHIR resource in any FHIR version modelled here, for callers that
* hold no FHIR version of their own. A [ClassInstance] names its type but not the version that
* type came from, and the versions disagree about which names are resources ("Sequence" is a
* DSTU3 resource; R4 renamed it "MolecularSequence"), so this asks every version rather than
* assuming one.
*
* Sound only for questions whose answer does not depend on the version — telling a resource
* from a complex datatype so it can be keyed by [getIdPart], for instance. Converting to HAPI
* FHIR is not such a question: use the version-qualified overload there.
*/
@JvmStatic
fun isFhirResource(classInstance: ClassInstance): Boolean {
return classInstance.type.namespaceURI == fhirModelNamespaceUri &&
ALL_RESOURCE_TYPE_NAMES.contains(classInstance.type.localPart)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package org.opencds.cqf.fhir.cql.engine.parameters

import ca.uhn.fhir.context.BaseRuntimeChildDefinition
import ca.uhn.fhir.context.BaseRuntimeElementCompositeDefinition
import ca.uhn.fhir.context.BaseRuntimeElementDefinition
import ca.uhn.fhir.context.BaseRuntimeElementDefinition.ChildTypeEnum
import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.fhirpath.IFhirPath
import ca.uhn.fhir.rest.server.exceptions.InternalErrorException
Expand Down Expand Up @@ -380,45 +383,37 @@ class CqlFhirParametersConverter(
* Converts a CQL [Value] to a HAPI FHIR structure.
*
* @param valueToConvert The CQL value to convert.
* @param parentName The enclosing FHIR type for nested/inner HAPI FHIR classes representing
* backbone elements.
* @param childDefinition The HAPI child definition this value is being converted into, or null
* at the top level, where there is no enclosing element. HAPI is authoritative about what
* type an element holds - the inner class it declares for a backbone element, or the
* `Enumeration` and its `EnumFactory` behind a bound code - where the CQL value's own type
* name is only a guess at it.
*/
fun toFhirValue(valueToConvert: Value, parentName: kotlin.String?): IBase {
var clazz: Class<*>?
val typeName: kotlin.String
when (valueToConvert) {
is NamedTypeValue -> {
typeName = valueToConvert.type.localPart
clazz = modelResolver.resolveType(typeName)
}

else -> {
typeName = valueToConvert.typeAsString
clazz = null
}
}
fun toFhirValue(valueToConvert: Value, childDefinition: BaseRuntimeChildDefinition?): IBase {
val typeName: kotlin.String =
if (valueToConvert is NamedTypeValue) valueToConvert.type.localPart
else valueToConvert.typeAsString

val elementDefinition =
childDefinition?.let { elementDefinitionFor(it, valueToConvert, typeName) }
val clazz: Class<*>? =
elementDefinition?.implementingClass
?: if (valueToConvert is NamedTypeValue) modelResolver.resolveType(typeName)
else null
requireNotNull(clazz) { "Could not resolve FHIR type: $typeName" }
if (
!parentName.isNullOrBlank() &&
!clazz.isEnum &&
clazz.name.contains("$") &&
(clazz.enclosingClass.simpleName != parentName)
) {
val correctClassName = clazz.name.replace(clazz.enclosingClass.simpleName, parentName)
try {
clazz = Class.forName(correctClassName)
} catch (e: ClassNotFoundException) {
throw IllegalArgumentException("Could not resolve inner FHIR type: $typeName")
}
}

val instance: IBase
try {
if (clazz.isEnum) {
instance = modelResolver.createHapiInstance(typeName) as IBase
} else {
instance = clazz.getDeclaredConstructor().newInstance() as IBase
}
instance =
when {
// Instantiating through HAPI passes a bound code its EnumFactory, which
// reflecting on the class does not, and without which no code parses.
elementDefinition != null ->
elementDefinition.newInstance(childDefinition.instanceConstructorArguments)
as IBase
clazz.isEnum -> modelResolver.createHapiInstance(typeName) as IBase
else -> clazz.getDeclaredConstructor().newInstance() as IBase
}
} catch (e: Exception) {
throw IllegalArgumentException("Could not create instance of $typeName", e)
}
Expand Down Expand Up @@ -452,37 +447,62 @@ class CqlFhirParametersConverter(
return instance
}

val ibaseClazz = clazz as Class<out IBase?>
var definition =
fhirContext.getElementDefinition(ibaseClazz)
as BaseRuntimeElementCompositeDefinition<*>?
if (definition == null) {
val resourceClazz = clazz as Class<out IBaseResource?>
definition = fhirContext.getResourceDefinition(resourceClazz)
}

// `toFhirValue()` is called recursively for all subelements of the CQL class instance. If
// the current class is a nested/inner class, the same parent resource name (type name)
// should be used because in HAPI FHIR, all classes representing nested backbone elements
// are declared directly inside the named parent class.
val parentNameForChildren = if (clazz.enclosingClass == null) typeName else parentName
val definition =
elementDefinition as? BaseRuntimeElementCompositeDefinition<*>
?: compositeDefinitionFor(clazz)

for (child in definition.getChildren()) {
val elementValue = (valueToConvert as ClassInstance)[child.elementName]
if (elementValue == null) {
continue
}
val elementValue = (valueToConvert as ClassInstance)[child.elementName] ?: continue
if (elementValue is List) {
for (item in elementValue) {
child.mutator.addValue(instance, toFhirValue(item!!, parentNameForChildren))
child.mutator.addValue(instance, toFhirValue(item!!, child))
}
} else {
child.mutator.addValue(instance, toFhirValue(elementValue, parentNameForChildren))
child.mutator.addValue(instance, toFhirValue(elementValue, child))
}
}
return instance
}

/**
* The HAPI element definition for a value being converted into [childDefinition], or null when
* HAPI cannot settle it on its own and the CQL type name has to answer instead.
*/
private fun elementDefinitionFor(
childDefinition: BaseRuntimeChildDefinition,
valueToConvert: Value,
typeName: kotlin.String,
): BaseRuntimeElementDefinition<*>? {
val validChildNames = childDefinition.validChildNames
val definition =
if (validChildNames.size == 1) {
childDefinition.getChildByName(validChildNames.first())
} else {
// A choice element ([x]) holds one of several types, so the CQL value's own
// type picks between them; HAPI still supplies the class it will accept.
val candidate =
if (valueToConvert is NamedTypeValue)
runCatching { modelResolver.resolveType(typeName) }.getOrNull()
else null
if (candidate != null && IBase::class.java.isAssignableFrom(candidate)) {
@Suppress("UNCHECKED_CAST")
childDefinition.getChildElementDefinitionByDatatype(
candidate as Class<out IBase>
)
} else null
}
return definition?.takeIf { SUPPORTED_CHILD_TYPES.contains(it.childType) }
}

@Suppress("UNCHECKED_CAST")
private fun compositeDefinitionFor(clazz: Class<*>): BaseRuntimeElementCompositeDefinition<*> {
val elementDefinition =
fhirContext.getElementDefinition(clazz as Class<out IBase?>)
as BaseRuntimeElementCompositeDefinition<*>?
return elementDefinition
?: fhirContext.getResourceDefinition(clazz as Class<out IBaseResource?>)
}

private fun convertToCql(ppca: IParametersParameterComponentAdapter): Value? {
if (ppca.hasValue()) {
return this.fhirTypeConverter.toCqlType(ppca.getValue()) as Value?
Expand All @@ -496,6 +516,21 @@ class CqlFhirParametersConverter(
}

companion object {
/**
* The element kinds this converter can instantiate from a HAPI child definition.
* `contained` (a CONTAINED_RESOURCE_LIST, whose implementing class is the IBaseResource
* interface) and `Narrative.div` (an XhtmlNode, which is not an IBase at all) are left to
* the CQL type name, which handles them no worse than it did before.
*/
private val SUPPORTED_CHILD_TYPES =
setOf(
ChildTypeEnum.PRIMITIVE_DATATYPE,
ChildTypeEnum.ID_DATATYPE,
ChildTypeEnum.COMPOSITE_DATATYPE,
ChildTypeEnum.RESOURCE_BLOCK,
ChildTypeEnum.RESOURCE,
)

// This is basically a copy and paste from R4FhirTypeConverter, but it's not exposed.
const val EMPTY_LIST_EXT_URL: kotlin.String =
"http://hl7.org/fhir/StructureDefinition/cqf-isEmptyList"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,9 @@ void evaluationResultToParameters() {
var testData = new EvaluationResult();
testData.set(
new EvaluationExpressionRef("Patient"),
new ExpressionResult(new ClassInstance(new QName(fhirModelNamespaceUri, "Patient"), Map.of()), null));
testData.set(new EvaluationExpressionRef("Numerator"), new ExpressionResult(new Boolean(true), null));
new ExpressionResult(
new ClassInstance(new QName(fhirModelNamespaceUri, "Patient"), Map.of()), Map.of()));
testData.set(new EvaluationExpressionRef("Numerator"), new ExpressionResult(new Boolean(true), Map.of()));

var actual = (Parameters) cqlFhirParametersConverter.toFhirParameters(testData);

Expand All @@ -94,9 +95,11 @@ void evaluationResultToEmptyListParameters() {
var testData = new EvaluationResult();
testData.set(
new EvaluationExpressionRef("Patient"),
new ExpressionResult(new ClassInstance(new QName(fhirModelNamespaceUri, "Patient"), Map.of()), null));
new ExpressionResult(
new ClassInstance(new QName(fhirModelNamespaceUri, "Patient"), Map.of()), Map.of()));
testData.set(
new EvaluationExpressionRef("Encounters"), new ExpressionResult(List.Companion.getEMPTY_LIST(), null));
new EvaluationExpressionRef("Encounters"),
new ExpressionResult(List.Companion.getEMPTY_LIST(), Map.of()));

Parameters actual = (Parameters) cqlFhirParametersConverter.toFhirParameters(testData);

Expand All @@ -116,7 +119,7 @@ void evaluationResultsWithListContainingNullValue() {
var cqlList = new List(testList);

var testData = new EvaluationResult();
testData.set(new EvaluationExpressionRef("NullInList"), new ExpressionResult(cqlList, null));
testData.set(new EvaluationExpressionRef("NullInList"), new ExpressionResult(cqlList, Map.of()));

var actual = (Parameters) cqlFhirParametersConverter.toFhirParameters(testData);

Expand All @@ -132,8 +135,9 @@ void evaluationResultNullParameters() {
var testData = new EvaluationResult();
testData.set(
new EvaluationExpressionRef("Patient"),
new ExpressionResult(new ClassInstance(new QName(fhirModelNamespaceUri, "Patient"), Map.of()), null));
testData.set(new EvaluationExpressionRef("Null"), new ExpressionResult(null, null));
new ExpressionResult(
new ClassInstance(new QName(fhirModelNamespaceUri, "Patient"), Map.of()), Map.of()));
testData.set(new EvaluationExpressionRef("Null"), new ExpressionResult(null, Map.of()));

var actual = (Parameters) cqlFhirParametersConverter.toFhirParameters(testData);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,21 @@ private CqlExpressionValue(@Nullable String expressionName, @Nullable Object raw

/**
* Wraps an {@link ExpressionResult}. Accepts a null result and yields an empty wrapper.
* <p>
* The engine hands back its evaluated resources as a Map keyed by resource id. This pipeline
* carries them as a Set, so the keys are dropped here - but into a set that keys on the same
* identity, not a plain {@code HashSet}, which would hash each resource by walking its element
* graph and give back the cost the engine's own keying exists to avoid.
*/
public static CqlExpressionValue of(@Nullable String expressionName, @Nullable ExpressionResult result) {
if (result == null) {
return EMPTY;
}
var resources = result.getEvaluatedResources();
return new CqlExpressionValue(
expressionName, result.getValue(), resources != null ? resources : Collections.emptySet());
expressionName,
result.getValue(),
new HashSetForFhirResourcesAndCqlTypes<>(
result.getEvaluatedResources().values()));
}

/**
Expand Down
Loading
Loading