From 965d842dc362895c2cf686538ebec98f4934f2ee Mon Sep 17 00:00:00 2001 From: Suyash Kumar Date: Tue, 9 Jul 2024 11:36:13 -0700 Subject: [PATCH] Implement Combine operator. Required for the CQL in https://github.com/google/cql/issues/39. PiperOrigin-RevId: 650702309 --- interpreter/operator_dispatcher.go | 11 +++ interpreter/operator_string.go | 53 ++++++++++++++ model/model.go | 8 +++ parser/operators.go | 14 ++++ parser/operators_test.go | 35 ++++++++++ tests/enginetests/operator_string_test.go | 84 +++++++++++++++++++++++ tests/spectests/exclusions/exclusions.go | 4 +- 7 files changed, 208 insertions(+), 1 deletion(-) diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index a332a9c..c01799c 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -961,6 +961,17 @@ func (i *interpreter) naryOverloads(m model.INaryExpression) ([]convert.Overload Result: evalConcatenate, }, }, nil + case *model.Combine: + return []convert.Overload[evalNarySignature]{ + { + Operands: []types.IType{&types.List{ElementType: types.String}}, + Result: i.evalCombine, + }, + { + Operands: []types.IType{&types.List{ElementType: types.String}, types.String}, + Result: i.evalCombine, + }, + }, nil default: return nil, fmt.Errorf("unsupported Nary Expression %v", m.GetName()) } diff --git a/interpreter/operator_string.go b/interpreter/operator_string.go index 83b95e9..a91f700 100644 --- a/interpreter/operator_string.go +++ b/interpreter/operator_string.go @@ -166,6 +166,59 @@ func (i *interpreter) evalSplit(m model.IBinaryExpression, left, right result.Va return result.New(l) } +// Combine(source List) String +// Combine(source List, separator String) String +// https://cql.hl7.org/09-b-cqlreference.html#combine +func (i *interpreter) evalCombine(m model.INaryExpression, operands []result.Value) (result.Value, error) { + if len(operands) == 0 { + // Dispatcher and Parser should prevent this from happening. + return result.Value{}, fmt.Errorf("internal error - Combine must have at least one operand") + } + + if result.IsNull(operands[0]) { + return result.New(nil) + } + + // Extract string list: + strList, err := result.ToSlice(operands[0]) + if err != nil { + return result.Value{}, err + } + if len(strList) == 0 { + return result.New(nil) + } + + // Extract separator: + sep := "" + if len(operands) == 2 { + if result.IsNull(operands[1]) { + return result.New(nil) + } + sep, err = result.ToString(operands[1]) + if err != nil { + return result.Value{}, err + } + } + + resultBuilder := strings.Builder{} + for idx, str := range strList { + if result.IsNull(str) { + continue + } + s, err := result.ToString(str) + if err != nil { + return result.Value{}, err + } + resultBuilder.WriteString(s) + + // Write the separator unless we are on the last element. + if idx < len(strList)-1 { + resultBuilder.WriteString(sep) + } + } + return result.New(resultBuilder.String()) +} + // convert a quantity value to a string func quantityToString(q result.Quantity) string { f := strconv.FormatFloat(q.Value, 'f', -1, 64) diff --git a/model/model.go b/model/model.go index a4c26fe..c1d24d1 100644 --- a/model/model.go +++ b/model/model.go @@ -1013,6 +1013,11 @@ type Coalesce struct{ *NaryExpression } // Concatenate is https://cql.hl7.org/04-logicalspecification.html#concatenate. type Concatenate struct{ *NaryExpression } +// Combine is https://cql.hl7.org/04-logicalspecification.html#combine. +// In ELM Combine is an OperatorExpression, but we're modeling it as a NaryExpression since in CQL +// it takes either 1 or 2 arguments. +type Combine struct{ *NaryExpression } + // Date is the functional syntax to create a Date https://cql.hl7.org/09-b-cqlreference.html#date-1. type Date struct{ *NaryExpression } @@ -1357,3 +1362,6 @@ func (a *Sum) GetName() string { return "Sum" } // GetName returns the name of the system operator. func (a *Split) GetName() string { return "Split" } + +// GetName returns the name of the system operator. +func (a *Combine) GetName() string { return "Combine" } diff --git a/parser/operators.go b/parser/operators.go index 34dca1e..b0ab8d5 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -891,6 +891,20 @@ func (p *Parser) loadSystemOperators() error { } }, }, + { + name: "Combine", + operands: [][]types.IType{ + {&types.List{ElementType: types.String}}, + {&types.List{ElementType: types.String}, types.String}, + }, + model: func() model.IExpression { + return &model.Combine{ + NaryExpression: &model.NaryExpression{ + Expression: model.ResultType(types.String), + }, + } + }, + }, // DATE AND TIME OPERATORS - https://cql.hl7.org/09-b-cqlreference.html#datetime-operators-2 { name: "Add", diff --git a/parser/operators_test.go b/parser/operators_test.go index 5d53331..009f170 100644 --- a/parser/operators_test.go +++ b/parser/operators_test.go @@ -642,6 +642,41 @@ func TestBuiltInFunctions(t *testing.T) { }, }, }, + { + name: "Combine({'1'})", + cql: "Combine({'1'})", + want: &model.Combine{ + NaryExpression: &model.NaryExpression{ + Operands: []model.IExpression{ + &model.List{ + Expression: model.ResultType(&types.List{ElementType: types.String}), + List: []model.IExpression{ + model.NewLiteral("1", types.String), + }, + }, + }, + Expression: model.ResultType(types.String), + }, + }, + }, + { + name: "Combine({'1'}, 'sep')", + cql: "Combine({'1'}, 'sep')", + want: &model.Combine{ + NaryExpression: &model.NaryExpression{ + Operands: []model.IExpression{ + &model.List{ + Expression: model.ResultType(&types.List{ElementType: types.String}), + List: []model.IExpression{ + model.NewLiteral("1", types.String), + }, + }, + model.NewLiteral("sep", types.String), + }, + Expression: model.ResultType(types.String), + }, + }, + }, // DATE AND TIME OPERATORS - https://cql.hl7.org/09-b-cqlreference.html#datetime-operators-2 { name: "After", diff --git a/tests/enginetests/operator_string_test.go b/tests/enginetests/operator_string_test.go index 30af360..cbde8a1 100644 --- a/tests/enginetests/operator_string_test.go +++ b/tests/enginetests/operator_string_test.go @@ -305,3 +305,87 @@ func TestSplit(t *testing.T) { }) } } + +func TestCombine(t *testing.T) { + tests := []struct { + name string + cql string + wantModel model.IExpression + wantResult result.Value + }{ + { + name: "Combine without separator", + cql: "Combine({'A', 'B'})", + wantModel: &model.Combine{ + NaryExpression: &model.NaryExpression{ + Operands: []model.IExpression{ + &model.List{ + Expression: model.ResultType(&types.List{ElementType: types.String}), + List: []model.IExpression{ + model.NewLiteral("A", types.String), + model.NewLiteral("B", types.String), + }, + }, + }, + Expression: model.ResultType(types.String), + }, + }, + wantResult: newOrFatal(t, "AB"), + }, + { + name: "Combine with separator", + cql: "Combine({'A', 'B'}, ':')", + wantResult: newOrFatal(t, "A:B"), + }, + { + name: "Combine with empty list is null", + cql: "Combine({})", + wantResult: newOrFatal(t, nil), + }, + { + name: "Combine with empty list and non-empty separator is null", + cql: "Combine({}, ':')", + wantResult: newOrFatal(t, nil), + }, + { + name: "Combine with null list is null", + cql: "Combine(null, ':')", + wantResult: newOrFatal(t, nil), + }, + { + name: "Combine with null separator is null", + cql: "Combine({'A', 'B'}, null)", + wantResult: newOrFatal(t, nil), + }, + { + name: "Combine skips null elements in input list", + cql: "Combine({'A', 'B', null, 'C'})", + wantResult: newOrFatal(t, "ABC"), + }, + { + name: "Combine with list of nulls", + cql: "Combine({null as String, null as String})", + wantResult: newOrFatal(t, ""), + }, + } + 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) + } + }) + } +} diff --git a/tests/spectests/exclusions/exclusions.go b/tests/spectests/exclusions/exclusions.go index 7a1ca35..dab163f 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -358,7 +358,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "CqlStringOperatorsTest.xml": XMLTestFileExclusions{ GroupExcludes: []string{ // TODO: b/342061715 - unsupported operators. - "Combine", "EndsWith", "Indexer", "LastPositionOf", @@ -375,6 +374,9 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { // TODO: b/346880550 - These test appear to have incorrect assertions. "DateTimeToString1", "DateTimeToString2", + // The spec test is incorrect, fix pending in + // https://github.com/cqframework/cql-tests/pull/35. + "CombineEmptyList", }, }, "CqlTypesTest.xml": XMLTestFileExclusions{