From 723b8d62c7f38fea57d3d29b26bad007d908c2bb Mon Sep 17 00:00:00 2001 From: Evan Gordon Date: Fri, 12 Jul 2024 10:37:50 -0700 Subject: [PATCH] Add support for Overlaps(Interval, Interval) Boolean // op(left Date, right Interval) Boolean // https://cql.hl7.org/09-b-cqlreference.html#after-1 @@ -170,7 +184,6 @@ func (i *interpreter) evalCompareDateTimeInterval(be model.IBinaryExpression, lO // https://cql.hl7.org/09-b-cqlreference.html#before-1 // https://cql.hl7.org/09-b-cqlreference.html#on-or-after-2 // https://cql.hl7.org/09-b-cqlreference.html#on-or-before-2 -// TODO(b/308016038): Once Start and End are properly supported, handle low/high inclusivity. func (i *interpreter) evalCompareIntervalDateTimeInterval(be model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { if result.IsNull(lObj) || result.IsNull(rObj) { return result.New(nil) @@ -255,6 +268,99 @@ func (i *interpreter) evalCompareIntervalDateTimeInterval(be model.IBinaryExpres return result.Value{}, fmt.Errorf("internal error - unsupported Binary Comparison Expression in evalCompareIntervalDateTimeInterval: %v", be) } +// Overlaps(left Interval, right Interval) Boolean +// Overlaps(left Interval, right Interval) Boolean +// https://cql.hl7.org/09-b-cqlreference.html#overlaps +func (i *interpreter) evalOverlapsIntervalDateTimeInterval(be model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + if result.IsNull(lObj) || result.IsNull(rObj) { + return result.New(nil) + } + p, err := precisionFromBinaryExpression(be) + if err != nil { + return result.Value{}, err + } + + iType, ok := be.Left().GetResultType().(*types.Interval) + if !ok { + return result.Value{}, fmt.Errorf("internal error - evalCompareIntervalDateTimeInterval got Value that is not an interval type") + } + pointType := iType.PointType + allowUnsetPrec := true + if err := validatePrecisionByType(p, allowUnsetPrec, pointType); err != nil { + return result.Value{}, err + } + if p != "" { + return result.Value{}, fmt.Errorf("internal error - overlaps does not yet support precision") + } + + // Get left interval bounds. + lObjStart, lObjEnd, err := startAndEnd(lObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + leftStart, leftEnd, err := applyToValues(lObjStart, lObjEnd, result.ToDateTime) + if err != nil { + return result.Value{}, err + } + + // Get right interval bounds. + rObjStart, rObjEnd, err := startAndEnd(rObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + rightStart, rightEnd, err := applyToValues(rObjStart, rObjEnd, result.ToDateTime) + if err != nil { + return result.Value{}, err + } + + // Due to complexity regarding DateTime precision, we will calculate each case separately and + // return the OR of all results. If any of the cases are true, then the result is true, if any + // of the cases are null, then the result is null, otherwise the result is false. + compResults := []result.Value{} + // Case 1. Left starts during right interval. + leftStartsDuringRightIntervalValue, err := dateTimeInIntervalWithPrecision(leftStart, rightStart, rightEnd, p) + if err != nil { + return result.Value{}, err + } + compResults = append(compResults, leftStartsDuringRightIntervalValue) + + // Case 2. Left ends during right interval. + leftEndsDuringRightIntervalValue, err := dateTimeInIntervalWithPrecision(leftEnd, rightStart, rightEnd, p) + if err != nil { + return result.Value{}, err + } + compResults = append(compResults, leftEndsDuringRightIntervalValue) + + // Case 3. Right starts during left interval. + rightStartsDuringLeftIntervalValue, err := dateTimeInIntervalWithPrecision(rightStart, leftStart, leftEnd, p) + if err != nil { + return result.Value{}, err + } + compResults = append(compResults, rightStartsDuringLeftIntervalValue) + + // Case 4. Right ends during left interval. + rightEndsDuringLeftIntervalValue, err := dateTimeInIntervalWithPrecision(rightEnd, leftStart, leftEnd, p) + if err != nil { + return result.Value{}, err + } + compResults = append(compResults, rightEndsDuringLeftIntervalValue) + + trueVal, err := result.New(true) + if err != nil { + return result.Value{}, err + } + nullVal, err := result.New(nil) + if err != nil { + return result.Value{}, err + } + if valueInList(trueVal, compResults) { + return trueVal, nil + } else if valueInList(nullVal, compResults) { + return nullVal, nil + } + return result.New(false) +} + // in _precision_ (point Decimal, argument Interval) Boolean // in _precision_ (point Long, argument Interval) Boolean // in _precision_ (point Integer, argument Interval) Boolean @@ -270,11 +376,7 @@ func evalInIntervalNumeral(b model.IBinaryExpression, pointObj, intervalObj resu } // start and end handles null inclusivity as well as non-inclusive logic. - s, err := start(intervalObj, nil) - if err != nil { - return result.Value{}, err - } - e, err := end(intervalObj, nil) + s, e, err := startAndEnd(intervalObj, nil) if err != nil { return result.Value{}, err } @@ -355,6 +457,29 @@ func compareNumeral[t float64 | int64 | int32](left, right t) comparison { return leftAfterRight } +// duringDateTimeWithPrecision returns whether or not the given DateTimeValue is during the given +// low high interval. Returns null in cases where values cannot be compared such as right precision being +// less than left precision. +// All values are expected to be inclusive bounds. +// Return a null value if the comparison cannot be made due to insufficient precision. +func dateTimeInIntervalWithPrecision(a, low, high result.DateTime, p model.DateTimePrecision) (result.Value, error) { + lowComp, err := compareDateTimeWithPrecision(a, low, p) + if err != nil { + return result.Value{}, err + } + highComp, err := compareDateTimeWithPrecision(a, high, p) + if err != nil { + return result.Value{}, err + } + + if lowComp == insufficientPrecision || highComp == insufficientPrecision { + return result.New(nil) + } else if (lowComp == leftEqualRight || lowComp == leftAfterRight) && (highComp == leftEqualRight || highComp == leftBeforeRight) { + return result.New(true) + } + return result.New(false) +} + // in _precision_ (point DateTime, argument Interval) Boolean // in _precision_ (point Date, argument Interval) Boolean // https://cql.hl7.org/09-b-cqlreference.html#in diff --git a/interpreter/operator_list.go b/interpreter/operator_list.go index 93a0834..c9ced5d 100644 --- a/interpreter/operator_list.go +++ b/interpreter/operator_list.go @@ -58,12 +58,7 @@ func evalInList(m model.IBinaryExpression, lObj, listObj result.Value) (result.V return result.Value{}, err } - for _, elemObj := range r { - if lObj.Equal(elemObj) { - return result.New(true) - } - } - return result.New(false) + return result.New(valueInList(lObj, r)) } // First(argument List) T @@ -143,3 +138,13 @@ func (i *interpreter) evalIndexerList(m model.IBinaryExpression, lObj, rObj resu } return list[idx], nil } + +// valueInList returns true if the value is in the list using equality scemantics. +func valueInList(value result.Value, list []result.Value) bool { + for _, elemObj := range list { + if value.Equal(elemObj) { + return true + } + } + return false +} diff --git a/model/model.go b/model/model.go index 234d404..f2f1050 100644 --- a/model/model.go +++ b/model/model.go @@ -996,6 +996,9 @@ type Contains BinaryExpressionWithPrecision // CalculateAgeAt ELM expression from https://cql.hl7.org/04-logicalspecification.html#calculateageat. type CalculateAgeAt BinaryExpressionWithPrecision +// Overlaps ELM Expression from https://cql.hl7.org/04-logicalspecification.html#overlaps. +type Overlaps BinaryExpressionWithPrecision + // INaryExpression is an interface that Expressions with any number of operands meet. type INaryExpression interface { IExpression @@ -1331,6 +1334,9 @@ func (a *Contains) GetName() string { return "Contains" } // GetName returns the name of the system operator. func (a *CalculateAgeAt) GetName() string { return "CalculateAgeAt" } +// GetName returns the name of the system operator. +func (a *Overlaps) GetName() string { return "Overlaps" } + // GetName returns the name of the system operator. func (a *Except) GetName() string { return "Except" } diff --git a/parser/operator_expressions.go b/parser/operator_expressions.go index c8e9bb2..3c3ef41 100644 --- a/parser/operator_expressions.go +++ b/parser/operator_expressions.go @@ -91,6 +91,17 @@ func (v *visitor) VisitTimingExpression(ctx *cql.TimingExpressionContext) model. } else { return v.badExpression("internal error - grammar should not allow this TimeBoundaryExpression", ctx) } + case *cql.OverlapsIntervalOperatorPhraseContext: + precision = precisionFromContext(operator) + fnOperator = "Overlaps" + opText := operator.GetText() + containsAfter := strings.Contains(opText, "after") + containsBefore := strings.Contains(opText, "before") + if containsAfter { + return v.badExpression("overlaps after operator is not supported", ctx) + } else if containsBefore { + return v.badExpression("overlaps before operator is not supported", ctx) + } default: return v.badExpression("unsupported interval operator in timing expression", ctx) } diff --git a/parser/operators.go b/parser/operators.go index dec244d..f9eefe1 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -1432,6 +1432,20 @@ func (p *Parser) loadSystemOperators() error { }, model: inModel(model.MILLISECOND), }, + { + name: "Overlaps", + operands: [][]types.IType{ + []types.IType{&types.Interval{PointType: types.Date}, &types.Interval{PointType: types.Date}}, + []types.IType{&types.Interval{PointType: types.DateTime}, &types.Interval{PointType: types.DateTime}}, + }, + model: func() model.IExpression { + return &model.Overlaps{ + BinaryExpression: &model.BinaryExpression{ + Expression: model.ResultType(types.Boolean), + }, + } + }, + }, { name: "SameOrAfter", // See generatePrecisionTimingOverloads() for more overloads. diff --git a/parser/operators_test.go b/parser/operators_test.go index b5ec47c..a098099 100644 --- a/parser/operators_test.go +++ b/parser/operators_test.go @@ -1072,6 +1072,31 @@ func TestBuiltInFunctions(t *testing.T) { Precision: model.YEAR, }, }, + { + name: "Overlaps with Date", + cql: "Interval[@2010, @2015] overlaps Interval[@2010, @2020]", + want: &model.Overlaps{ + BinaryExpression: &model.BinaryExpression{ + Operands: []model.IExpression{ + &model.Interval{ + Low: model.NewLiteral("@2010", types.Date), + High: model.NewLiteral("@2015", types.Date), + Expression: model.ResultType(&types.Interval{PointType: types.Date}), + LowInclusive: true, + HighInclusive: true, + }, + &model.Interval{ + Low: model.NewLiteral("@2010", types.Date), + High: model.NewLiteral("@2020", types.Date), + Expression: model.ResultType(&types.Interval{PointType: types.Date}), + LowInclusive: true, + HighInclusive: true, + }, + }, + Expression: model.ResultType(types.Boolean), + }, + }, + }, { name: "Start", cql: "Start(Interval[1, 4])", diff --git a/tests/enginetests/operator_interval_test.go b/tests/enginetests/operator_interval_test.go index 3118b65..7e2f8f3 100644 --- a/tests/enginetests/operator_interval_test.go +++ b/tests/enginetests/operator_interval_test.go @@ -1521,6 +1521,145 @@ func TestIntervalIncludedIn(t *testing.T) { } } +func TestIntervalOverlaps(t *testing.T) { + tests := []struct { + name string + cql string + wantModel model.IExpression + wantResult result.Value + }{ + // Interval, Interval overloads: + { + name: "Interval overlaps null as Interval", + cql: "Interval[@2024-01-25T01:20:30.101-07:00, @2024-01-29T01:20:30.101-07:00] overlaps null as Interval", + wantResult: newOrFatal(t, nil), + }, + { + name: "null as Interval overlaps Interval", + cql: "null as Interval overlaps Interval[@2024-01-25T01:20:30.101-07:00, @2024-01-29T01:20:30.101-07:00]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Left ends during right", + cql: "Interval[@2024-01-25T01:20:30.101-07:00, @2024-03-02T01:20:30.101-07:00] overlaps Interval[@2024-02-29T01:20:30.101-07:00, @2024-03-29T01:20:30.101-07:00]", + wantResult: newOrFatal(t, true), + }, + { + name: "Left starts during right", + cql: "Interval[@2024-03-25T01:20:30.101-07:00, @2024-06-02T01:20:30.101-07:00] overlaps Interval[@2024-02-29T01:20:30.101-07:00, @2024-03-29T01:20:30.101-07:00]", + wantResult: newOrFatal(t, true), + }, + { + name: "Left contains right, or starts before right and ends after right", + cql: "Interval[@2024-01-25T01:20:30.101-07:00, @2024-06-02T01:20:30.101-07:00] overlaps Interval[@2024-02-29T01:20:30.101-07:00, @2024-03-29T01:20:30.101-07:00]", + wantResult: newOrFatal(t, true), + }, + { + name: "Right contains left, or starts after right and ends before right", + cql: "Interval[@2024-02-29T01:20:30.101-07:00, @2024-03-29T01:20:30.101-07:00] overlaps Interval[@2024-01-25T01:20:30.101-07:00, @2024-06-02T01:20:30.101-07:00]", + wantResult: newOrFatal(t, true), + }, + { + name: "Left strictly before right", + cql: "Interval[@2024-01-25T01:20:30.101-07:00, @2024-01-29T01:20:30.101-07:00] overlaps Interval[@2024-02-29T01:20:30.101-07:00, @2024-03-29T01:20:30.101-07:00]", + wantResult: newOrFatal(t, false), + }, + { + name: "Left strictly after right", + cql: "Interval[@2024-02-29T01:20:30.101-07:00, @2024-03-29T01:20:30.101-07:00] overlaps Interval[@2024-01-25T01:20:30.101-07:00, @2024-01-29T01:20:30.101-07:00]", + wantResult: newOrFatal(t, false), + }, + { + name: "Left Interval end matches closed right interval start", + cql: "Interval[@2024-01-25T01:20:30.101-07:00, @2024-02-29T01:20:30.101-07:00] overlaps Interval[@2024-02-29T01:20:30.101-07:00, @2024-03-29T01:20:30.101-07:00]", + wantResult: newOrFatal(t, true), + }, + { + name: "Left Interval end matches open right interval start", + cql: "Interval[@2024-01-25T01:20:30.101-07:00, @2024-02-29T01:20:30.101-07:00] overlaps Interval(@2024-02-29T01:20:30.101-07:00, @2024-03-29T01:20:30.101-07:00]", + wantResult: newOrFatal(t, false), + }, + { + name: "Left Interval overlaps right Interval with null end", + cql: "Interval[@2024-01-25T01:20:30.101-07:00, @2024-03-28T01:20:30.101-07:00] overlaps Interval[@2024-02-29T01:20:30.101-07:00, null]", + wantResult: newOrFatal(t, true), + }, + { + name: "Left Interval overlaps right Interval with null start", + cql: "Interval[@2024-01-25T01:20:30.101-07:00, @2024-02-28T01:20:30.101-07:00] overlaps Interval[null, @2024-02-29T01:20:30.101-07:00]", + wantResult: newOrFatal(t, true), + }, + // Sanity check some Interval, Interval overloads + { + name: "Left strictly before right", + cql: "Interval[@2024-01-25, @2024-02-28] overlaps Interval[@2024-02-29, @2024-03-29]", + wantResult: newOrFatal(t, false), + }, + { + name: "Left strictly after right", + cql: "Interval[@2024-02-29, @2024-03-29] overlaps Interval[@2024-01-25, @2024-02-28]", + wantResult: newOrFatal(t, false), + }, + { + name: "Left Interval end matches closed right interval start", + cql: "Interval[@2024-01-25, @2024-02-29] overlaps Interval[@2024-02-29, @2024-03-29]", + wantResult: newOrFatal(t, true), + }, + { + name: "Left Interval end matches open right interval start", + cql: "Interval[@2024-01-25, @2024-02-29] overlaps Interval(@2024-02-29, @2024-03-29]", + wantResult: newOrFatal(t, false), + }, + // mixed precision tests + { + name: "Left ends during right but insufficient precision to determine overlap", + cql: "Interval[@2024-01-25, @2024-02-28] overlaps Interval[@2024-02, @2024-03-29]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Left starts during right but insufficient precision to determine overlap", + cql: "Interval[@2024-02-28, @2024-03-29] overlaps Interval[@2024-01-25, @2024-02]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Left starts and ends during right uncertain period", + cql: "Interval[@2025-01-25, @2025-02-28] overlaps Interval[@2024-02, @2025]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Right starts during uncertain period and ends before left ends", + cql: "Interval[@2024, @2025-02] overlaps Interval[@2024-02, @2025-01]", + wantResult: newOrFatal(t, true), + }, + { + name: "Right starts during left and ends during uncertain period", + cql: "Interval[@2024-02, @2025] overlaps Interval[@2024-03, @2025-02]", + wantResult: newOrFatal(t, true), + }, + } + + 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 TestIntervalContains(t *testing.T) { tests := []struct { name string diff --git a/tests/spectests/exclusions/exclusions.go b/tests/spectests/exclusions/exclusions.go index 3aeda65..59c22d4 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -271,9 +271,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "Meets", "MeetsBefore", "MeetsAfter", - "Overlaps", - "OverlapsBefore", - "OverlapsAfter", "PointFrom", "ProperContains", "ProperIn", @@ -285,6 +282,35 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { }, NamesExcludes: []string{ // TODO: b/342061715 - unsupported operators. + // Note: overlaps before and after are not supported. but these tests are missing the + // before/after keyword for Date/Time test cases so they are not excluded. + "TestOverlapsNull", + "IntegerIntervalOverlapsTrue", + "IntegerIntervalOverlapsFalse", + "DecimalIntervalOverlapsTrue", + "DecimalIntervalOverlapsFalse", + "QuantityIntervalOverlapsTrue", + "QuantityIntervalOverlapsFalse", + "TestOverlapsBeforeNull", + "IntegerIntervalOverlapsBeforeTrue", + "IntegerIntervalOverlapsBeforeFalse", + "DecimalIntervalOverlapsBeforeTrue", + "DecimalIntervalOverlapsBeforeFalse", + "QuantityIntervalOverlapsBeforeTrue", + "QuantityIntervalOverlapsBeforeFalse", + "TestOverlapsAfterNull", + "IntegerIntervalOverlapsAfterTrue", + "IntegerIntervalOverlapsAfterFalse", + "DecimalIntervalOverlapsAfterTrue", + "DecimalIntervalOverlapsAfterFalse", + "QuantityIntervalOverlapsAfterTrue", + "QuantityIntervalOverlapsAfterFalse", + "TimeOverlapsAfterTrue", + "TimeOverlapsAfterFalse", + "TimeOverlapsBeforeTrue", + "TimeOverlapsBeforeFalse", + "TimeOverlapsTrue", + "TimeOverlapsFalse", "TimeContainsFalse", "TimeContainsTrue", // TODO: b/342061783 - Got unexpected result.