diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index af782da..612a59a 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -1217,6 +1217,35 @@ func (i *interpreter) binaryOverloads(m model.IBinaryExpression) ([]convert.Over Operands: []types.IType{&types.List{ElementType: types.Any}, &types.List{ElementType: types.Any}}, Result: evalExcept, }, + // Interval overloads + { + Operands: []types.IType{&types.Interval{PointType: types.Integer}, &types.Interval{PointType: types.Integer}}, + Result: i.evalExceptInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Long}, &types.Interval{PointType: types.Long}}, + Result: i.evalExceptInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Decimal}, &types.Interval{PointType: types.Decimal}}, + Result: i.evalExceptInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Date}, &types.Interval{PointType: types.Date}}, + Result: i.evalExceptInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.DateTime}, &types.Interval{PointType: types.DateTime}}, + Result: i.evalExceptInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Time}, &types.Interval{PointType: types.Time}}, + Result: i.evalExceptInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Quantity}, &types.Interval{PointType: types.Quantity}}, + Result: i.evalExceptInterval, + }, }, nil case *model.Intersect: return []convert.Overload[evalBinarySignature]{ diff --git a/interpreter/operator_interval.go b/interpreter/operator_interval.go index e0461dc..5f3390c 100644 --- a/interpreter/operator_interval.go +++ b/interpreter/operator_interval.go @@ -641,3 +641,141 @@ func evalWidthInterval(m model.IUnaryExpression, intervalObj result.Value) (resu } return result.Value{}, fmt.Errorf("internal error - unsupported point type in evalWidthInterval: %v", start.RuntimeType()) } + +// except(left Interval, right Interval) Interval +// https://cql.hl7.org/09-b-cqlreference.html#except-1 +// Returns null if the result is empty, if right is properly contained within left, or if the +// bounds cannot be compared (uncertain DateTime precision, incomparable Quantity units). +func (i *interpreter) evalExceptInterval(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + if result.IsNull(lObj) || result.IsNull(rObj) { + return result.New(nil) + } + left, err := result.ToInterval(lObj) + if err != nil { + return result.Value{}, err + } + ls, le, err := startAndEnd(lObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + rs, re, err := startAndEnd(rObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + if result.IsNull(ls) || result.IsNull(le) || result.IsNull(rs) || result.IsNull(re) { + return result.New(nil) + } + + rsToLe, err := compareIntervalPoints(rs, le) + if err != nil { + return result.Value{}, err + } + reToLs, err := compareIntervalPoints(re, ls) + if err != nil { + return result.Value{}, err + } + lsToRs, err := compareIntervalPoints(ls, rs) + if err != nil { + return result.Value{}, err + } + leToRe, err := compareIntervalPoints(le, re) + if err != nil { + return result.Value{}, err + } + for _, c := range []comparison{rsToLe, reToLs, lsToRs, leToRe} { + if c != leftBeforeRight && c != leftEqualRight && c != leftAfterRight { + return result.New(nil) + } + } + + if rsToLe == leftAfterRight || reToLs == leftBeforeRight { + return lObj, nil + } + if lsToRs == leftBeforeRight && leToRe != leftAfterRight { + rs, err := toUnitOf(rs, le) + if err != nil { + return result.Value{}, err + } + high, err := predecessor(rs, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + return result.New(result.Interval{Low: left.Low, High: high, LowInclusive: left.LowInclusive, HighInclusive: true, StaticType: left.StaticType}) + } + if leToRe == leftAfterRight && lsToRs != leftBeforeRight { + re, err := toUnitOf(re, ls) + if err != nil { + return result.Value{}, err + } + low, err := successor(re, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + return result.New(result.Interval{Low: low, High: left.High, LowInclusive: true, HighInclusive: left.HighInclusive, StaticType: left.StaticType}) + } + return result.New(nil) +} + +// compareIntervalPoints compares two non-null interval points of the same type. Quantities with +// different units are compared after unit conversion; unconvertible units return unsetComparison. +func compareIntervalPoints(a, b result.Value) (comparison, error) { + switch a.RuntimeType() { + case types.Integer: + l, r, err := applyToValues(a, b, result.ToInt32) + if err != nil { + return unsetComparison, err + } + return compareNumeral(l, r), nil + case types.Long: + l, r, err := applyToValues(a, b, result.ToInt64) + if err != nil { + return unsetComparison, err + } + return compareNumeral(l, r), nil + case types.Decimal: + l, r, err := applyToValues(a, b, result.ToFloat64) + if err != nil { + return unsetComparison, err + } + return compareNumeral(l, r), nil + case types.Quantity: + l, r, err := applyToValues(a, b, result.ToQuantity) + if err != nil { + return unsetComparison, err + } + lv := l.Value + if l.Unit != r.Unit { + if lv, err = ucum.ConvertUnit(l.Value, string(l.Unit), string(r.Unit)); err != nil { + return unsetComparison, nil + } + } + return compareNumeral(lv, r.Value), nil + case types.Date, types.DateTime, types.Time: + l, r, err := applyToValues(a, b, result.ToDateTime) + if err != nil { + return unsetComparison, err + } + return compareDateTime(l, r) + default: + return unsetComparison, fmt.Errorf("internal error - unsupported point type in except: %v", a.RuntimeType()) + } +} + +// toUnitOf converts a Quantity v to the unit of Quantity target. Other types are returned as is. +func toUnitOf(v, target result.Value) (result.Value, error) { + if v.RuntimeType() != types.Quantity { + return v, nil + } + q, t, err := applyToValues(v, target, result.ToQuantity) + if err != nil { + return result.Value{}, err + } + if q.Unit == t.Unit { + return v, nil + } + converted, err := ucum.ConvertUnit(q.Value, string(q.Unit), string(t.Unit)) + if err != nil { + return result.Value{}, err + } + return result.New(result.Quantity{Value: converted, Unit: t.Unit}) +} diff --git a/parser/operators.go b/parser/operators.go index 1f616c2..491aec8 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -1766,8 +1766,18 @@ func (p *Parser) loadSystemOperators() error { }, // LIST OPERATORS - https://cql.hl7.org/09-b-cqlreference.html#list-operators-2 { - name: "Except", - operands: [][]types.IType{{&types.List{ElementType: types.Any}, &types.List{ElementType: types.Any}}}, + name: "Except", + operands: [][]types.IType{ + {&types.List{ElementType: types.Any}, &types.List{ElementType: types.Any}}, + // INTERVAL OPERATORS - https://cql.hl7.org/09-b-cqlreference.html#interval-operators-3 + {&types.Interval{PointType: types.Integer}, &types.Interval{PointType: types.Integer}}, + {&types.Interval{PointType: types.Long}, &types.Interval{PointType: types.Long}}, + {&types.Interval{PointType: types.Decimal}, &types.Interval{PointType: types.Decimal}}, + {&types.Interval{PointType: types.Date}, &types.Interval{PointType: types.Date}}, + {&types.Interval{PointType: types.DateTime}, &types.Interval{PointType: types.DateTime}}, + {&types.Interval{PointType: types.Time}, &types.Interval{PointType: types.Time}}, + {&types.Interval{PointType: types.Quantity}, &types.Interval{PointType: types.Quantity}}, + }, model: func() model.IExpression { return &model.Except{ BinaryExpression: &model.BinaryExpression{}, diff --git a/tests/enginetests/operator_interval_test.go b/tests/enginetests/operator_interval_test.go index 595a902..7e4bb21 100644 --- a/tests/enginetests/operator_interval_test.go +++ b/tests/enginetests/operator_interval_test.go @@ -1930,6 +1930,372 @@ func TestIntervalWidth(t *testing.T) { } } +func TestExceptInterval(t *testing.T) { + tests := []struct { + name string + cql string + wantModel model.IExpression + wantResult result.Value + }{ + { + name: "Integer interval except - no overlap", + cql: "Interval[0, 5] except Interval[10, 15]", + wantModel: &model.Except{ + BinaryExpression: &model.BinaryExpression{ + Expression: model.ResultType(&types.Interval{PointType: types.Integer}), + Operands: []model.IExpression{ + &model.Interval{ + Low: model.NewLiteral("0", types.Integer), + High: model.NewLiteral("5", types.Integer), + LowInclusive: true, + HighInclusive: true, + Expression: model.ResultType(&types.Interval{PointType: types.Integer}), + }, + &model.Interval{ + Low: model.NewLiteral("10", types.Integer), + High: model.NewLiteral("15", types.Integer), + LowInclusive: true, + HighInclusive: true, + Expression: model.ResultType(&types.Interval{PointType: types.Integer}), + }, + }, + }, + }, + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(0)), + High: newOrFatal(t, int32(5)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - partial overlap left", + cql: "Interval[0, 5] except Interval[3, 7]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(0)), + High: newOrFatal(t, int32(2)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - partial overlap right", + cql: "Interval[5, 10] except Interval[0, 7]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(8)), + High: newOrFatal(t, int32(10)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - properly contained", + cql: "Interval[0, 10] except Interval[3, 7]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Integer interval except - complete overlap", + cql: "Interval[3, 7] except Interval[0, 10]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Integer interval except - identical intervals", + cql: "Interval[0, 5] except Interval[0, 5]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Integer interval except - null left", + cql: "null as Interval except Interval[0, 5]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Integer interval except - null right", + cql: "Interval[0, 5] except null as Interval", + wantResult: newOrFatal(t, nil), + }, + // Decimal intervals + { + name: "Decimal interval except - no overlap", + cql: "Interval[0.0, 5.0] except Interval[10.0, 15.0]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 0.0), + High: newOrFatal(t, 5.0), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + }, + { + name: "Decimal interval except - partial overlap", + cql: "Interval[0.0, 5.0] except Interval[3.0, 7.0]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 0.0), + High: newOrFatal(t, 2.99999999), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + }, + // Long intervals + { + name: "Long interval except - no overlap", + cql: "Interval[0L, 5L] except Interval[10L, 15L]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int64(0)), + High: newOrFatal(t, int64(5)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Long}, + }), + }, + { + name: "Long interval except - partial overlap", + cql: "Interval[0L, 5L] except Interval[3L, 7L]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int64(0)), + High: newOrFatal(t, int64(2)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Long}, + }), + }, + // Quantity intervals + { + name: "Quantity interval except - no overlap", + cql: "Interval[0'cm', 5'cm'] except Interval[10'cm', 15'cm']", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Quantity{Value: 0, Unit: "cm"}), + High: newOrFatal(t, result.Quantity{Value: 5, Unit: "cm"}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Quantity}, + }), + }, + { + name: "Quantity interval except - partial overlap", + cql: "Interval[0'cm', 5'cm'] except Interval[3'cm', 7'cm']", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Quantity{Value: 0, Unit: "cm"}), + High: newOrFatal(t, result.Quantity{Value: 2.99999999, Unit: "cm"}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Quantity}, + }), + }, + // Date intervals + { + name: "Date interval except - no overlap", + cql: "Interval[@2020-01-01, @2020-01-05] except Interval[@2020-01-10, @2020-01-15]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, time.January, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.Date{Date: time.Date(2020, time.January, 5, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Date}, + }), + }, + { + name: "Date interval except - complete overlap", + cql: "Interval[@2020-01-03, @2020-01-07] except Interval[@2020-01-01, @2020-01-10]", + wantResult: newOrFatal(t, nil), + }, + // DateTime intervals (simplified - returns null for complex cases) + { + name: "DateTime interval except - no overlap", + cql: "Interval[@2020-01-01T, @2020-01-05T] except Interval[@2020-01-10T, @2020-01-15T]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 5, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.DateTime}, + }), + }, + { + name: "DateTime interval except - complete overlap", + cql: "Interval[@2020-01-03T, @2020-01-07T] except Interval[@2020-01-01T, @2020-01-10T]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Integer interval except - right ends at left start", + cql: "Interval[0, 10] except Interval[-5, 0]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(10)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - right starts at left end", + cql: "Interval[0, 10] except Interval[10, 20]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(0)), + High: newOrFatal(t, int32(9)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - right is left start point", + cql: "Interval[0, 10] except Interval[0, 0]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(10)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - open bounds", + cql: "Interval(1, 5) except Interval(0, 3)", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(3)), + High: newOrFatal(t, int32(5)), + LowInclusive: true, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - no overlap keeps open bounds", + cql: "Interval(0, 5) except Interval[10, 20]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(0)), + High: newOrFatal(t, int32(5)), + LowInclusive: false, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - partial overlap keeps open low bound", + cql: "Interval(0, 10] except Interval[5, 20]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(0)), + High: newOrFatal(t, int32(4)), + LowInclusive: false, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - null low bound", + cql: "Interval[null, 10] except Interval[5, 20]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, nil), + High: newOrFatal(t, int32(4)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Long interval except - right starts at left end", + cql: "Interval[0L, 10L] except Interval[10L, 20L]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int64(0)), + High: newOrFatal(t, int64(9)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Long}, + }), + }, + { + name: "Quantity interval except - right starts at left end", + cql: "Interval[0'cm', 10'cm'] except Interval[10'cm', 20'cm']", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Quantity{Value: 0, Unit: "cm"}), + High: newOrFatal(t, result.Quantity{Value: 9.99999999, Unit: "cm"}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Quantity}, + }), + }, + { + name: "Quantity interval except - differing comparable units", + cql: "Interval[0'cm', 10'cm'] except Interval[50'mm', 200'mm']", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Quantity{Value: 0, Unit: "cm"}), + High: newOrFatal(t, result.Quantity{Value: 4.99999999, Unit: "cm"}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Quantity}, + }), + }, + { + name: "Date interval except - right ends at left start", + cql: "Interval[@2020-01-05, @2020-01-10] except Interval[@2020-01-01, @2020-01-05]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, time.January, 6, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.Date{Date: time.Date(2020, time.January, 10, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Date}, + }), + }, + { + name: "DateTime interval except - same day partial overlap", + cql: "Interval[@2020-01-01T10:00:00.000Z, @2020-01-01T12:00:00.000Z] except Interval[@2020-01-01T11:00:00.000Z, @2020-01-01T13:00:00.000Z]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 10, 0, 0, 0, time.UTC), Precision: model.MILLISECOND}), + High: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 10, 59, 59, 999000000, time.UTC), Precision: model.MILLISECOND}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.DateTime}, + }), + }, + { + name: "DateTime interval except - same day no overlap", + cql: "Interval[@2020-01-01T10:00:00.000Z, @2020-01-01T12:00:00.000Z] except Interval[@2020-01-01T13:00:00.000Z, @2020-01-01T14:00:00.000Z]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 10, 0, 0, 0, time.UTC), Precision: model.MILLISECOND}), + High: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 12, 0, 0, 0, time.UTC), Precision: model.MILLISECOND}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.DateTime}, + }), + }, + { + name: "Quantity interval except - incomparable units", + cql: "Interval[0'cm', 10'cm'] except Interval[5'g', 20'g']", + wantResult: newOrFatal(t, nil), + }, + { + name: "Date interval except - uncertain precision", + cql: "Interval[@2020-01-01, @2020-01-10] except Interval[@2020-01, @2020-02]", + wantResult: newOrFatal(t, nil), + }, + } + + 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) + } + if diff := cmp.Diff(tc.wantModel, getTESTRESULTModel(t, parsedLibs)); tc.wantModel != nil && diff != "" { + t.Errorf("Parse diff (-want +got):\n%s", diff) + } + + 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.wantResult, getTESTRESULT(t, results), protocmp.Transform()); diff != "" { + t.Errorf("Eval diff (-want +got)\n%v", diff) + } + }) + } +} + func TestComparison_Error(t *testing.T) { tests := []struct { name string diff --git a/tests/spectests/exclusions/exclusions.go b/tests/spectests/exclusions/exclusions.go index 50d255e..3c171c4 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -225,7 +225,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "Collapse", "Expand", "Ends", - "Except", "Includes", "Intersect", "Meets", @@ -311,6 +310,8 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { // TODO: b/342064453 - Ambiguous match. "TestEqualNull", "TestInNullBoundaries", + "NullInterval", + "TestExceptNull", }, }, "CqlListOperatorsTest.xml": {