diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index af782da..d990133 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -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()) } diff --git a/interpreter/operator_interval.go b/interpreter/operator_interval.go index e0461dc..5820f9a 100644 --- a/interpreter/operator_interval.go +++ b/interpreter/operator_interval.go @@ -16,6 +16,7 @@ package interpreter import ( "fmt" + "sort" "time" "github.com/google/cql/model" @@ -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>) List> +// collapse(argument List>, per Quantity) List> +// 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 +} diff --git a/model/model.go b/model/model.go index 74d2eaf..0924955 100644 --- a/model/model.go +++ b/model/model.go @@ -891,7 +891,7 @@ type Expand struct{ *BinaryExpression } // Has two signatures: // collapse(argument List>) List> // collapse(argument List>, per Quantity) List> -type Collapse struct{ *BinaryExpression } +type Collapse struct{ *NaryExpression } // CalculateAge CQL expression type type CalculateAge struct { diff --git a/parser/library_test.go b/parser/library_test.go index 1731efa..d0aab9f 100644 --- a/parser/library_test.go +++ b/parser/library_test.go @@ -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(` diff --git a/parser/operators.go b/parser/operators.go index 1f616c2..9ba84fb 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -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) @@ -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{ diff --git a/tests/enginetests/operator_interval_test.go b/tests/enginetests/operator_interval_test.go index 595a902..f20a76c 100644 --- a/tests/enginetests/operator_interval_test.go +++ b/tests/enginetests/operator_interval_test.go @@ -125,6 +125,625 @@ func TestEnd(t *testing.T) { } } +func TestCollapse(t *testing.T) { + tests := []struct { + name string + cql string + want result.Value + }{ + { + name: "Collapse overlapping Date intervals", + cql: `collapse {Interval[@2020-01-01, @2020-01-15], Interval[@2020-01-10, @2020-01-25]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, 1, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.Date{Date: time.Date(2020, 1, 25, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Date}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Date}}, + }), + }, + { + name: "Collapse adjacent Date intervals", + cql: `collapse {Interval[@2020-01-01, @2020-01-15], Interval[@2020-01-16, @2020-01-25]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, 1, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.Date{Date: time.Date(2020, 1, 25, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Date}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Date}}, + }), + }, + { + name: "Collapse null list", + cql: `collapse (null as List>)`, + want: newOrFatal(t, nil), + }, + { + name: "Collapse empty list", + cql: `collapse List>{}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{}, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Date}}, + }), + }, + { + name: "Collapse overlapping Integer intervals", + cql: `collapse {Interval[1, 5], Interval[3, 8]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(8)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse single interval", + cql: `collapse {Interval[1, 5]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(5)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse excludes null intervals", + cql: `collapse {Interval[1, 5], null, Interval[3, 8]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(8)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse overlapping Decimal intervals with small precision difference", + cql: `collapse {Interval[4.0, 6.0], Interval[6.00000001, 8.0]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 4.0), + High: newOrFatal(t, 8.0), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Decimal}}, + }), + }, + { + name: "Collapse adjacent Decimal intervals", + cql: `collapse {Interval[1.0, 3.0], Interval[3.0, 5.0]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 1.0), + High: newOrFatal(t, 5.0), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Decimal}}, + }), + }, + { + name: "Collapse non-overlapping Decimal intervals", + cql: `collapse {Interval[1.0, 2.0], Interval[4.0, 5.0]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 1.0), + High: newOrFatal(t, 2.0), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 4.0), + High: newOrFatal(t, 5.0), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Decimal}}, + }), + }, + { + name: "Collapse multiple overlapping Integer intervals", + cql: `collapse {Interval[1, 5], Interval[3, 7], Interval[12, 19], Interval[7, 10]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(10)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(12)), + High: newOrFatal(t, int32(19)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse multiple adjacent Integer intervals", + cql: `collapse {Interval[1, 2], Interval[3, 7], Interval[10, 19], Interval[7, 10]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(19)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse multiple Quantity intervals", + cql: `collapse {Interval[1'g', 5'g'], Interval[3'g', 7'g'], Interval[12'g', 19'g'], Interval[7'g', 10'g']}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Quantity{Value: 1, Unit: "g"}), + High: newOrFatal(t, result.Quantity{Value: 10, Unit: "g"}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Quantity}, + }), + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Quantity{Value: 12, Unit: "g"}), + High: newOrFatal(t, result.Quantity{Value: 19, Unit: "g"}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Quantity}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Quantity}}, + }), + }, + { + name: "Two overlapping intervals", + cql: `collapse {Interval[1, 5], Interval[3, 7]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(7)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Two adjacent intervals", + cql: `collapse {Interval[1, 5], Interval[6, 10]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(10)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Two touching intervals", + cql: `collapse {Interval[1, 5], Interval[5, 10]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(10)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Two non-overlapping intervals", + cql: `collapse {Interval[1, 5], Interval[8, 10]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(5)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(8)), + High: newOrFatal(t, int32(10)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Three intervals - step by step", + cql: `collapse {Interval[1, 5], Interval[3, 7], Interval[7, 10]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(10)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse keeps closed Integer intervals with a gap", + cql: `collapse {Interval[1, 4], Interval[6, 10]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(4)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(6)), + High: newOrFatal(t, int32(10)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse keeps open Integer intervals that do not meet", + cql: `collapse {Interval[1, 5), Interval(5, 10]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(5)), + LowInclusive: true, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Integer}, + }), + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(5)), + High: newOrFatal(t, int32(10)), + LowInclusive: false, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse merges open Integer intervals that meet", + cql: `collapse {Interval[1, 5), Interval[5, 10]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(10)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse keeps open boundaries of merged intervals", + cql: `collapse {Interval(1, 5), Interval(3, 8)}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(8)), + LowInclusive: false, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse keeps closed Date intervals with a gap", + cql: `collapse {Interval[@2020-01-01, @2020-01-15], Interval[@2020-01-17, @2020-01-25]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, 1, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.Date{Date: time.Date(2020, 1, 15, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Date}, + }), + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, 1, 17, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.Date{Date: time.Date(2020, 1, 25, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Date}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Date}}, + }), + }, + { + name: "Collapse keeps open Date intervals that do not meet", + cql: `collapse {Interval[@2020-01-01, @2020-01-15), Interval(@2020-01-15, @2020-01-25]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, 1, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.Date{Date: time.Date(2020, 1, 15, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Date}, + }), + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, 1, 15, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.Date{Date: time.Date(2020, 1, 25, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: false, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Date}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Date}}, + }), + }, + { + name: "Collapse keeps open Decimal intervals that do not meet", + cql: `collapse {Interval[1.0, 2.0), Interval(2.0, 3.0]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 1.0), + High: newOrFatal(t, 2.0), + LowInclusive: true, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 2.0), + High: newOrFatal(t, 3.0), + LowInclusive: false, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Decimal}}, + }), + }, + { + name: "Collapse closed null low contains the other interval", + cql: `collapse {Interval[null, 5], Interval[1, 2]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, nil), + High: newOrFatal(t, int32(5)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse open null low cannot prove an overlap", + cql: `collapse {Interval(null, 5], Interval[1, 2]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, nil), + High: newOrFatal(t, int32(5)), + LowInclusive: false, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(2)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse closed null low merges an overlapping interval", + cql: `collapse {Interval[null, 5], Interval[4, 6]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, nil), + High: newOrFatal(t, int32(6)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse open null low merges an interval that contains its end", + cql: `collapse {Interval(null, 5], Interval[4, 6]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, nil), + High: newOrFatal(t, int32(6)), + LowInclusive: false, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse open null high gives an unknown end", + cql: `collapse {Interval[1, 3], Interval[2, null)}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, nil), + LowInclusive: true, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Integer}}, + }), + }, + { + name: "Collapse Long intervals", + cql: `collapse {Interval[1L, 5L], Interval[6L, 10L], Interval[1, 2]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int64(1)), + High: newOrFatal(t, int64(10)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Long}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.Long}}, + }), + }, + { + name: "Collapse DateTimes of mixed precision", + cql: `collapse {Interval[@2020-01-01T10:30, @2020-01-01T10:35], Interval[@2020-01-01T10, @2020-01-01T10], Interval[@2020-01-01T10:31, @2020-01-01T10:40]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.DateTime{Date: time.Date(2020, 1, 1, 10, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.HOUR}), + High: newOrFatal(t, result.DateTime{Date: time.Date(2020, 1, 1, 10, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.HOUR}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.DateTime}, + }), + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.DateTime{Date: time.Date(2020, 1, 1, 10, 30, 0, 0, defaultEvalTimestamp.Location()), Precision: model.MINUTE}), + High: newOrFatal(t, result.DateTime{Date: time.Date(2020, 1, 1, 10, 40, 0, 0, defaultEvalTimestamp.Location()), Precision: model.MINUTE}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.DateTime}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.DateTime}}, + }), + }, + { + name: "Collapse DateTimes of mixed precision in another order", + cql: `collapse {Interval[@2020-01-01T10:30, @2020-01-01T10:35], Interval[@2020-01-01T10:31, @2020-01-01T10:40], Interval[@2020-01-01T10, @2020-01-01T10]}`, + want: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.DateTime{Date: time.Date(2020, 1, 1, 10, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.HOUR}), + High: newOrFatal(t, result.DateTime{Date: time.Date(2020, 1, 1, 10, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.HOUR}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.DateTime}, + }), + newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.DateTime{Date: time.Date(2020, 1, 1, 10, 30, 0, 0, defaultEvalTimestamp.Location()), Precision: model.MINUTE}), + High: newOrFatal(t, result.DateTime{Date: time.Date(2020, 1, 1, 10, 40, 0, 0, defaultEvalTimestamp.Location()), Precision: model.MINUTE}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.DateTime}, + }), + }, + StaticType: &types.List{ElementType: &types.Interval{PointType: types.DateTime}}, + }), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := newFHIRParser(t) + parsedLibs, err := p.Libraries(context.Background(), wrapInLib(t, tc.cql), parser.Config{}) + if err != nil { + t.Fatalf("Parse returned unexpected error: %v", err) + } + results, err := interpreter.Eval(context.Background(), parsedLibs, defaultInterpreterConfig(t, p)) + if err != nil { + t.Fatalf("Eval returned unexpected error: %v", err) + } + if diff := cmp.Diff(tc.want, getTESTRESULT(t, results), protocmp.Transform()); diff != "" { + t.Errorf("Evaluate diff (-want +got):\n%s", diff) + } + }) + } +} + func TestStart(t *testing.T) { tests := []struct { name string diff --git a/tests/spectests/exclusions/exclusions.go b/tests/spectests/exclusions/exclusions.go index 50d255e..e9c95ac 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -222,7 +222,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { // TODO: b/342061715 - unsupported operators. "After", "Before", - "Collapse", "Expand", "Ends", "Except", @@ -309,6 +308,7 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "TestOnOrBeforeDecimalFalse", "TestOnOrBeforeQuantityTrue", // TODO: b/342064453 - Ambiguous match. + "TestCollapseNull", "TestEqualNull", "TestInNullBoundaries", },