Skip to content

Commit 8cd37a4

Browse files
JPercivalclaude
andcommitted
feat: add model type resolution to TypeResolver for context and property types
- TypeResolver: resolve context identifiers (e.g., "Patient") to their model ClassType, and resolve property access on model types (e.g., Patient.birthDatetime → System.DateTime) - SemanticAnalyzer: add ToDate wrapping in AgeIn desugarer for Year/Month precision (CQL spec requires Date precision) - AgeOperators still skipped: ConversionExpression from desugaring emits incorrectly — ToDate wrapper disappears during emission. Needs investigation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7be32bf commit 8cd37a4

3 files changed

Lines changed: 71 additions & 10 deletions

File tree

Src/java/cql-to-elm/src/commonMain/kotlin/org/cqframework/cql/cql2elm/analysis/SemanticAnalyzer.kt

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class SemanticAnalyzer(
6262

6363
for (iteration in 1..maxIterations) {
6464
// INFER: type resolution + overload resolution (reads synthetics for effective types)
65-
val resolver = TypeResolver(operatorRegistry, syntheticTable)
65+
val resolver = TypeResolver(operatorRegistry, syntheticTable, modelManager)
6666
val iterationTypeTable = resolver.resolve(desugared, symbols)
6767

6868
// Merge this iteration's results into the cumulative table.
@@ -178,8 +178,24 @@ private class AgeInDesugarer(private val birthDatePropertyName: String) :
178178
if (!isAt && args.isNotEmpty()) return super.visitFunctionCallExpression(expression)
179179
if (isAt && args.size != 1) return super.visitFunctionCallExpression(expression)
180180

181-
val birthDateExpr = buildBirthDateExpr(expression)
182-
val calculateName = "CalculateAgeIn${name.removePrefix("AgeIn")}"
181+
var birthDateExpr: org.hl7.cql.ast.Expression = buildBirthDateExpr(expression)
182+
val suffix = name.removePrefix("AgeIn")
183+
val calculateName = "CalculateAgeIn$suffix"
184+
185+
// CQL spec: AgeIn(Years|Months)() operates on Date precision.
186+
// Wrap birthDate (DateTime) in ToDate for Year/Month 0-arg calls.
187+
if (!isAt && (suffix == "Years" || suffix == "Months")) {
188+
birthDateExpr =
189+
org.hl7.cql.ast.ConversionExpression(
190+
operand = birthDateExpr,
191+
destinationType =
192+
org.hl7.cql.ast.NamedTypeSpecifier(
193+
name = org.hl7.cql.ast.QualifiedIdentifier(listOf("Date"))
194+
),
195+
locator = expression.locator,
196+
)
197+
}
198+
183199
val visitedArgs = args.map { visitExpression(it) }
184200
val newArgs = listOf(birthDateExpr) + visitedArgs
185201

Src/java/cql-to-elm/src/commonMain/kotlin/org/cqframework/cql/cql2elm/analysis/TypeResolver.kt

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
package org.cqframework.cql.cql2elm.analysis
44

5+
import org.cqframework.cql.cql2elm.ModelManager
56
import org.cqframework.cql.cql2elm.model.Conversion
67
import org.cqframework.cql.cql2elm.model.OperatorResolution
78
import org.hl7.cql.ast.AsExpression
@@ -82,6 +83,7 @@ import org.hl7.cql.model.TupleTypeElement
8283
class TypeResolver(
8384
internal val operatorRegistry: OperatorRegistry,
8485
private val syntheticTable: SyntheticTable? = null,
86+
private val modelManager: ModelManager? = null,
8587
) : ExpressionFold<DataType?> {
8688

8789
/** Tracks expression definitions currently being resolved to detect circular references. */
@@ -294,8 +296,8 @@ class TypeResolver(
294296
): DataType? = inferFunctionCallType(expr, arguments)
295297

296298
override fun onPropertyAccess(expr: PropertyAccessExpression, target: DataType?): DataType? {
297-
// Target already pre-folded (identifiers within resolved); nothing more to do
298-
return null
299+
if (target == null) return null
300+
return resolvePropertyType(target, expr.property.value)
299301
}
300302

301303
override fun onIndex(expr: IndexExpression, target: DataType?, index: DataType?): DataType? =
@@ -417,7 +419,7 @@ class TypeResolver(
417419
// referencing the context name should resolve as expression references.
418420
symbolTable.resolveContext(name)?.let { resolution ->
419421
typeTable.setIdentifierResolution(expression, resolution)
420-
return null // Type unknown at analysis time (model-dependent)
422+
return resolveContextType(name)
421423
}
422424

423425
// Check parameter definitions
@@ -840,4 +842,47 @@ class TypeResolver(
840842
typeTable.setOperatorResolution(expression, resolution)
841843
return resolution.operator.resultType
842844
}
845+
846+
/** Resolve the type of a context identifier (e.g., "Patient") from the loaded models. */
847+
private fun resolveContextType(contextName: String): DataType? {
848+
val mm = modelManager ?: return null
849+
for (usingDef in symbolTable.usingDefinitions) {
850+
if (usingDef.modelIdentifier.simpleName == "System") continue
851+
val model =
852+
try {
853+
mm.resolveModel(usingDef.modelIdentifier.simpleName, usingDef.version?.value)
854+
} catch (_: Exception) {
855+
continue
856+
}
857+
val ctx = model.resolveContextName(contextName, mustResolve = false)
858+
if (ctx != null) return ctx.type
859+
}
860+
return null
861+
}
862+
863+
/** Resolve the type of a property on a source type (e.g., "birthDatetime" on QDM.Patient). */
864+
private fun resolvePropertyType(sourceType: DataType, propertyName: String): DataType? {
865+
if (sourceType is org.hl7.cql.model.ClassType) {
866+
// Search elements including inherited ones
867+
var current: DataType? = sourceType
868+
while (current is org.hl7.cql.model.ClassType) {
869+
val element = current.elements.firstOrNull { it.name == propertyName }
870+
if (element != null) return element.type
871+
current = current.baseType
872+
}
873+
}
874+
if (sourceType is org.hl7.cql.model.IntervalType) {
875+
return when (propertyName) {
876+
"low",
877+
"high" -> sourceType.pointType
878+
"lowClosed",
879+
"highClosed" -> operatorRegistry.type("Boolean")
880+
else -> null
881+
}
882+
}
883+
if (sourceType is org.hl7.cql.model.TupleType) {
884+
return sourceType.elements.firstOrNull { it.name == propertyName }?.type
885+
}
886+
return null
887+
}
843888
}

Src/java/cql-to-elm/src/jvmTest/kotlin/org/cqframework/cql/cql2elm/codegen/FullParityTest.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,11 @@ class FullParityTest {
214214
"Code→Concept conversion (ToConcept) and CodesToConcept error recovery",
215215
// Name hiding: QDM model interval resolution and out-of-scope error recovery
216216
"NameHiding" to "QDM interval resolution and out-of-scope error recovery",
217-
// AgeOperators: desugaring works (AgeIn*→CalculateAgeIn* with birthDate injection)
218-
// but TypeResolver can't resolve model property types (Patient.birthDatetime)
219-
// so operator resolution doesn't discover the DateTime→Date conversion.
217+
// AgeOperators: desugared before loop + model type resolution working, but
218+
// ConversionExpression (ToDate) in desugared AST not emitting — needs
219+
// investigation.
220220
"AgeOperators" to
221-
"TypeResolver needs model type resolution for Patient.birthDatetime",
221+
"ConversionExpression(ToDate) from desugaring not emitting correctly",
222222
// Terminology: legacy resolves retrieve code properties and function references
223223
"TerminologyReferences" to
224224
"Retrieve code properties and terminology function resolution",

0 commit comments

Comments
 (0)