Skip to content
Evan Machusak edited this page Nov 1, 2024 · 5 revisions

Models in CQL

Rationale

Models are currenly defined as XML. This has three consequences:

  • Library authors cannot define their own named types unless they create a model;
  • Tooling authors need XML capability in addition to ANTLR capability;
  • Writing new models is challenging.

If types, conversions, contexts, and other built-in system features could be expressed natively in CQL, we could eliminate all three of these burdens.

Proposal

Define canonical URI as part of the declaration

The current library declaration in CQL has this grammar:

libraryDefinition
    : 'library' qualifiedIdentifier ('version' versionSpecifier)?
    ;

These declarations look like this in CQL:

library FHIRHelpers version '4.0.1'

Models have a URL as part of their declaration:

<ns4:modelInfo name="System" version="1.0.0" url="urn:hl7-org:elm-types:r1" targetQualifier="system"
               xmlns:ns4="urn:hl7-org:elm-modelinfo:r1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

This URL is the canonical identifier for the model. Currently, the model's URL is encoded in named type specifiers in generated ELM, e.g.:

{
  "type" : "NamedTypeSpecifier",
  "name" : "{http://hl7.org/fhir}Observation"
}

To remain backward compatible with existing ELM tooling, libraries that are taking the place of models in the tooling need to be able to express their canonical URL so that this information can be added to type specifiers.

The proposed syntax is:

library System : 'urn:hl7-org:elm-types:r1' version '1.0.0'

This syntax is similar to value set declarations with versions, e.g.:

valueset "Advanced Illness": 'https://www.ncqa.org/fhir/ValueSet/2.16.840.1.113883.3.464.1004.1465' version '2024.0.0'

The new grammar becomes:

libraryDefinition
    : 'library' qualifiedIdentifier (':' libraryUrl)? ('version' versionSpecifier)?
    ;
libraryUrl
    : STRING
    ;

The ELM VersionedIdentifier node that captures this information currently would look like this:

<identifier id="{urn:hl7-org:elm-types:r1}System" version="1.0.0"/>

Alternatively, it could be:

<identifier id="System" system="urn:hl7-org:elm-types:r1" version="1.0.0"/>

Or, the schema could be updated to add a url property to capture the library's canonical URL. This would require updating the ELM schema to a new version.

Simple type declarations

The major purpose of a model today is to declare the types available to CQL language. Like all languages, some types must be assumed to exist and be provided by the tooling. In models today, these are declared as simple types, like this:

<ns4:typeInfo xsi:type="ns4:SimpleTypeInfo" name="System.Any"/>
<ns4:typeInfo xsi:type="ns4:SimpleTypeInfo" name="System.Boolean" baseType="System.Any"/>

A simple type is one that cannot be described in terms except as of itself. CQL runtime execution environments are required to be able to represent these simple types.

The grammar for defining these will be:

simpleTypeDefinition:
	'define' accessModifier? 'simple' 'type' qualifiedIdentifier baseTypeSpecifier?;

baseTypeSpecifier:
	'extends' (namedTypeSpecifier | genericTypeSpecifier)

Like all definitions, simple types can be public or private. Model libraries should make these public. These will look like this in CQL:

define public simple type Any

define public simple type Boolean extends System.Any

Simple types should be used only in the standard CQL System model, since they create burden for tooling authors who must be able to handle these.

Instead of the simple keyword, we may want to consider either primitive or external instead. simple is chosen here to align with existing model nomenclature, but it could lead to authors thinking that types without elements are simple, which is not the case.

The extends clause names the type's base type. Types in CQL can only have one base type.

Class type declarations

Class types are defined without the simple modifier, e.g.:

define public type Quantity extends System.Any {
  value System.Decimal,
  unit System.String
}

The grammar for this construct is:

classTypeDefinition:
  'define' accessModifier? 'type' qualifiedIdentifier baseTypeSpecifier? typeElements?;
typeElements:
  '{' ((typeElementDefinition ',')* typeElementDefinition )? '}';

typeElementDefinition:
  identifier (typeSpecifier | elementFunction)

elementFunction:
   'function(' operandDefinition? ')' 'returns' typeSpecifier ':' (functionBody | 'external')

Element definitions consist of the element identifier followed by either a type specifier or a function.

The type specifier form works like existing tuple type definitions do.

The function form supports model mapping which currently uses the target attribute, for example in parts of QICore:

<element name="period" target="FHIRHelpers.ToInterval(%value)" description="Time period when address was/is in use" definition="Time period when address was/is in use.">
  <elementTypeSpecifier xsi:type="IntervalTypeSpecifier">
    <pointTypeSpecifier namespace="System" name="DateTime" xsi:type="NamedTypeSpecifier"/>
      </elementTypeSpecifier>
    </element>
</element>

This can be expressed using the function syntax like this:

define type Address extends QICore.Element {
  period function(period QICore.Period) returns System.Interval<System.DateTime>: FHIRHelpers.ToInterval(period)
}

For functions that take a single operand, the type of the operand is the pre-transformed element type. The purpose of this example, taken from the QICore.Address type, is to transform the period element from being its native FHIR.Period type into a System.Interval<System.DateTime>. Implementations should replace Property accesses to the period element of QICore.Address with the body of the function, binding the operand to the original Property node.

Note that derived types can define functional elements that exist in their base type hierarchy, for example:

define type Immunization extends FHIR.Immunization {
   vaccineCode function(code FHIR.CodeableConcept) returns List<System.Code>: if code is null and reference is not null then "Code from reference"(reference)
}

Note that the entire instance is automatically in scope for these functions, allowing element functions to refer to other elements. This requires that the same cycle detection requirements that exist in library scope applies to instance scope also, e.g.:

define type "Has recursion" {
  x function() returns System.Integer: y
  y function() returns System.Integer: x // error; cycle detected.
}

Type elements can also be defined without operands, creating computed properties, for example:

define type "Measurement Period" extends Interval<System.Date> {
  "Duration in days" function() returns System.Integer: duration in days between low and high
}

Element functions always require a returns clause, so that it is easy for readers and tooling to see what the type of the element will be quickly and easily, without having to read the entire body of the function.

When a class type extends a base type, it inherits all of the elements from its base types. If a type defines an element which the base type already defines, the derived type is not allowed to redefine the element.

define type Base { x System.Integer }
define type Derived extends Base { x System.String } // error; element x is already declared in base type Base

Generic type declarations

Generic types can be declared, e.g.:

define generic type Interval<T> extends System.Any
	where T: Choice<System.Integer,System.Long,System.Decimal,System.Quantity,System.DateTime,System.Date,System.Time>
{
  lowClosed System.Boolean,
  low T,
  highClosed System.Boolean,
  high T
}

This type declaration is a class type declaration with two additional components.

First, the type's type parameters are expressed using <T>. Implementations are allowed to limit the number of generic type parameters they support; existing implementations likely only support single type parameters.

Second, they describe type constraints which are stipulated with the where clause. Type constraints are expressed as type specifiers. A type argument T' can be assigned to a type parameter T with constraint C if T' can be implicitly converted to C.

Specifically, a type T is implicitly convertible to type C according to the rules of the Developer's Guide, section 4.9. The type coercion from T to C must be convertible with a precedence of 6 (Implicit Conversion to Class Type) or lower.

In the above example, this constraint indicates that Interval can only be created for T that is one of the types named in the Choice<> type constraint: Integer, Long, Decimal, Quantity, DateTime, Date, and Time. This is already enforced by existing tooling, but it is done at the tooling level because Interval and List types are not declared in the System model for ELM R1.

The grammar rule for this type definition would be:

genericTypeDefinition:
	'define' accessModifier? 'generic' 'type' (qualifiedIdentifier) '<' (
		typeParameter (',' typeParameter)*
	) '>' baseTypeSpecifier? (
		typeParameterConstraint (',' typeParameterConstraint)*
	) typeElements?;

Within generic types, generic type parameters can be used as type specifiers, including in functions, e.g.:

define generic type Foo<T> extends System.Any {
  member function(t T) returns T: DoSomething(t)
}

In order for this model to compile, DoSomething must have an overload with a parameter of type T in order for a Foo of that T to be referenced. For example:

define function DoSomething(i System.Integer): i+1

define "Foo Int": Foo<System.Integer> // allowed

define "Foo Long": Foo<System.Long> // error; no overload of DoSomething of type Sytem.Long is defined

From a tooling perspective, instantiating a generic type requires assessing the element declarations that use functions by replacing the type parameters with the type arguments and evaluating whether the function body would produce any errors. If errors would be produced, that generic type specifier is not allowed in any context.

Generic types necessitate a new GenericTypeSpecifier ELM type specifier type, which will replace IntervalTypeSpecifier and ListTypeSpecifier. For backward compatibility with 1.5 engines, generic types other than System.List and System.Interval would be disallowed.

Context definitions

Models can define contexts. This is the XML representation in the FHIR model:

<contextInfo name="Patient" keyElement="id" birthDateElement="birthDate.value">
  <contextType namespace="FHIR" name="Patient"/>
</contextInfo>

This can expressed in CQL like this:

define context Patient of type FHIR.Patient with key id

The grammar for this declaration is:

contextDefinition:
  'define' 'context' identifier 'of' 'type' namedTypeSpecifier 'with' 'key' identifier

It is not legal to declare more than one context with the same type. This is determined when model libraries are included. If, across all included libraries, more than one context definition of the same type is defined, an error on the include statement is raised.

Note that if a context is defined in library C, it is only defined for a library L if L has an include C statement in it. A library M which declares include L but does not declare include C will not be able to use the contexts in C.

In XML, contexts have a birthDateElement as well.

These are handled with a new construct called a context function:

define public context function AgeInYears(patient FHIR.Patient):
  if patient is null or patient.birthDate is null then null
  else
    return difference in years between patient.birthDate and Today()

Context functions specify exactly one operand, whose type is the type of a declared context. This function definition produces an error if no context whose type is FHIR.Patient is in scope, either within its own library or the libraries it directly includes.

This defines the calling convention like this:

define context Patient of type FHIR.Patient with key id

define public context function AgeInYears(patient FHIR.Patient):
  if patient is null or patient.birthDate is null then null
  else
    return difference in years between patient.birthDate and Today()

context Patient

define f: AgeInYears() // the Patient context is automatically used as the operand to AgeInYears

The grammar for these functions is:

contextFunction:
  'define' accessModifier? 'context' 'function' '(' (
		operandDefinition (',' operandDefinition)*
	)? ')' ('returns' typeSpecifier)? ':' (
		functionBody
		| external
	);

Operator definitions

Currently, the CQL specification defines a large number of operators. These operators are assumed in tooling, but should be explicitly declared in the System model. This will formalize the signatures in the CQL specification as actual functions, which will simplify tooling significantly because operators become the same as user-defined functions with special names. For example:

define operator Add(left System.Integer, right System.Integer) returns System.Integer: external
define operator +(left System.Integer, right System.Integer): Add(left,right)

This would formally declare the System.Integer overload of the Add operator. We would use the operator keyword here to take advantage of the globalness of functions. If we declared Add using the function keyword, it would need to be called with a library prefix since it is not fluent, and that would not be backward compatible with existing usage of functional operators like Add in code. The semantics of operators over functions is that their signatures are added to the scope of libraries that include libraries containing them, e.g.:

library Foo

include System version '2.0.0' // adds all the operators defined in System into Foo's scope so they can be called without the System qualifier

Operators would be FunctionDef elements with special names, e.g.:

<def localId="6" locator="3:1-3:76" resultTypeName="t:Integer" name="Add" context="Patient" accessLevel="Public" xsi:type="FunctionDef">
<def localId="6" locator="3:1-3:76" resultTypeName="t:Integer" name="operator+" context="Patient" accessLevel="Public" xsi:type="FunctionDef">

This also allows for native operators on model types, for example:

define operator+(left FHIR.integer, right FHIR.integer): Add(left.value, right.value)

The grammar for operators would be:

operatorDefinition:
	'define' accessModifier? 'operator' operator '(' (
		operandDefinition (',' operandDefinition)*
	)? ')' ('returns' typeSpecifier)? ':' (
		functionBody
		| external
	);

operator:
	| '+' 
	| '-' 
	| '*' 
	| '/' 
	| '%' 
	| '=' 
	| '!='
	;

The list of operators provided here is not complete. We will need to work together to come up with an exhaustive list.

The n-aryness of an operator is based on how many parameters are supplied, for example:

define operator -(left System.Integer, right System.Integer) returns System.Integer: external // subtraction
define operator -(argument System.Integer) returns System.Integer: external // negation

Functions in the specification that provide an optional precision operator are expressed as System.String, for example:

define operator difference(low System.Date, high System.Date, precision String) returns System.Integer: external

One consequence of defining operators in models is ELM models of CQL libraries can use fewer ELM node types. For example:

define f: 1 + 2

Could be expressed either with an Add node or a FunctionRef to the System.operator+ function.

Conversion operators

Models can define conversion functions between types. This lets us express these XML elements found in models:

<conversionInfo functionName="FHIRHelpers.ToInterval" fromType="FHIR.Period" toType="Interval&lt;System.DateTime>"/>

In CQL, this could be expressed this way:

define implicit conversion from period FHIR.Period to System.Interval<Sytem.DateTime>:
    if period is null then
        null
    else
        if period."start" is null then
            Interval(period."start".value, period."end".value]
        else
            Interval[period."start".value, period."end".value]

The body of this function is the body found in the FHIRHelpers library. Since the FHIR types and their conversions will be expressed as libraries, it will no longer be necessary to define that libraries are both using FHIR version '4.0.1' and also that they include FHIRHelpers version '4.0.1'. Going forward, this will be accomplished with a single definition of include FHIR version '4.0.1'.

Conversions can be implicit or explicit. Explicit conversions require the use of the convert keyword.

Conversions defined this way will allow type coercion during overload resolution to occur, e.g.:

define type Foo { x System.Integer }
define type Bar { x System.Interval<System.Integer> }

define function "Takes bar"(b Bar): b
define f: "Takes bar"(Foo { x: 1 }) // error

Now adding:

define public implicit conversion from foo Foo to Bar: Bar { x: foo.x }

define f: "Takes bar"(Foo { x: 1 }) // no error

The grammar for these conversions is this:

conversionDefinition:
	'define' accessModifier? (implicit | explicit) 'conversion' 'from' operandDefinition 'to' typeSpecifier ':'
		(functionBody | external);

implicit: 'implicit';
explicit: 'explicit';

Example use case

Here is a snippet of what could become a full model.

library SystemWithUncertainty : 'urn:hl7-org:elm-uncertainty:r2' version '2.0.0'


define generic type Uncertainty<T> extends Interval<T> where T: Choice<Integer,Long,Date,DateTime,Time> {}
define operator difference(low Uncertainty<Date>, high Uncertainty<Date>, precision String) returns Uncertainty<Date>: external
define implicit conversion from uncertainty Uncertainty<Date> to Date:
  if (uncertainty.low == uncertainty.high) return uncertainty.low;
  else return null;
define implicit conversion from date Date to Uncertainty<Date>: Interval[date,date] as Uncertainty<Date>
define operator >(left Uncertainty<Date>, right Uncertainty<Date>) returns System.Boolean: external
library Uses_Uncertainty

include SystemWithUncertainty version '2.0.0'

define "returns uncertainty": difference between @2005 and @2010

The idea behind this example is that the CQL language itself can become separated from some specific usage patterns. If CQL tooling supports the constructs defined here, including custom type declarations, operators, and conversions, the entire uncertainty feature can be implemented as a library rather than as baked into the specification. This allows libraries to "opt-in" to certain behaviors by including the SystemWithUncertainty library instead of the System library.