diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index af782da..111ed10 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -776,6 +776,13 @@ func (i *interpreter) unaryOverloads(m model.IUnaryExpression) ([]convert.Overlo Result: evalTail, }, }, nil + case *model.Descendants: + return []convert.Overload[evalUnarySignature]{ + { + Operands: []types.IType{types.Any}, + Result: i.evalDescendants, + }, + }, nil case *model.Upper: return []convert.Overload[evalUnarySignature]{ { diff --git a/interpreter/operator_list.go b/interpreter/operator_list.go index 719be47..6c34b3c 100644 --- a/interpreter/operator_list.go +++ b/interpreter/operator_list.go @@ -16,6 +16,7 @@ package interpreter import ( "fmt" + "sort" "github.com/google/cql/model" "github.com/google/cql/result" @@ -450,6 +451,137 @@ func evalTail(m model.IUnaryExpression, listObj result.Value) (result.Value, err return result.New(result.List{Value: list[1:], StaticType: staticType}) } +// Descendants(argument Any) List +// FHIRPath: .descendants() === Descendants(X) +// Returns every element below the operand as a flat list, in document order. For a list operand, +// the result is the flattened descendants of each element, never the elements themselves. +func (i *interpreter) evalDescendants(m model.IUnaryExpression, operand result.Value) (result.Value, error) { + if result.IsNull(operand) { + return result.New(nil) + } + descendants := []result.Value{} + for _, elem := range appendFlattened(nil, operand) { + if err := i.appendDescendants(elem, &descendants); err != nil { + return result.Value{}, err + } + } + return result.New(result.List{ + Value: descendants, + StaticType: &types.List{ElementType: types.Any}, + }) +} + +func (i *interpreter) appendDescendants(v result.Value, descendants *[]result.Value) error { + children, err := i.elementChildren(v) + if err != nil { + return err + } + for _, child := range children { + *descendants = append(*descendants, child) + if err := i.appendDescendants(child, descendants); err != nil { + return err + } + } + return nil +} + +// elementChildren returns the direct children of v. Lists are flattened and nulls dropped, so a +// list value itself is never a child. +func (i *interpreter) elementChildren(v result.Value) ([]result.Value, error) { + var children []result.Value + switch ot := v.GolangValue().(type) { + case result.Tuple: + keys := make([]string, 0, len(ot.Value)) + for k := range ot.Value { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + children = appendFlattened(children, ot.Value[k]) + } + case result.Quantity: + return systemChildren(ot.Value, string(ot.Unit)) + case result.Ratio: + return systemChildren(ot.Numerator, ot.Denominator) + case result.Code: + return systemChildren(ot.Code, ot.System, ot.Version, ot.Display) + case result.Concept: + for _, c := range ot.Codes { + if c == nil { + continue + } + child, err := result.New(*c) + if err != nil { + return nil, err + } + children = append(children, child) + } + if ot.Display != "" { + child, err := result.New(ot.Display) + if err != nil { + return nil, err + } + children = append(children, child) + } + case result.Named: + msg := ot.Value.ProtoReflect() + fields := msg.Descriptor().Fields() + for idx := 0; idx < fields.Len(); idx++ { + fd := fields.Get(idx) + if !msg.Has(fd) { + continue + } + property := fd.JSONName() + staticType, err := i.modelInfo.PropertyTypeSpecifier(ot.RuntimeType, property) + if err != nil { + // Proto-only fields (e.g. valueUs on FHIR.dateTime) are not FHIR elements. This also + // skips Reference.reference, a proto oneof the engine has no property support for. + continue + } + // System-typed elements are a primitive's own value or Element.id, not FHIR nodes. + if _, ok := staticType.(types.System); ok { + continue + } + child, err := i.protoProperty(ot, property, staticType) + if err != nil { + return nil, err + } + children = appendFlattened(children, child) + } + } + return children, nil +} + +// systemChildren returns the elements of a System structured type in declaration order. Empty +// strings are unset optional elements, so they are dropped like nulls. +func systemChildren(elems ...any) ([]result.Value, error) { + var children []result.Value + for _, e := range elems { + if s, ok := e.(string); ok && s == "" { + continue + } + child, err := result.New(e) + if err != nil { + return nil, err + } + children = append(children, child) + } + return children, nil +} + +func appendFlattened(values []result.Value, v result.Value) []result.Value { + if result.IsNull(v) { + return values + } + if l, ok := v.GolangValue().(result.List); ok { + for _, elem := range l.Value { + values = appendFlattened(values, elem) + } + return values + } + return append(values, v) +} + // Take(argument List, number Integer) List // https://cql.hl7.org/09-b-cqlreference.html#take // Removes the last n elements from the list. diff --git a/model/model.go b/model/model.go index 74d2eaf..602994e 100644 --- a/model/model.go +++ b/model/model.go @@ -915,6 +915,12 @@ var _ IUnaryExpression = &Duration{} // Tail ELM Expression https://cql.hl7.org/09-b-cqlreference.html#tail. type Tail struct{ *UnaryExpression } +// Descendants ELM expression from FHIRPath specification +// Returns all descendant elements of a given element in a hierarchical structure +type Descendants struct{ *UnaryExpression } + +var _ IUnaryExpression = &Descendants{} + // BinaryExpression is a CQL expression that has two operands. The ELM representation may have // additional operands (ex BinaryExpressionWithPrecision). type BinaryExpression struct { @@ -1658,6 +1664,9 @@ func (s *Skip) GetName() string { return "Skip" } // GetName returns the name of the system operator. func (t *Tail) GetName() string { return "Tail" } +// GetName returns the name of the system operator. +func (d *Descendants) GetName() string { return "Descendants" } + // GetName returns the name of the system operator. func (t *Take) GetName() string { return "Take" } diff --git a/parser/operators.go b/parser/operators.go index 1f616c2..2a8a2f9 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -1934,6 +1934,59 @@ func (p *Parser) loadSystemOperators() error { } }, }, + { + name: "Descendants", + operands: [][]types.IType{ + {types.Any}, + }, + model: func() model.IExpression { + return &model.Descendants{ + UnaryExpression: &model.UnaryExpression{ + Expression: model.ResultType(&types.List{ElementType: types.Any}), + }, + } + }, + }, + { + name: "Descendents", + operands: [][]types.IType{ + {types.Any}, + }, + model: func() model.IExpression { + return &model.Descendants{ + UnaryExpression: &model.UnaryExpression{ + Expression: model.ResultType(&types.List{ElementType: types.Any}), + }, + } + }, + }, + // Lowercase FHIRPath forms, https://cql.hl7.org/16-i-fhirpathtranslation.html. + { + name: "descendants", + operands: [][]types.IType{ + {types.Any}, + }, + model: func() model.IExpression { + return &model.Descendants{ + UnaryExpression: &model.UnaryExpression{ + Expression: model.ResultType(&types.List{ElementType: types.Any}), + }, + } + }, + }, + { + name: "descendents", + operands: [][]types.IType{ + {types.Any}, + }, + model: func() model.IExpression { + return &model.Descendants{ + UnaryExpression: &model.UnaryExpression{ + Expression: model.ResultType(&types.List{ElementType: types.Any}), + }, + } + }, + }, { name: "Take", operands: [][]types.IType{ diff --git a/tests/enginetests/operator_list_test.go b/tests/enginetests/operator_list_test.go index 113cd44..6fd327e 100644 --- a/tests/enginetests/operator_list_test.go +++ b/tests/enginetests/operator_list_test.go @@ -25,6 +25,10 @@ import ( "github.com/google/cql/parser" "github.com/google/cql/result" "github.com/google/cql/types" + c4pb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/codes_go_proto" + d4pb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/datatypes_go_proto" + r4pb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/bundle_and_contained_resource_go_proto" + r4observationpb "github.com/google/fhir/go/proto/google/fhir/proto/r4/core/resources/observation_go_proto" "github.com/google/go-cmp/cmp" "google.golang.org/protobuf/testing/protocmp" ) @@ -117,6 +121,199 @@ func TestExists(t *testing.T) { } } +func TestDescendants(t *testing.T) { + tests := []struct { + name string + cql string + resources []*r4pb.ContainedResource + wantResult result.Value + }{ + { + name: "Descendants of null returns null", + cql: "Descendants(null)", + wantResult: newOrFatal(t, nil), + }, + { + name: "Descendents spelling variant", + cql: "Descendents(null)", + wantResult: newOrFatal(t, nil), + }, + { + name: "Fluent syntax with capital D - descendants", + cql: "(null).Descendants()", + wantResult: newOrFatal(t, nil), + }, + { + name: "Fluent syntax with capital D - descendents", + cql: "(null).Descendents()", + wantResult: newOrFatal(t, nil), + }, + { + name: "Fluent syntax with lowercase d - descendants", + cql: "(null).descendants()", + wantResult: newOrFatal(t, nil), + }, + { + name: "Fluent syntax with lowercase d - descendents", + cql: "(null).descendents()", + wantResult: newOrFatal(t, nil), + }, + { + name: "Tuple elements in name order", + cql: "Descendants(Tuple{f: 6, b: 2, d: 4, a: 1, e: 5, c: 3})", + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{newOrFatal(t, int32(1)), newOrFatal(t, int32(2)), newOrFatal(t, int32(3)), newOrFatal(t, int32(4)), newOrFatal(t, int32(5)), newOrFatal(t, int32(6))}, + StaticType: &types.List{ElementType: types.Any}, + }), + }, + { + name: "List inside a tuple is flattened", + cql: "Descendants(Tuple{a: {1, 2}})", + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{newOrFatal(t, int32(1)), newOrFatal(t, int32(2))}, + StaticType: &types.List{ElementType: types.Any}, + }), + }, + { + name: "List operand returns descendants of each element, not the elements", + cql: "Descendants({Tuple{c: 1}, Tuple{c: 2}})", + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{newOrFatal(t, int32(1)), newOrFatal(t, int32(2))}, + StaticType: &types.List{ElementType: types.Any}, + }), + }, + { + name: "Nested list operand is flattened", + cql: "Descendants({Tuple{a: 1}, {Tuple{b: 2}}})", + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{newOrFatal(t, int32(1)), newOrFatal(t, int32(2))}, + StaticType: &types.List{ElementType: types.Any}, + }), + }, + { + name: "List of primitives has no descendants", + cql: "Descendants({1, {2, 3}})", + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{}, + StaticType: &types.List{ElementType: types.Any}, + }), + }, + { + name: "Tuple inside a tuple", + cql: "Descendants(Tuple{a: Tuple{b: 1}})", + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Tuple{ + Value: map[string]result.Value{"b": newOrFatal(t, int32(1))}, + RuntimeType: &types.Tuple{ElementTypes: map[string]types.IType{"b": types.Integer}}, + }), + newOrFatal(t, int32(1)), + }, + StaticType: &types.List{ElementType: types.Any}, + }), + }, + { + name: "Quantity descendants are its value and unit", + cql: "Descendants(5 'mg')", + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{newOrFatal(t, 5.0), newOrFatal(t, "mg")}, + StaticType: &types.List{ElementType: types.Any}, + }), + }, + { + name: "Ratio descendants are its quantities and their elements", + cql: "Descendants(1 'mg' : 2 'mL')", + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Quantity{Value: 1, Unit: "mg"}), newOrFatal(t, 1.0), newOrFatal(t, "mg"), + newOrFatal(t, result.Quantity{Value: 2, Unit: "mL"}), newOrFatal(t, 2.0), newOrFatal(t, "mL"), + }, + StaticType: &types.List{ElementType: types.Any}, + }), + }, + { + name: "Code descendants skip unset optional elements", + cql: "Descendants(Code { code: '123', system: 'http://example.com', display: 'd' })", + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{newOrFatal(t, "123"), newOrFatal(t, "http://example.com"), newOrFatal(t, "d")}, + StaticType: &types.List{ElementType: types.Any}, + }), + }, + { + name: "Concept descendants are its codes, their elements and its display", + cql: "Descendants(Concept { codes: { Code { code: '1', system: 's' } }, display: 'c' })", + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Code{Code: "1", System: "s"}), newOrFatal(t, "1"), newOrFatal(t, "s"), + newOrFatal(t, "c"), + }, + StaticType: &types.List{ElementType: types.Any}, + }), + }, + { + name: "FHIR resource elements in document order", + cql: "Descendants(First([Observation]))", + resources: []*r4pb.ContainedResource{containedFromObservation(&r4observationpb.Observation{ + Status: &r4observationpb.Observation_StatusCode{Value: c4pb.ObservationStatusCode_FINAL}, + Code: &d4pb.CodeableConcept{Coding: []*d4pb.Coding{{Code: &d4pb.Code{Value: "1234"}}}}, + Value: &r4observationpb.Observation_ValueX{Choice: &r4observationpb.Observation_ValueX_Integer{Integer: &d4pb.Integer{Value: 4}}}, + })}, + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Named{Value: &r4observationpb.Observation_StatusCode{Value: c4pb.ObservationStatusCode_FINAL}, RuntimeType: &types.Named{TypeName: "FHIR.ObservationStatus"}}), + newOrFatal(t, result.Named{Value: &d4pb.CodeableConcept{Coding: []*d4pb.Coding{{Code: &d4pb.Code{Value: "1234"}}}}, RuntimeType: &types.Named{TypeName: "FHIR.CodeableConcept"}}), + newOrFatal(t, result.Named{Value: &d4pb.Coding{Code: &d4pb.Code{Value: "1234"}}, RuntimeType: &types.Named{TypeName: "FHIR.Coding"}}), + newOrFatal(t, result.Named{Value: &d4pb.Code{Value: "1234"}, RuntimeType: &types.Named{TypeName: "FHIR.code"}}), + newOrFatal(t, result.Named{Value: &d4pb.Integer{Value: 4}, RuntimeType: &types.Named{TypeName: "FHIR.integer"}}), + }, + StaticType: &types.List{ElementType: types.Any}, + }), + }, + { + name: "Fluent descendants on a FHIR resource", + cql: "Count(First([Observation]).descendants())", + resources: []*r4pb.ContainedResource{containedFromObservation(&r4observationpb.Observation{ + Status: &r4observationpb.Observation_StatusCode{Value: c4pb.ObservationStatusCode_FINAL}, + Extension: []*d4pb.Extension{{ + Id: &d4pb.String{Value: "ext1"}, + Url: &d4pb.Uri{Value: "http://example.com/ext"}, + Value: &d4pb.Extension_ValueX{Choice: &d4pb.Extension_ValueX_StringValue{StringValue: &d4pb.String{Value: "x"}}}, + }}, + })}, + wantResult: newOrFatal(t, 4), + }, + { + name: "Retrieve operand excludes the resources themselves", + cql: "Count(Descendants([Observation]))", + resources: []*r4pb.ContainedResource{containedFromObservation(&r4observationpb.Observation{ + Status: &r4observationpb.Observation_StatusCode{Value: c4pb.ObservationStatusCode_FINAL}, + })}, + wantResult: newOrFatal(t, 1), + }, + } + 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) + } + + config := defaultInterpreterConfig(t, p) + if tc.resources != nil { + config.Retriever = newRetrieverFromProtosOrFatal(t, tc.resources) + } + results, err := interpreter.Eval(context.Background(), parsedLibs, config) + 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 TestInList(t *testing.T) { tests := []struct { name string diff --git a/tests/spectests/exclusions/exclusions.go b/tests/spectests/exclusions/exclusions.go index 50d255e..d05bb60 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -316,7 +316,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "CqlListOperatorsTest.xml": { GroupExcludes: []string{ // TODO: b/342061715 - unsupported operators. - "Descendents", }, NamesExcludes: []string{ // TODO: b/342061715 - unsupported operator.