diff --git a/interpreter/operator_comparison.go b/interpreter/operator_comparison.go index cb0e8cc..da9c9bc 100644 --- a/interpreter/operator_comparison.go +++ b/interpreter/operator_comparison.go @@ -100,6 +100,38 @@ func (i *interpreter) evalEquivalentValue(lObj, rObj result.Value) (result.Value return innerEquivalentFunc(nil, lObj, rObj) } +// equivalentGolang attempts to apply the CQL equivalent operator to the passed Golang values, +// and returns a Golang bool. +// It first attempts to convert them to result.Value by calling result.New, then calls +// evalEquivalentValue. +func (i *interpreter) equivalentGolang(lObj, rObj any) (bool, error) { + lVal, rVal, err := convertToValues(lObj, rObj) + if err != nil { + return false, err + } + equi, err := i.evalEquivalentValue(lVal, rVal) + if err != nil { + return false, err + } + equiBool, err := result.ToBool(equi) + if err != nil { + return false, err + } + return equiBool, nil +} + +func convertToValues(l, r any) (result.Value, result.Value, error) { + lVal, err := result.New(l) + if err != nil { + return result.Value{}, result.Value{}, err + } + rVal, err := result.New(r) + if err != nil { + return result.Value{}, result.Value{}, err + } + return lVal, rVal, nil +} + // ~(left List, right List) Boolean // All equivalent overloads should be resilient to a nil model. // https://cql.hl7.org/09-b-cqlreference.html#equivalent-2 @@ -258,6 +290,81 @@ func evalEquivalentDateTime(_ model.IBinaryExpression, lObj, rObj result.Value) } } +// ~(left Concept, right Code) Boolean +// https://cql.hl7.org/09-b-cqlreference.html#equivalent-3 +// Some Equivalent overloads are categorized in the clinical operator section, like this one, but +// are included in operator_comparison.go to keep all equivalent overloads together. +func (i *interpreter) evalEquivalentConceptCode(b model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + if result.IsNull(lObj) && result.IsNull(rObj) { + return result.New(true) + } + if result.IsNull(lObj) != result.IsNull(rObj) { + return result.New(false) + } + + con, err := result.ToConcept(lObj) + if err != nil { + return result.Value{}, err + } + + // Sanity check right hand type. + _, err = result.ToCode(rObj) + if err != nil { + return result.Value{}, err + } + + for _, conCode := range con.Codes { + conCodeObj, err := result.New(conCode) + if err != nil { + return result.Value{}, err + } + equi, err := i.evalEquivalentValue(conCodeObj, rObj) + if err != nil { + return result.Value{}, err + } + equiBool, err := result.ToBool(equi) + if err != nil { + return result.Value{}, err + } + if equiBool { + return result.New(true) + } + } + return result.New(false) +} + +func (i *interpreter) evalEquivalentCodeCode(b model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + if result.IsNull(lObj) && result.IsNull(rObj) { + return result.New(true) + } + if result.IsNull(lObj) != result.IsNull(rObj) { + return result.New(false) + } + + lCode, rCode, err := applyToValues(lObj, rObj, result.ToCode) + if err != nil { + return result.Value{}, err + } + + // Codes are equivalent if the system and codes are equivalent. + codesEqui, err := i.equivalentGolang(lCode.Code, rCode.Code) + if err != nil { + return result.Value{}, err + } + if !codesEqui { + return result.New(false) + } + + systemsEqui, err := i.equivalentGolang(lCode.System, rCode.System) + if err != nil { + return result.Value{}, err + } + if !systemsEqui { + return result.New(false) + } + return result.New(true) +} + // op(left Integer, right Integer) Boolean // https://cql.hl7.org/09-b-cqlreference.html#less // https://cql.hl7.org/09-b-cqlreference.html#less-or-equal @@ -361,19 +468,3 @@ func compare[n cmp.Ordered](m model.IBinaryExpression, l, r n) (result.Value, er } return result.Value{}, fmt.Errorf("internal error - unsupported Binary Comparison Expression %v", m) } - -// ~(left Code, right Code) Boolean -// https://cql.hl7.org/09-b-cqlreference.html#equivalent -func evalEquivalentCode(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { - if result.IsNull(lObj) && result.IsNull(rObj) { - return result.New(true) - } - if result.IsNull(lObj) != result.IsNull(rObj) { - return result.New(false) - } - - lc := lObj.GolangValue().(result.Code) - rc := rObj.GolangValue().(result.Code) - eq := lc.Code == rc.Code && lc.System == rc.System - return result.New(eq) -} diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index 5b098f1..a332a9c 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -647,10 +647,6 @@ func (i *interpreter) binaryOverloads(m model.IBinaryExpression) ([]convert.Over Operands: []types.IType{types.String, types.String}, Result: evalEquivalentString, }, - { - Operands: []types.IType{types.Code, types.Code}, - Result: evalEquivalentCode, - }, { Operands: []types.IType{types.DateTime, types.DateTime}, Result: evalEquivalentDateTime, @@ -669,6 +665,14 @@ func (i *interpreter) binaryOverloads(m model.IBinaryExpression) ([]convert.Over Operands: []types.IType{&types.Interval{PointType: types.Any}, &types.Interval{PointType: types.Any}}, Result: i.evalEquivalentInterval, }, + { + Operands: []types.IType{types.Concept, types.Code}, + Result: i.evalEquivalentConceptCode, + }, + { + Operands: []types.IType{types.Code, types.Code}, + Result: i.evalEquivalentCodeCode, + }, }, nil case *model.Less, *model.LessOrEqual, *model.Greater, *model.GreaterOrEqual: return []convert.Overload[evalBinarySignature]{ diff --git a/parser/operators.go b/parser/operators.go index e2a8f13..34dca1e 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -514,6 +514,9 @@ func (p *Parser) loadSystemOperators() error { name: "Equivalent", operands: [][]types.IType{ {convert.GenericType, convert.GenericType}, + // The following overloads come from + // CLINICAL OPERATORS - https://cql.hl7.org/09-b-cqlreference.html#equivalent-3 + {types.Concept, types.Code}, }, model: func() model.IExpression { return &model.Equivalent{ diff --git a/parser/operators_test.go b/parser/operators_test.go index 3b51ec9..5d53331 100644 --- a/parser/operators_test.go +++ b/parser/operators_test.go @@ -381,6 +381,47 @@ func TestBuiltInFunctions(t *testing.T) { }, }, }, + { + name: "Equivalent(Concept, Code)", + cql: "Equivalent(Concept { codes: { Code { system: 'http://example.com', code: '1' } } }, Code { system: 'http://example.com', code: '1' })", + want: &model.Equivalent{ + BinaryExpression: &model.BinaryExpression{ + Operands: []model.IExpression{ + &model.Instance{ + Expression: model.ResultType(types.Concept), + ClassType: types.Concept, + Elements: []*model.InstanceElement{ + &model.InstanceElement{ + Name: "codes", + Value: &model.List{ + Expression: model.ResultType(&types.List{ElementType: types.Code}), + List: []model.IExpression{ + &model.Instance{ + Expression: model.ResultType(types.Code), + ClassType: types.Code, + Elements: []*model.InstanceElement{ + &model.InstanceElement{Name: "system", Value: model.NewLiteral("http://example.com", types.String)}, + &model.InstanceElement{Name: "code", Value: model.NewLiteral("1", types.String)}, + }, + }, + }, + }, + }, + }, + }, + &model.Instance{ + Expression: model.ResultType(types.Code), + ClassType: types.Code, + Elements: []*model.InstanceElement{ + &model.InstanceElement{Name: "system", Value: model.NewLiteral("http://example.com", types.String)}, + &model.InstanceElement{Name: "code", Value: model.NewLiteral("1", types.String)}, + }, + }, + }, + Expression: model.ResultType(types.Boolean), + }, + }, + }, { name: "Less", cql: "Less(5, 5)", diff --git a/tests/enginetests/operator_comparison_test.go b/tests/enginetests/operator_comparison_test.go index 37dd208..f473764 100644 --- a/tests/enginetests/operator_comparison_test.go +++ b/tests/enginetests/operator_comparison_test.go @@ -602,7 +602,7 @@ func TestEquivalentCodes(t *testing.T) { name: `null ~ Code`, cql: dedent.Dedent(` codesystem cs: 'https://example.com/cs/diagnosis' version '1.0' - define TESTRESULT: null ~ Code 'code1' from "cs" display 'display1'`), + define TESTRESULT: null as Code ~ Code 'code1' from "cs" display 'display1'`), wantModel: &model.Equivalent{ BinaryExpression: &model.BinaryExpression{ Operands: []model.IExpression{ @@ -632,6 +632,17 @@ func TestEquivalentCodes(t *testing.T) { define TESTRESULT: Code 'code1' from "cs" display 'display1' ~ Code 'code1' from "cs" display 'display1'`), wantResult: newOrFatal(t, true), }, + { + name: `Equivalent codes uses string equivalency for codes`, + cql: dedent.Dedent(`define TESTRESULT: Code { system: 'system1', code: '1\t1' } ~ Code { system: 'system1', code: '1 1' }`), + wantResult: newOrFatal(t, true), + }, + { + name: `Equivalent codes uses string equivalency for system`, + cql: dedent.Dedent(` + define TESTRESULT: Code { system: 'system 1', code: '1' } ~ Code { system: 'system\t1', code: '1' }`), + wantResult: newOrFatal(t, true), + }, { name: `Codes with different displays still true`, cql: dedent.Dedent(` @@ -728,6 +739,116 @@ func TestNotEquivalentCodes(t *testing.T) { } } +func TestEquivalentConceptCode(t *testing.T) { + tests := []struct { + name string + cql string + wantModel model.IExpression + wantResult result.Value + }{ + // (Concept, Code) equivalency tests, per + // https://cql.hl7.org/09-b-cqlreference.html#equivalent-3 + { + name: `Equivalent Concept and Code`, + cql: "define TESTRESULT: Equivalent(Concept { codes: { Code { system: 'http://example.com', code: '1' } } }, Code { system: 'http://example.com', code: '1' })", + wantModel: &model.Equivalent{ + BinaryExpression: &model.BinaryExpression{ + Operands: []model.IExpression{ + &model.Instance{ + Expression: model.ResultType(types.Concept), + ClassType: types.Concept, + Elements: []*model.InstanceElement{ + &model.InstanceElement{ + Name: "codes", + Value: &model.List{ + Expression: model.ResultType(&types.List{ElementType: types.Code}), + List: []model.IExpression{ + &model.Instance{ + Expression: model.ResultType(types.Code), + ClassType: types.Code, + Elements: []*model.InstanceElement{ + &model.InstanceElement{Name: "system", Value: model.NewLiteral("http://example.com", types.String)}, + &model.InstanceElement{Name: "code", Value: model.NewLiteral("1", types.String)}, + }, + }, + }, + }, + }, + }, + }, + &model.Instance{ + Expression: model.ResultType(types.Code), + ClassType: types.Code, + Elements: []*model.InstanceElement{ + &model.InstanceElement{Name: "system", Value: model.NewLiteral("http://example.com", types.String)}, + &model.InstanceElement{Name: "code", Value: model.NewLiteral("1", types.String)}, + }, + }, + }, + Expression: model.ResultType(types.Boolean), + }, + }, + wantResult: newOrFatal(t, true), + }, + { + name: "Equivalent with ~ operator", + cql: "define TESTRESULT: Concept { codes: { Code { system: 'http://example.com', code: '1' } } } ~ Code { system: 'http://example.com', code: '1' }", + wantResult: newOrFatal(t, true), + }, + { + name: "Equivalent where Concept has multiple codes", + cql: "define TESTRESULT: Equivalent(Concept { codes: { Code { system: 'http://example.com', code: '1' }, Code { system: 'http://example.com', code: '2' } } }, Code { system: 'http://example.com', code: '1' })", + wantResult: newOrFatal(t, true), + }, + { + name: "Equivalent uses string equivalency for code comparison", + cql: "define TESTRESULT: Equivalent(Concept { codes: { Code { system: 'http://example.com', code: '1 1' } } }, Code { system: 'http://example.com', code: '1\t1' })", + wantResult: newOrFatal(t, true), + }, + { + name: "Not Equivalent", + cql: "define TESTRESULT: Equivalent(Concept { codes: { Code { system: 'http://example.com', code: '1' } } }, Code { system: 'http://example.com', code: '2' })", + wantResult: newOrFatal(t, false), + }, + { + name: "Equivalent(null, null)", + cql: "define TESTRESULT: Equivalent(null as Concept, null as Code)", + wantResult: newOrFatal(t, true), + }, + { + name: "Equivalent(null, Code)", + cql: "define TESTRESULT: Equivalent(null as Concept, Code { system: 'http://example.com', code: '1' })", + wantResult: newOrFatal(t, false), + }, + { + name: "Equivalent(Concept, null)", + cql: "define TESTRESULT: Equivalent(Concept { codes: { Code { system: 'http://example.com', code: '1' } } }, null as Code)", + wantResult: newOrFatal(t, false), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := newFHIRParser(t) + parsedLibs, err := p.Libraries(context.Background(), []string{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 TestGreater(t *testing.T) { tests := []struct { name string