From 9812c6ed14195162ebc2227fb0c08730e0568faa Mon Sep 17 00:00:00 2001 From: Evan Gordon Date: Thu, 13 Jun 2024 17:17:35 -0700 Subject: [PATCH] Add interpreter support for contains operator. PiperOrigin-RevId: 643170247 --- interpreter/operator_interval.go | 2 + parser/operator_expressions_test.go | 8 +- parser/operators.go | 10 ++ parser/operators_test.go | 6 +- tests/enginetests/operator_interval_test.go | 183 ++++++++++++++++++++ tests/spectests/exclusions/exclusions.go | 7 +- 6 files changed, 208 insertions(+), 8 deletions(-) diff --git a/interpreter/operator_interval.go b/interpreter/operator_interval.go index bb834a4..15ef35d 100644 --- a/interpreter/operator_interval.go +++ b/interpreter/operator_interval.go @@ -260,6 +260,7 @@ func (i *interpreter) evalCompareIntervalDateTimeInterval(be model.IBinaryExpres // in _precision_ (point Integer, argument Interval) Boolean // in _precision_ (point Quantity, argument Interval) Boolean // https://cql.hl7.org/09-b-cqlreference.html#in +// 'Contains' with the left and right args reversed is forwarded here. func evalInIntervalNumeral(b model.IBinaryExpression, pointObj, intervalObj result.Value) (result.Value, error) { if result.IsNull(pointObj) { return result.New(nil) @@ -358,6 +359,7 @@ func compareNumeral[t float64 | int64 | int32](left, right t) comparison { // in _precision_ (point Date, argument Interval) Boolean // https://cql.hl7.org/09-b-cqlreference.html#in // 'IncludedIn' with left arg of point type is forwarded here. +// 'Contains' with the left and right args reversed is forwarded here. func (i *interpreter) evalInIntervalDateTime(b model.IBinaryExpression, pointObj, intervalObj result.Value) (result.Value, error) { m := b.(*model.In) precision := model.DateTimePrecision(m.Precision) diff --git a/parser/operator_expressions_test.go b/parser/operator_expressions_test.go index 05814ee..b5500e8 100644 --- a/parser/operator_expressions_test.go +++ b/parser/operator_expressions_test.go @@ -816,10 +816,11 @@ func TestOperatorExpressions(t *testing.T) { { name: "MembershipExpression Contains With Precision", cql: "Interval[@2013-01-01T00:00:00.0, @2014-01-01T00:00:00.0) contains year of @2013-01-01T00:00:00.0", - want: &model.Contains{ + want: &model.In{ Precision: model.YEAR, BinaryExpression: &model.BinaryExpression{ Operands: []model.IExpression{ + model.NewLiteral("@2013-01-01T00:00:00.0", types.DateTime), &model.Interval{ Low: model.NewLiteral("@2013-01-01T00:00:00.0", types.DateTime), High: model.NewLiteral("@2014-01-01T00:00:00.0", types.DateTime), @@ -827,7 +828,6 @@ func TestOperatorExpressions(t *testing.T) { LowInclusive: true, HighInclusive: false, }, - model.NewLiteral("@2013-01-01T00:00:00.0", types.DateTime), }, Expression: model.ResultType(types.Boolean), }, @@ -836,9 +836,10 @@ func TestOperatorExpressions(t *testing.T) { { name: "MembershipExpression Contains Without Precision", cql: "Interval[@2013-01-01T00:00:00.0, @2014-01-01T00:00:00.0) contains @2013-01-01T00:00:00.0", - want: &model.Contains{ + want: &model.In{ BinaryExpression: &model.BinaryExpression{ Operands: []model.IExpression{ + model.NewLiteral("@2013-01-01T00:00:00.0", types.DateTime), &model.Interval{ Low: model.NewLiteral("@2013-01-01T00:00:00.0", types.DateTime), High: model.NewLiteral("@2014-01-01T00:00:00.0", types.DateTime), @@ -846,7 +847,6 @@ func TestOperatorExpressions(t *testing.T) { LowInclusive: true, HighInclusive: false, }, - model.NewLiteral("@2013-01-01T00:00:00.0", types.DateTime), }, Expression: model.ResultType(types.Boolean), }, diff --git a/parser/operators.go b/parser/operators.go index da5bd68..bf36a73 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -59,6 +59,14 @@ func (v *visitor) resolveFunction(libraryName, funcName string, operands []model switch t := r.(type) { case *model.Coalesce: return v.parseCoalesce(t, resolved.WrappedOperands) + case *model.Contains: + // If we reverse the operands we can treat contains as an In. + contains := t + r = &model.In{ + Precision: contains.Precision, + BinaryExpression: &model.BinaryExpression{Expression: contains.Expression}, + } + resolved.WrappedOperands[0], resolved.WrappedOperands[1] = resolved.WrappedOperands[1], resolved.WrappedOperands[0] case *model.Message: if len(resolved.WrappedOperands) != 5 { return nil, errors.New("internal error - resolving message function returned incorrect argument") @@ -987,6 +995,8 @@ func (p *Parser) loadSystemOperators() error { }, { name: "Contains", + // Contains is a macro for the In operator but with the operands reversed. + // We convert to that model in resolveFunctions() above. operands: [][]types.IType{ {convert.GenericList, convert.GenericType}, // TODO(b/301606416): Add support for ContainsYears, ContainsDays... diff --git a/parser/operators_test.go b/parser/operators_test.go index f503a02..c0e97c1 100644 --- a/parser/operators_test.go +++ b/parser/operators_test.go @@ -749,16 +749,18 @@ func TestBuiltInFunctions(t *testing.T) { { name: "Contains", cql: "Contains({3}, 1)", - want: &model.Contains{ + // The contains operator is optimized away and replaced with an in operator with the original + // operands reversed. + want: &model.In{ BinaryExpression: &model.BinaryExpression{ Operands: []model.IExpression{ + model.NewLiteral("1", types.Integer), &model.List{ Expression: model.ResultType(&types.List{ElementType: types.Integer}), List: []model.IExpression{ model.NewLiteral("3", types.Integer), }, }, - model.NewLiteral("1", types.Integer), }, Expression: model.ResultType(types.Boolean), }, diff --git a/tests/enginetests/operator_interval_test.go b/tests/enginetests/operator_interval_test.go index 19d7622..3118b65 100644 --- a/tests/enginetests/operator_interval_test.go +++ b/tests/enginetests/operator_interval_test.go @@ -1521,6 +1521,189 @@ func TestIntervalIncludedIn(t *testing.T) { } } +func TestIntervalContains(t *testing.T) { + tests := []struct { + name string + cql string + wantModel model.IExpression + wantResult result.Value + }{ + // TODO: b/331225778 - Null support handling for Contains operator + { + name: "On inclusive bound date", + cql: "Interval[@2020-03-25, @2022-04] contains month of @2020-03", + wantModel: &model.In{ + BinaryExpression: &model.BinaryExpression{ + Expression: model.ResultType(types.Boolean), + Operands: []model.IExpression{ + model.NewLiteral("@2020-03", types.Date), + &model.Interval{ + Low: model.NewLiteral("@2020-03-25", types.Date), + High: model.NewLiteral("@2022-04", types.Date), + LowInclusive: true, + HighInclusive: true, + Expression: model.ResultType(&types.Interval{PointType: types.Date}), + }, + }, + }, + Precision: model.MONTH, + }, + wantResult: newOrFatal(t, true), + }, + { + name: "Point arg null datetime", + cql: "Interval[@2024-03-31T00:00:00.000Z, @2024-03-31T00:00:00.000Z] contains null", + wantResult: newOrFatal(t, nil), + }, + { + name: "On inclusive bound datetime", + cql: "Interval[@2024-03-31T00:00:00.000Z, @2025-03-31T00:00:00.000Z) contains month of @2024-03-31T00:00:00.000Z", + wantResult: newOrFatal(t, true), + }, + { + name: "On exclusive bound date", + cql: "Interval(@2020-03-25, @2022-04) contains month of @2020-03", + wantResult: newOrFatal(t, false), + }, + { + name: "On exclusive bound datetime", + cql: "Interval(@2024-03-31T00:00:00.000Z, @2025-03-31T00:00:00.000Z) contains month of @2024-03-31T00:00:00.000Z", + wantResult: newOrFatal(t, false), + }, + { + name: "On inclusive bound datetime second precision", + cql: "Interval[@2024-03-31T00:00:00.000Z, @2024-03-31T00:00:05.000Z] contains second of @2024-03-31T00:00:00.000Z", + wantResult: newOrFatal(t, true), + }, + { + name: "On exclusive and inclusive bound", + cql: "Interval(@2020-03, @2022-03] contains month of @2020-03", + wantResult: newOrFatal(t, false), + }, + { + name: "Insufficient precision date", + cql: "Interval[@2020-03-25, @2020-04] contains day of @2020-03", + wantResult: newOrFatal(t, nil), + }, + { + name: "Insufficient precision datetime", + cql: "Interval[@2024-03-28T00:00:00.000Z, @2024-03-31T00:00:00.000Z] contains day of @2024-03", + wantResult: newOrFatal(t, nil), + }, + { + name: "Insufficient precision but for sure false", + cql: "Interval[@2028-03-25, @2020-04] contains day of @2020-03", + wantResult: newOrFatal(t, false), + }, + { + name: "Null inclusive bound is true", + cql: "Interval[null, @2022-04) contains month of @2020-03", + wantResult: newOrFatal(t, true), + }, + { + name: "Null inclusive bound but this is for sure false", + cql: "Interval[null, @2022-04) contains month of @2025-03", + wantResult: newOrFatal(t, false), + }, + { + name: "Null exclusive bound is null", + cql: "Interval(null, @2022-04) contains month of @2021-03", + wantResult: newOrFatal(t, nil), + }, + { + name: "Null exclusive bound but this is for sure false", + cql: "Interval(null, @2022-04) contains month of @2025-03", + wantResult: newOrFatal(t, false), + }, + // No precision + { + name: "No included in operator precision: On inclusive bound date", + cql: "Interval[@2020-03-25, @2022-04) contains day of @2020-03-25", + wantResult: newOrFatal(t, true), + }, + { + name: "No included in operator precision: On exclusive bound datetime", + cql: "Interval(@2024-03-31T00:00:00.000Z, @2025-03-31T00:00:00.000Z) contains day of @2024-03-31T00:00:00.000Z", + wantResult: newOrFatal(t, false), + }, + { + name: "No included in operator precision with differing operand precision", + cql: "Interval[@2020-03-25, @2022-04-25) contains @2020-03", + wantResult: newOrFatal(t, nil), + }, + { + name: "interval contains integer", + cql: "Interval[0, 100] contains 42", + wantResult: newOrFatal(t, true), + }, + { + name: "Interval does not contain integer", + cql: "Interval[0, 25] contains 42", + wantResult: newOrFatal(t, false), + }, + { + name: "Interval contains integer on bounds", + cql: "Interval[0, 25] contains 25", + wantResult: newOrFatal(t, true), + }, + { + name: "Interval on bounds not inclusive integer", + cql: "Interval[0, 25) contains 25", + wantResult: newOrFatal(t, false), + }, + { + name: "Integer before interval bounds not inclusive", + cql: "Interval[0, 25) contains 24", + wantResult: newOrFatal(t, true), + }, + { + name: "Interval not contains integer with null exlusive bounds", + cql: "Interval[0, null) contains 25", + wantResult: newOrFatal(t, nil), + }, + { + name: "Interval contains double on upper bounds", + cql: "Interval[1.0, 1.5] contains 1.5", + wantResult: newOrFatal(t, true), + }, + { + name: "Interval not contains long", + cql: "Interval[1L, 2L] contains 0L", + wantResult: newOrFatal(t, false), + }, + { + name: "Interval contains quantity on bounds", + cql: "Interval[1'cm', 2'cm') contains 1'cm'", + wantResult: newOrFatal(t, true), + }, + { + name: "Functional syntax", + cql: "Contains(Interval[0, 100], 42)", + 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 TestComparison_Error(t *testing.T) { tests := []struct { name string diff --git a/tests/spectests/exclusions/exclusions.go b/tests/spectests/exclusions/exclusions.go index cbf83b4..0d8934d 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -264,7 +264,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "Before", "Collapse", "Expand", - "Contains", "Ends", "Except", "Includes", @@ -286,6 +285,9 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "Width", }, NamesExcludes: []string{ + // TODO: b/342061715 - unsupported operators. + "TimeContainsFalse", + "TimeContainsTrue", // TODO: b/342061783 - Got unexpected result. "TimeInTrue", "TimeInFalse", @@ -310,6 +312,7 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "TestOnOrBeforeDecimalFalse", "TestOnOrBeforeQuantityTrue", // TODO: b/342064453 - Ambiguous match. + "TestNullElement1", "TestEqualNull", "TestInNullBoundaries", }, @@ -317,7 +320,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "CqlListOperatorsTest.xml": XMLTestFileExclusions{ GroupExcludes: []string{ // TODO: b/342061715 - unsupported operators. - "Contains", "Descendents", "Distinct", "Except", @@ -339,6 +341,7 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { }, NamesExcludes: []string{ // TODO: b/342061715 - unsupported operator. + "ContainsNullLeft", "In1Null", "EquivalentABCAnd123", "Equivalent123AndABC",