Skip to content
Open
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
59 changes: 59 additions & 0 deletions interpreter/operator_dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,65 @@ func (i *interpreter) naryOverloads(m model.INaryExpression) ([]convert.Overload
Result: evalSubstring,
},
}, nil
case *model.Collapse:
return []convert.Overload[evalNarySignature]{
{
Operands: []types.IType{&types.List{ElementType: &types.Interval{PointType: types.Date}}},
Result: i.evalCollapseDateTime,
},
{
Operands: []types.IType{&types.List{ElementType: &types.Interval{PointType: types.Date}}, types.Quantity},
Result: i.evalCollapseDateTime,
},
{
Operands: []types.IType{&types.List{ElementType: &types.Interval{PointType: types.DateTime}}},
Result: i.evalCollapseDateTime,
},
{
Operands: []types.IType{&types.List{ElementType: &types.Interval{PointType: types.DateTime}}, types.Quantity},
Result: i.evalCollapseDateTime,
},
{
Operands: []types.IType{&types.List{ElementType: &types.Interval{PointType: types.Time}}},
Result: i.evalCollapseDateTime,
},
{
Operands: []types.IType{&types.List{ElementType: &types.Interval{PointType: types.Time}}, types.Quantity},
Result: i.evalCollapseDateTime,
},
{
Operands: []types.IType{&types.List{ElementType: &types.Interval{PointType: types.Integer}}},
Result: i.evalCollapseInteger,
},
{
Operands: []types.IType{&types.List{ElementType: &types.Interval{PointType: types.Integer}}, types.Quantity},
Result: i.evalCollapseInteger,
},
{
Operands: []types.IType{&types.List{ElementType: &types.Interval{PointType: types.Long}}},
Result: i.evalCollapseLong,
},
{
Operands: []types.IType{&types.List{ElementType: &types.Interval{PointType: types.Long}}, types.Quantity},
Result: i.evalCollapseLong,
},
{
Operands: []types.IType{&types.List{ElementType: &types.Interval{PointType: types.Decimal}}},
Result: i.evalCollapseDecimal,
},
{
Operands: []types.IType{&types.List{ElementType: &types.Interval{PointType: types.Decimal}}, types.Quantity},
Result: i.evalCollapseDecimal,
},
{
Operands: []types.IType{&types.List{ElementType: &types.Interval{PointType: types.Quantity}}},
Result: i.evalCollapseQuantity,
},
{
Operands: []types.IType{&types.List{ElementType: &types.Interval{PointType: types.Quantity}}, types.Quantity},
Result: i.evalCollapseQuantity,
},
}, nil
default:
return nil, fmt.Errorf("unsupported Nary Expression %v", m.GetName())
}
Expand Down
246 changes: 246 additions & 0 deletions interpreter/operator_interval.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package interpreter

import (
"fmt"
"sort"
"time"

"github.com/google/cql/model"
Expand Down Expand Up @@ -641,3 +642,248 @@ func evalWidthInterval(m model.IUnaryExpression, intervalObj result.Value) (resu
}
return result.Value{}, fmt.Errorf("internal error - unsupported point type in evalWidthInterval: %v", start.RuntimeType())
}

// collapse(argument List<Interval<T>>) List<Interval<T>>
// collapse(argument List<Interval<T>>, per Quantity) List<Interval<T>>
// https://cql.hl7.org/09-b-cqlreference.html#collapse
// TODO: Support the per argument. Until then intervals collapse at the precision of their points.
func (i *interpreter) evalCollapseDateTime(m model.INaryExpression, operands []result.Value) (result.Value, error) {
return collapse(operands, &i.evaluationTimestamp, compareDateTimeValues)
}

func (i *interpreter) evalCollapseInteger(m model.INaryExpression, operands []result.Value) (result.Value, error) {
return collapse(operands, &i.evaluationTimestamp, compareNumeralValues(result.ToInt32))
}

func (i *interpreter) evalCollapseLong(m model.INaryExpression, operands []result.Value) (result.Value, error) {
return collapse(operands, &i.evaluationTimestamp, compareNumeralValues(result.ToInt64))
}

func (i *interpreter) evalCollapseDecimal(m model.INaryExpression, operands []result.Value) (result.Value, error) {
return collapse(operands, &i.evaluationTimestamp, compareNumeralValues(result.ToFloat64))
}

func (i *interpreter) evalCollapseQuantity(m model.INaryExpression, operands []result.Value) (result.Value, error) {
return collapse(operands, &i.evaluationTimestamp, compareQuantityValues)
}

// pointComparator compares two non-null interval points.
type pointComparator func(left, right result.Value) (comparison, error)

func compareNumeralValues[t float64 | int64 | int32](toNumeral func(result.Value) (t, error)) pointComparator {
return func(left, right result.Value) (comparison, error) {
l, r, err := applyToValues(left, right, toNumeral)
if err != nil {
return unsetComparison, err
}
return compareNumeral(l, r), nil
}
}

func compareDateTimeValues(left, right result.Value) (comparison, error) {
l, r, err := applyToValues(left, right, result.ToDateTime)
if err != nil {
return unsetComparison, err
}
return compareDateTime(l, r)
}

func compareQuantityValues(left, right result.Value) (comparison, error) {
l, r, err := applyToValues(left, right, result.ToQuantity)
if err != nil {
return unsetComparison, err
}
rightValue := r.Value
if l.Unit != r.Unit {
rightValue, err = ucum.ConvertUnit(r.Value, string(r.Unit), string(l.Unit))
if err != nil {
return unsetComparison, err
}
}
return compareNumeral(l.Value, rightValue), nil
}

// collapseInterval is an interval with the start and end computed by start() and end(). A null
// start or end is an open null boundary, which is unknown.
type collapseInterval struct {
interval result.Interval
start, end result.Value
}

func collapse(operands []result.Value, evaluationTimestamp *time.Time, compare pointComparator) (result.Value, error) {
if len(operands) == 0 {
return result.Value{}, fmt.Errorf("internal error - collapse must have at least one operand")
}
listObj := operands[0]
if result.IsNull(listObj) {
return result.New(nil)
}
listType, ok := listObj.RuntimeType().(*types.List)
if !ok {
return result.Value{}, fmt.Errorf("internal error - collapse got a non-list operand %v", listObj.RuntimeType())
}
values, err := result.ToSlice(listObj)
if err != nil {
return result.Value{}, err
}

var intervals []collapseInterval
for _, v := range values {
if result.IsNull(v) {
continue
}
interval, err := result.ToInterval(v)
if err != nil {
return result.Value{}, err
}
s, e, err := startAndEnd(v, evaluationTimestamp)
if err != nil {
return result.Value{}, err
}
intervals = append(intervals, collapseInterval{interval: interval, start: s, end: e})
}
if len(intervals) <= 1 {
return newIntervalList(intervals, listType)
}

if err := sortIntervalsByStartTime(intervals, compare); err != nil {
return result.Value{}, err
}
collapsed := []collapseInterval{intervals[0]}
for _, next := range intervals[1:] {
current := &collapsed[len(collapsed)-1]
merge, err := canMerge(*current, next, compare, evaluationTimestamp)
if err != nil {
return result.Value{}, err
}
if !merge {
collapsed = append(collapsed, next)
continue
}
later, err := laterEnd(*current, next, compare)
if err != nil {
return result.Value{}, err
}
current.interval.High = later.interval.High
current.interval.HighInclusive = later.interval.HighInclusive
current.end = later.end
}
return newIntervalList(collapsed, listType)
}

func newIntervalList(intervals []collapseInterval, listType *types.List) (result.Value, error) {
values := make([]result.Value, 0, len(intervals))
for _, ci := range intervals {
v, err := result.New(ci.interval)
if err != nil {
return result.Value{}, err
}
values = append(values, v)
}
return result.New(result.List{Value: values, StaticType: listType})
}

// sortIntervalsByStartTime sorts intervals by start, unknown starts first, then by end, unknown
// ends last. The order is total, so the result does not depend on the input order.
func sortIntervalsByStartTime(intervals []collapseInterval, compare pointComparator) error {
var sortErr error
sort.SliceStable(intervals, func(a, b int) bool {
c, err := orderPoints(intervals[a].start, intervals[b].start, true, compare)
if err == nil && c == leftEqualRight {
c, err = orderPoints(intervals[a].end, intervals[b].end, false, compare)
}
if err != nil && sortErr == nil {
sortErr = err
}
return c == leftBeforeRight
})
return sortErr
}

// orderPoints compares two points for sorting. A DateTime that is equal to a finer DateTime up to
// its own precision sorts first, since compare cannot order the two.
func orderPoints(left, right result.Value, nullsFirst bool, compare pointComparator) (comparison, error) {
leftNull, rightNull := result.IsNull(left), result.IsNull(right)
if leftNull || rightNull {
switch {
case leftNull == rightNull:
return leftEqualRight, nil
case leftNull == nullsFirst:
return leftBeforeRight, nil
default:
return leftAfterRight, nil
}
}
c, err := compare(left, right)
if err != nil || c != insufficientPrecision {
return c, err
}
l, r, err := applyToValues(left, right, result.ToDateTime)
if err != nil {
return unsetComparison, err
}
// precisionGreaterOrEqual is true when l is the coarser precision.
if precisionGreaterOrEqual(l.Precision, r.Precision) {
return leftBeforeRight, nil
}
return leftAfterRight, nil
}

// canMerge returns true if current and next provably overlap or meet. current must not start
// after next. An unknown boundary is bounded by the other end of its own interval, so
// Interval(null, 5] merges with Interval[4, 6] but not with Interval[1, 2].
func canMerge(current, next collapseInterval, compare pointComparator, evaluationTimestamp *time.Time) (bool, error) {
startsBeforeNextEnds, err := provablyLessOrEqual(knownOr(current.start, current.end), knownOr(next.end, next.start), compare)
if err != nil || !startsBeforeNextEnds {
return false, err
}
overlaps, err := provablyLessOrEqual(knownOr(next.start, next.end), knownOr(current.end, current.start), compare)
if err != nil || overlaps {
return overlaps, err
}
if result.IsNull(current.end) || result.IsNull(next.start) {
return false, nil
}
afterEnd, err := successor(current.end, evaluationTimestamp)
if err != nil {
return false, err
}
return provablyLessOrEqual(next.start, afterEnd, compare)
}

// laterEnd returns whichever interval ends later. An unknown end counts as later, since the merged
// end is then unknown too.
func laterEnd(current, next collapseInterval, compare pointComparator) (collapseInterval, error) {
if result.IsNull(current.end) {
return current, nil
}
if result.IsNull(next.end) {
return next, nil
}
c, err := compare(next.end, current.end)
if err != nil {
return collapseInterval{}, err
}
if c == leftAfterRight {
return next, nil
}
return current, nil
}

func provablyLessOrEqual(left, right result.Value, compare pointComparator) (bool, error) {
if result.IsNull(left) || result.IsNull(right) {
return false, nil
}
c, err := compare(left, right)
if err != nil {
return false, err
}
return c == leftBeforeRight || c == leftEqualRight, nil
}

func knownOr(v, fallback result.Value) result.Value {
if result.IsNull(v) {
return fallback
}
return v
}
2 changes: 1 addition & 1 deletion model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -891,7 +891,7 @@ type Expand struct{ *BinaryExpression }
// Has two signatures:
// collapse(argument List<Interval<T>>) List<Interval<T>>
// collapse(argument List<Interval<T>>, per Quantity) List<Interval<T>>
type Collapse struct{ *BinaryExpression }
type Collapse struct{ *NaryExpression }

// CalculateAge CQL expression type
type CalculateAge struct {
Expand Down
9 changes: 9 additions & 0 deletions parser/library_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,15 @@ func TestMalformedCQLSingleLibrary(t *testing.T) {
errContains: []string{" could not resolve Expand(System.Integer)"},
errCount: 1,
},
{
name: "Invalid Collapse Expression",
cql: dedent.Dedent(`
using FHIR version '4.0.1'
define "Param": collapse 4
`),
errContains: []string{" could not resolve Collapse(System.Integer)"},
errCount: 1,
},
{
name: "Using Declaration with Local Identifier",
cql: dedent.Dedent(`
Expand Down
27 changes: 27 additions & 0 deletions parser/operators.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ func (v *visitor) resolveFunction(libraryName, funcName string, operands []model
return nil, err
}
t.Expression = model.ResultType(&types.List{ElementType: listElemType})
case *model.Collapse:
// For collapse, the result type is the same as the input type (list of intervals)
t.Expression = model.ResultType(resolved.WrappedOperands[0].GetResultType())
case *model.Avg:
listType := resolved.WrappedOperands[0].GetResultType().(*types.List)
t.Expression = model.ResultType(listType.ElementType)
Expand Down Expand Up @@ -1123,6 +1126,30 @@ func (p *Parser) loadSystemOperators() error {
}
},
},
{
name: "Collapse",
operands: [][]types.IType{
{&types.List{ElementType: &types.Interval{PointType: types.Date}}},
{&types.List{ElementType: &types.Interval{PointType: types.Date}}, types.Quantity},
{&types.List{ElementType: &types.Interval{PointType: types.DateTime}}},
{&types.List{ElementType: &types.Interval{PointType: types.DateTime}}, types.Quantity},
{&types.List{ElementType: &types.Interval{PointType: types.Time}}},
{&types.List{ElementType: &types.Interval{PointType: types.Time}}, types.Quantity},
{&types.List{ElementType: &types.Interval{PointType: types.Integer}}},
{&types.List{ElementType: &types.Interval{PointType: types.Integer}}, types.Quantity},
{&types.List{ElementType: &types.Interval{PointType: types.Long}}},
{&types.List{ElementType: &types.Interval{PointType: types.Long}}, types.Quantity},
{&types.List{ElementType: &types.Interval{PointType: types.Decimal}}},
{&types.List{ElementType: &types.Interval{PointType: types.Decimal}}, types.Quantity},
{&types.List{ElementType: &types.Interval{PointType: types.Quantity}}},
{&types.List{ElementType: &types.Interval{PointType: types.Quantity}}, types.Quantity},
},
model: func() model.IExpression {
return &model.Collapse{
NaryExpression: &model.NaryExpression{},
}
},
},
{
name: "Indexer",
operands: [][]types.IType{
Expand Down
Loading
Loading