diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index af782da..a2f165d 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -1246,6 +1246,34 @@ func (i *interpreter) binaryOverloads(m model.IBinaryExpression) ([]convert.Over Operands: []types.IType{&types.List{ElementType: types.Any}, &types.List{ElementType: types.Any}}, Result: evalProperlyIncludedInList, }, + { + Operands: []types.IType{&types.Interval{PointType: types.Date}, &types.Interval{PointType: types.Date}}, + Result: i.evalProperlyIncludedInInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.DateTime}, &types.Interval{PointType: types.DateTime}}, + Result: i.evalProperlyIncludedInInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Integer}, &types.Interval{PointType: types.Integer}}, + Result: i.evalProperlyIncludedInInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Long}, &types.Interval{PointType: types.Long}}, + Result: i.evalProperlyIncludedInInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Decimal}, &types.Interval{PointType: types.Decimal}}, + Result: i.evalProperlyIncludedInInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Quantity}, &types.Interval{PointType: types.Quantity}}, + Result: i.evalProperlyIncludedInInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Time}, &types.Interval{PointType: types.Time}}, + Result: i.evalProperlyIncludedInInterval, + }, }, nil case *model.Skip: return []convert.Overload[evalBinarySignature]{ diff --git a/interpreter/operator_interval.go b/interpreter/operator_interval.go index e0461dc..ef09b1b 100644 --- a/interpreter/operator_interval.go +++ b/interpreter/operator_interval.go @@ -15,6 +15,7 @@ package interpreter import ( + "cmp" "fmt" "time" @@ -641,3 +642,109 @@ func evalWidthInterval(m model.IUnaryExpression, intervalObj result.Value) (resu } return result.Value{}, fmt.Errorf("internal error - unsupported point type in evalWidthInterval: %v", start.RuntimeType()) } + +// ProperlyIncludedIn(left Interval, right Interval) Boolean +// https://cql.hl7.org/09-b-cqlreference.html#properly-included-in-1 +func (i *interpreter) evalProperlyIncludedInInterval(b model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + if result.IsNull(lObj) || result.IsNull(rObj) { + return result.New(nil) + } + if m, ok := b.(*model.ProperlyIncludedIn); ok && m.Precision != model.UNSETDATETIMEPRECISION { + return result.Value{}, fmt.Errorf("internal error - properly included in does not yet support precision") + } + + // start and end handle null inclusivity and open boundaries, so every bound below is closed and + // a null bound is unknown. + leftStart, leftEnd, err := startAndEnd(lObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + rightStart, rightEnd, err := startAndEnd(rObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + + startComp, err := compareIntervalPoints(leftStart, rightStart) + if err != nil { + return result.Value{}, err + } + endComp, err := compareIntervalPoints(leftEnd, rightEnd) + if err != nil { + return result.Value{}, err + } + if startComp == leftBeforeRight || endComp == leftAfterRight { + return result.New(false) + } + + // An unknown start is at most the left end and an unknown end is at least the left start, so + // Interval(null, 1] properly included in Interval[5, null) is still false. + if result.IsNull(leftStart) { + c, err := compareIntervalPoints(leftEnd, rightStart) + if err != nil { + return result.Value{}, err + } + if c == leftBeforeRight { + return result.New(false) + } + } + if result.IsNull(leftEnd) { + c, err := compareIntervalPoints(leftStart, rightEnd) + if err != nil { + return result.Value{}, err + } + if c == leftAfterRight { + return result.New(false) + } + } + + startKnown := startComp == leftEqualRight || startComp == leftAfterRight + endKnown := endComp == leftEqualRight || endComp == leftBeforeRight + if !startKnown || !endKnown { + return result.New(nil) + } + return result.New(startComp == leftAfterRight || endComp == leftBeforeRight) +} + +// compareIntervalPoints compares two interval points of the same point type. It returns +// comparedToNull if either point is null or if Quantity units cannot be converted. +func compareIntervalPoints(l, r result.Value) (comparison, error) { + if result.IsNull(l) || result.IsNull(r) { + return comparedToNull, nil + } + switch l.RuntimeType() { + case types.Integer: + return compareValues(l, r, result.ToInt32) + case types.Long: + return compareValues(l, r, result.ToInt64) + case types.Decimal: + return compareValues(l, r, result.ToFloat64) + case types.Quantity: + lq, rq, err := applyToValues(l, r, result.ToQuantity) + if err != nil { + return unsetComparison, err + } + if lq.Unit == rq.Unit { + return toComparison(cmp.Compare(lq.Value, rq.Value)), nil + } + converted, err := ucum.ConvertUnit(lq.Value, string(lq.Unit), string(rq.Unit)) + if err != nil { + return comparedToNull, nil + } + return toComparison(cmp.Compare(converted, rq.Value)), nil + case types.Date, types.DateTime, types.Time: + ldt, rdt, err := applyToValues(l, r, result.ToDateTime) + if err != nil { + return unsetComparison, err + } + return compareDateTime(ldt, rdt) + } + return unsetComparison, fmt.Errorf("internal error - unsupported point type in compareIntervalPoints: %v", l.RuntimeType()) +} + +func compareValues[t cmp.Ordered](l, r result.Value, fn func(result.Value) (t, error)) (comparison, error) { + lv, rv, err := applyToValues(l, r, fn) + if err != nil { + return unsetComparison, err + } + return toComparison(cmp.Compare(lv, rv)), nil +} diff --git a/parser/operators.go b/parser/operators.go index 1f616c2..50efab7 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -1871,6 +1871,13 @@ func (p *Parser) loadSystemOperators() error { operands: [][]types.IType{ {convert.GenericType, convert.GenericList}, {convert.GenericList, convert.GenericList}, + {&types.Interval{PointType: types.Date}, &types.Interval{PointType: types.Date}}, + {&types.Interval{PointType: types.DateTime}, &types.Interval{PointType: types.DateTime}}, + {&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.Quantity}, &types.Interval{PointType: types.Quantity}}, + {&types.Interval{PointType: types.Time}, &types.Interval{PointType: types.Time}}, }, model: func() model.IExpression { return &model.ProperlyIncludedIn{ diff --git a/tests/enginetests/operator_interval_test.go b/tests/enginetests/operator_interval_test.go index 595a902..dc0aca3 100644 --- a/tests/enginetests/operator_interval_test.go +++ b/tests/enginetests/operator_interval_test.go @@ -125,6 +125,137 @@ func TestEnd(t *testing.T) { } } +func TestProperlyIncludedInInterval(t *testing.T) { + tests := []struct { + name string + cql string + want result.Value + }{ + { + name: "Properly included Date intervals", + cql: `Interval[@2020-01-05, @2020-01-15] properly included in Interval[@2020-01-01, @2020-01-20]`, + want: newOrFatal(t, true), + }, + { + name: "Equal Date intervals not properly included", + cql: `Interval[@2020-01-01, @2020-01-15] properly included in Interval[@2020-01-01, @2020-01-15]`, + want: newOrFatal(t, false), + }, + { + name: "Not included Date intervals", + cql: `Interval[@2020-01-01, @2020-01-25] properly included in Interval[@2020-01-05, @2020-01-15]`, + want: newOrFatal(t, false), + }, + { + name: "Properly included Integer intervals", + cql: `Interval[5, 15] properly included in Interval[1, 20]`, + want: newOrFatal(t, true), + }, + { + name: "Equal Integer intervals not properly included", + cql: `Interval[1, 15] properly included in Interval[1, 15]`, + want: newOrFatal(t, false), + }, + { + name: "Not included Integer intervals", + cql: `Interval[1, 25] properly included in Interval[5, 15]`, + want: newOrFatal(t, false), + }, + { + name: "Null left interval", + cql: `(null as Interval) properly included in Interval[@2020-01-01, @2020-01-15]`, + want: newOrFatal(t, nil), + }, + { + name: "Null right interval", + cql: `Interval[@2020-01-01, @2020-01-15] properly included in (null as Interval)`, + want: newOrFatal(t, nil), + }, + { + name: "Unknown left start ends before right start", + cql: `Interval(null, 1] properly included in Interval[5, null)`, + want: newOrFatal(t, false), + }, + { + name: "Unknown left end starts after right end", + cql: `Interval[5, null) properly included in Interval(null, 1]`, + want: newOrFatal(t, false), + }, + { + name: "Unknown left start inside right interval", + cql: `Interval(null, 5] properly included in Interval[1, 10]`, + want: newOrFatal(t, nil), + }, + { + name: "Unknown right start", + cql: `Interval[2, 5] properly included in Interval(null, 10]`, + want: newOrFatal(t, nil), + }, + { + name: "Closed null right bounds", + cql: `Interval[2, 5] properly included in Interval[null as Integer, null as Integer]`, + want: newOrFatal(t, true), + }, + { + name: "Same Integer interval with different boundary inclusivity", + cql: `Interval[1, 5] properly included in Interval[1, 6)`, + want: newOrFatal(t, false), + }, + { + name: "Properly included Long intervals", + cql: `Interval[2L, 4L] properly included in Interval[1L, 5L]`, + want: newOrFatal(t, true), + }, + { + name: "Properly included Decimal intervals with open bounds", + cql: `Interval(1.0, 2.0) properly included in Interval[1.0, 2.0]`, + want: newOrFatal(t, true), + }, + { + name: "Properly included Time intervals", + cql: `Interval[@T10:00, @T11:00] properly included in Interval[@T09:00, @T12:00]`, + want: newOrFatal(t, true), + }, + { + name: "Date intervals with insufficient precision", + cql: `Interval[@2020, @2021] properly included in Interval[@2020-01-01, @2022-01-01]`, + want: newOrFatal(t, nil), + }, + { + name: "Quantity intervals with convertible units", + cql: `Interval[500 'g', 900 'g'] properly included in Interval[0.1 'kg', 1 'kg']`, + want: newOrFatal(t, true), + }, + { + name: "Same Quantity interval in different units", + cql: `Interval[1 'kg', 2 'kg'] properly included in Interval[1000 'g', 2000 'g']`, + want: newOrFatal(t, false), + }, + { + name: "Quantity intervals with incompatible units", + cql: `Interval[1 'g', 2 'g'] properly included in Interval[1 'm', 5 'm']`, + want: 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) + } + 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..28204e8 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -235,7 +235,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "ProperContains", "ProperIn", "ProperlyIncludes", - "ProperlyIncludedIn", "Starts", "Union", }, @@ -311,6 +310,7 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { // TODO: b/342064453 - Ambiguous match. "TestEqualNull", "TestInNullBoundaries", + "IntegerIntervalProperlyIncludedInNullBoundaries", }, }, "CqlListOperatorsTest.xml": {