From 62ef906b6bf3d4874dd32bd0d5c869306b6d9e6f Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Tue, 1 Jul 2025 12:28:04 -0500 Subject: [PATCH 1/5] #152 add support for Descendants and Descendents operator --- interpreter/operator_dispatcher.go | 7 ++ interpreter/operator_list.go | 69 ++++++++++++ model/model.go | 9 ++ parser/operators.go | 55 ++++++++++ tests/enginetests/operator_list_test.go | 128 +++++++++++++++++++++++ tests/spectests/exclusions/exclusions.go | 1 - 6 files changed, 268 insertions(+), 1 deletion(-) diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index af782da..5f31ebe 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: evalDescendants, + }, + }, nil case *model.Upper: return []convert.Overload[evalUnarySignature]{ { diff --git a/interpreter/operator_list.go b/interpreter/operator_list.go index 719be47..350c59b 100644 --- a/interpreter/operator_list.go +++ b/interpreter/operator_list.go @@ -450,6 +450,75 @@ 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 +// Returns all descendant elements of a given element in a hierarchical structure +// From FHIRPath specification: .descendants(X) === Descendants(X) +func evalDescendants(m model.IUnaryExpression, operand result.Value) (result.Value, error) { + if result.IsNull(operand) { + return result.New(nil) + } + + var allDescendants []result.Value + + // Get immediate children of the operand + children, err := getElementChildren(operand) + if err != nil { + return result.Value{}, err + } + + // For each child, add it and recursively get its descendants + for _, child := range children { + allDescendants = append(allDescendants, child) + + // Recursively get descendants of this child + childDescendants, err := evalDescendants(m, child) + if err != nil { + return result.Value{}, err + } + + if !result.IsNull(childDescendants) { + if list, ok := childDescendants.GolangValue().(result.List); ok { + allDescendants = append(allDescendants, list.Value...) + } + } + } + + return result.New(result.List{ + Value: allDescendants, + StaticType: &types.List{ElementType: types.Any}, + }) +} + +// Helper function to get immediate children of an element +func getElementChildren(element result.Value) ([]result.Value, error) { + switch ot := element.GolangValue().(type) { + case result.Named: + // For FHIR resources, get all property values + return getNamedElementChildren(ot) + case result.List: + // For lists, return all elements + return ot.Value, nil + case result.Tuple: + // For tuples, return all values + var children []result.Value + for _, value := range ot.Value { + children = append(children, value) + } + return children, nil + default: + // For primitive types, no children + return []result.Value{}, nil + } +} + +// Helper function to get children from named elements (FHIR resources) +func getNamedElementChildren(named result.Named) ([]result.Value, error) { + // For now, return empty list for Named types (FHIR resources) + // TODO: Implement proper proto message traversal using reflection + // to extract all field values as children + return []result.Value{}, nil +} + // 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..fb6d1b8 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -1934,6 +1934,61 @@ 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}), + }, + } + }, + }, + // Fluent functions are defined as lowercase, not sure + // the best way to handle this for all the fluent functions + // but this is for the spectests which is lowercase according to the CQL spec + { + 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..8e64dbc 100644 --- a/tests/enginetests/operator_list_test.go +++ b/tests/enginetests/operator_list_test.go @@ -117,6 +117,134 @@ func TestExists(t *testing.T) { } } +func TestDescendants(t *testing.T) { + tests := []struct { + name string + cql string + 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), + }, + } + 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) + } + + 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 TestFluentFunctionCaseSensitivity(t *testing.T) { + tests := []struct { + name string + cql string + wantResult result.Value + }{ + // Test that fluent functions are case-sensitive + { + name: "Add fluent - capital A", + cql: "4.Add(4)", + wantResult: newOrFatal(t, 8), + }, + { + name: "IsNull fluent - capital I and N", + cql: "(null).IsNull()", + wantResult: newOrFatal(t, true), + }, + { + name: "ToString fluent - capital T and S", + cql: "42.ToString()", + wantResult: newOrFatal(t, "42"), + }, + { + name: "Length fluent - capital L", + cql: "{1, 2, 3}.Length()", + wantResult: newOrFatal(t, 3), + }, + { + name: "First fluent - capital F", + cql: "{1, 2, 3}.First()", + wantResult: newOrFatal(t, 1), + }, + { + name: "Last fluent - capital L", + cql: "{1, 2, 3}.Last()", + wantResult: newOrFatal(t, 3), + }, + { + name: "Exists fluent - capital E", + cql: "{1, 2, 3}.Exists()", + wantResult: newOrFatal(t, true), + }, + { + name: "Distinct fluent - capital D", + cql: "{1, 1, 2}.Distinct()", + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{newOrFatal(t, int32(1)), newOrFatal(t, int32(2))}, + StaticType: &types.List{ElementType: types.Integer}, + }), + }, + } + 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) + } + + 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 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. From 9eff81bb48c2240d7bc25492844ce89980715638 Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Sun, 11 Oct 2026 10:57:49 -0400 Subject: [PATCH 2/5] #152 gofmt operator_list.go and operator_list_test.go --- interpreter/operator_list.go | 12 ++++++------ tests/enginetests/operator_list_test.go | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/interpreter/operator_list.go b/interpreter/operator_list.go index 350c59b..26bf5d4 100644 --- a/interpreter/operator_list.go +++ b/interpreter/operator_list.go @@ -457,32 +457,32 @@ func evalDescendants(m model.IUnaryExpression, operand result.Value) (result.Val if result.IsNull(operand) { return result.New(nil) } - + var allDescendants []result.Value - + // Get immediate children of the operand children, err := getElementChildren(operand) if err != nil { return result.Value{}, err } - + // For each child, add it and recursively get its descendants for _, child := range children { allDescendants = append(allDescendants, child) - + // Recursively get descendants of this child childDescendants, err := evalDescendants(m, child) if err != nil { return result.Value{}, err } - + if !result.IsNull(childDescendants) { if list, ok := childDescendants.GolangValue().(result.List); ok { allDescendants = append(allDescendants, list.Value...) } } } - + return result.New(result.List{ Value: allDescendants, StaticType: &types.List{ElementType: types.Any}, diff --git a/tests/enginetests/operator_list_test.go b/tests/enginetests/operator_list_test.go index 8e64dbc..fae2ae8 100644 --- a/tests/enginetests/operator_list_test.go +++ b/tests/enginetests/operator_list_test.go @@ -217,8 +217,8 @@ func TestFluentFunctionCaseSensitivity(t *testing.T) { wantResult: newOrFatal(t, true), }, { - name: "Distinct fluent - capital D", - cql: "{1, 1, 2}.Distinct()", + name: "Distinct fluent - capital D", + cql: "{1, 1, 2}.Distinct()", wantResult: newOrFatal(t, result.List{ Value: []result.Value{newOrFatal(t, int32(1)), newOrFatal(t, int32(2))}, StaticType: &types.List{ElementType: types.Integer}, From 00c874dabdcf9f9d5e2dc5c19e2cae62099c671f Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Sun, 11 Oct 2026 11:11:26 -0400 Subject: [PATCH 3/5] #152 address review comments: traverse FHIR elements, flatten lists, stable tuple order in Descendants --- interpreter/operator_dispatcher.go | 2 +- interpreter/operator_list.go | 118 +++++++++++++--------- parser/operators.go | 4 +- tests/enginetests/operator_list_test.go | 129 +++++++++++++----------- 4 files changed, 139 insertions(+), 114 deletions(-) diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index 5f31ebe..111ed10 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -780,7 +780,7 @@ func (i *interpreter) unaryOverloads(m model.IUnaryExpression) ([]convert.Overlo return []convert.Overload[evalUnarySignature]{ { Operands: []types.IType{types.Any}, - Result: evalDescendants, + Result: i.evalDescendants, }, }, nil case *model.Upper: diff --git a/interpreter/operator_list.go b/interpreter/operator_list.go index 26bf5d4..b408f9e 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" @@ -451,72 +452,91 @@ func evalTail(m model.IUnaryExpression, listObj result.Value) (result.Value, err } // Descendants(argument Any) List -// Returns all descendant elements of a given element in a hierarchical structure -// From FHIRPath specification: .descendants(X) === Descendants(X) -func evalDescendants(m model.IUnaryExpression, operand result.Value) (result.Value, error) { +// FHIRPath: .descendants() === Descendants(X) +// Returns every element below the operand as a flat list, in document order. +func (i *interpreter) evalDescendants(m model.IUnaryExpression, operand result.Value) (result.Value, error) { if result.IsNull(operand) { return result.New(nil) } - - var allDescendants []result.Value - - // Get immediate children of the operand - children, err := getElementChildren(operand) - if err != nil { + descendants := []result.Value{} + if err := i.appendDescendants(operand, &descendants); err != nil { return result.Value{}, err } + return result.New(result.List{ + Value: descendants, + StaticType: &types.List{ElementType: types.Any}, + }) +} - // For each child, add it and recursively get its descendants +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 { - allDescendants = append(allDescendants, child) - - // Recursively get descendants of this child - childDescendants, err := evalDescendants(m, child) - if err != nil { - return result.Value{}, err - } - - if !result.IsNull(childDescendants) { - if list, ok := childDescendants.GolangValue().(result.List); ok { - allDescendants = append(allDescendants, list.Value...) - } + *descendants = append(*descendants, child) + if err := i.appendDescendants(child, descendants); err != nil { + return err } } - - return result.New(result.List{ - Value: allDescendants, - StaticType: &types.List{ElementType: types.Any}, - }) + return nil } -// Helper function to get immediate children of an element -func getElementChildren(element result.Value) ([]result.Value, error) { - switch ot := element.GolangValue().(type) { - case result.Named: - // For FHIR resources, get all property values - return getNamedElementChildren(ot) +// 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.List: - // For lists, return all elements - return ot.Value, nil + children = appendFlattened(children, v) case result.Tuple: - // For tuples, return all values - var children []result.Value - for _, value := range ot.Value { - children = append(children, value) + 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.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. + 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 - default: - // For primitive types, no children - return []result.Value{}, nil } + return children, nil } -// Helper function to get children from named elements (FHIR resources) -func getNamedElementChildren(named result.Named) ([]result.Value, error) { - // For now, return empty list for Named types (FHIR resources) - // TODO: Implement proper proto message traversal using reflection - // to extract all field values as children - return []result.Value{}, 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 diff --git a/parser/operators.go b/parser/operators.go index fb6d1b8..2a8a2f9 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -1960,9 +1960,7 @@ func (p *Parser) loadSystemOperators() error { } }, }, - // Fluent functions are defined as lowercase, not sure - // the best way to handle this for all the fluent functions - // but this is for the spectests which is lowercase according to the CQL spec + // Lowercase FHIRPath forms, https://cql.hl7.org/16-i-fhirpathtranslation.html. { name: "descendants", operands: [][]types.IType{ diff --git a/tests/enginetests/operator_list_test.go b/tests/enginetests/operator_list_test.go index fae2ae8..6894e47 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" ) @@ -121,6 +125,7 @@ func TestDescendants(t *testing.T) { tests := []struct { name string cql string + resources []*r4pb.ContainedResource wantResult result.Value }{ { @@ -153,77 +158,76 @@ func TestDescendants(t *testing.T) { cql: "(null).descendents()", wantResult: newOrFatal(t, nil), }, - } - 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) - } - - 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 TestFluentFunctionCaseSensitivity(t *testing.T) { - tests := []struct { - name string - cql string - wantResult result.Value - }{ - // Test that fluent functions are case-sensitive - { - name: "Add fluent - capital A", - cql: "4.Add(4)", - wantResult: newOrFatal(t, 8), - }, - { - name: "IsNull fluent - capital I and N", - cql: "(null).IsNull()", - wantResult: newOrFatal(t, true), - }, { - name: "ToString fluent - capital T and S", - cql: "42.ToString()", - wantResult: newOrFatal(t, "42"), - }, - { - name: "Length fluent - capital L", - cql: "{1, 2, 3}.Length()", - wantResult: newOrFatal(t, 3), + 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: "First fluent - capital F", - cql: "{1, 2, 3}.First()", - wantResult: newOrFatal(t, 1), + 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: "Last fluent - capital L", - cql: "{1, 2, 3}.Last()", - wantResult: newOrFatal(t, 3), + name: "Nested lists are flattened", + cql: "Descendants({1, {2, 3}})", + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{newOrFatal(t, int32(1)), newOrFatal(t, int32(2)), newOrFatal(t, int32(3))}, + StaticType: &types.List{ElementType: types.Any}, + }), }, { - name: "Exists fluent - capital E", - cql: "{1, 2, 3}.Exists()", - wantResult: newOrFatal(t, true), + 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: "Distinct fluent - capital D", - cql: "{1, 1, 2}.Distinct()", + 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, int32(1)), newOrFatal(t, int32(2))}, - StaticType: &types.List{ElementType: types.Integer}, + 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), + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -233,14 +237,17 @@ func TestFluentFunctionCaseSensitivity(t *testing.T) { t.Fatalf("Parse returned unexpected error: %v", err) } - results, err := interpreter.Eval(context.Background(), parsedLibs, defaultInterpreterConfig(t, p)) + 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) } - }) } } From 0aa7b1bba58938e3f34d2eeb3bc19ca332ced179 Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Sun, 11 Oct 2026 11:23:06 -0400 Subject: [PATCH 4/5] #152 address review comments: Descendants of a list returns each element's descendants --- interpreter/operator_list.go | 14 +++++++------ tests/enginetests/operator_list_test.go | 28 +++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/interpreter/operator_list.go b/interpreter/operator_list.go index b408f9e..f07c85a 100644 --- a/interpreter/operator_list.go +++ b/interpreter/operator_list.go @@ -453,14 +453,17 @@ func evalTail(m model.IUnaryExpression, listObj result.Value) (result.Value, err // Descendants(argument Any) List // FHIRPath: .descendants() === Descendants(X) -// Returns every element below the operand as a flat list, in document order. +// 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{} - if err := i.appendDescendants(operand, &descendants); err != nil { - return result.Value{}, err + 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, @@ -487,8 +490,6 @@ func (i *interpreter) appendDescendants(v result.Value, descendants *[]result.Va func (i *interpreter) elementChildren(v result.Value) ([]result.Value, error) { var children []result.Value switch ot := v.GolangValue().(type) { - case result.List: - children = appendFlattened(children, v) case result.Tuple: keys := make([]string, 0, len(ot.Value)) for k := range ot.Value { @@ -509,7 +510,8 @@ func (i *interpreter) elementChildren(v result.Value) ([]result.Value, error) { 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. + // 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. diff --git a/tests/enginetests/operator_list_test.go b/tests/enginetests/operator_list_test.go index 6894e47..b392c70 100644 --- a/tests/enginetests/operator_list_test.go +++ b/tests/enginetests/operator_list_test.go @@ -175,10 +175,26 @@ func TestDescendants(t *testing.T) { }), }, { - name: "Nested lists are flattened", + 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{newOrFatal(t, int32(1)), newOrFatal(t, int32(2)), newOrFatal(t, int32(3))}, + Value: []result.Value{}, StaticType: &types.List{ElementType: types.Any}, }), }, @@ -228,6 +244,14 @@ func TestDescendants(t *testing.T) { })}, 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) { From 7f4d1a322bfbf098fb98a29a72d009e52ee23b55 Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Sun, 11 Oct 2026 12:07:48 -0400 Subject: [PATCH 5/5] #152 address review comments: Descendants returns the elements of System structured types Code, Concept, Quantity and Ratio are structured types in the CQL reference, so Descendants now returns their elements in declaration order instead of treating them as leaves. Unset optional string elements are skipped like nulls. --- interpreter/operator_list.go | 41 +++++++++++++++++++++++++ tests/enginetests/operator_list_test.go | 38 +++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/interpreter/operator_list.go b/interpreter/operator_list.go index f07c85a..6c34b3c 100644 --- a/interpreter/operator_list.go +++ b/interpreter/operator_list.go @@ -499,6 +499,30 @@ func (i *interpreter) elementChildren(v result.Value) ([]result.Value, error) { 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() @@ -528,6 +552,23 @@ func (i *interpreter) elementChildren(v result.Value) ([]result.Value, error) { 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 diff --git a/tests/enginetests/operator_list_test.go b/tests/enginetests/operator_list_test.go index b392c70..6fd327e 100644 --- a/tests/enginetests/operator_list_test.go +++ b/tests/enginetests/operator_list_test.go @@ -212,6 +212,44 @@ func TestDescendants(t *testing.T) { 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]))",