From 76d60890cb0a6b09aa5f2f505ca8611f94d73cbf Mon Sep 17 00:00:00 2001 From: Ryan Brush Date: Thu, 5 Sep 2024 13:31:11 -0700 Subject: [PATCH] Add support for sorting by expressions in CQL. This change adds support for sorting by expressions in CQL. This is done by adding a new model type, SortByExpression, which represents an expression that can be used to sort the results of a query. The parser is updated to parse sort by expressions, and the interpreter is updated to evaluate them. PiperOrigin-RevId: 671490955 --- cql_test.go | 4 + internal/reference/reference.go | 30 ++++++++ internal/reference/reference_test.go | 51 +++++++++++++ interpreter/expressions.go | 20 +++++ interpreter/query.go | 73 +++++++++++-------- model/model.go | 21 +++++- parser/expressions.go | 21 ++++++ parser/query.go | 40 +++++++--- parser/query_test.go | 44 +++++++++++ tests/enginetests/query_test.go | 72 +++++++++++++++++- .../enginetests/testdata/patient_bundle.json | 25 ++++++- 11 files changed, 353 insertions(+), 48 deletions(-) diff --git a/cql_test.go b/cql_test.go index c223b2a..30ecf10 100644 --- a/cql_test.go +++ b/cql_test.go @@ -63,6 +63,7 @@ func TestCQL(t *testing.T) { retriever: enginetests.BuildRetriever(t), wantResult: newOrFatal(t, result.List{Value: []result.Value{ newOrFatal(t, result.Named{Value: enginetests.RetrieveFHIRResource(t, "Encounter", "1"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), + newOrFatal(t, result.Named{Value: enginetests.RetrieveFHIRResource(t, "Encounter", "2"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), }, StaticType: &types.List{ElementType: &types.Named{TypeName: "FHIR.Encounter"}}, }), @@ -84,6 +85,7 @@ func TestCQL(t *testing.T) { wantSourceValues: []result.Value{ newOrFatal(t, result.List{Value: []result.Value{ newOrFatal(t, result.Named{Value: enginetests.RetrieveFHIRResource(t, "Encounter", "1"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), + newOrFatal(t, result.Named{Value: enginetests.RetrieveFHIRResource(t, "Encounter", "2"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), }, StaticType: &types.List{ElementType: &types.Named{TypeName: "FHIR.Encounter"}}, }), @@ -302,6 +304,7 @@ func TestCQL_MultipleEvals(t *testing.T) { wantResult: newOrFatal(t, result.List{ Value: []result.Value{ newOrFatal(t, result.Named{Value: enginetests.RetrieveFHIRResource(t, "Encounter", "1"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), + newOrFatal(t, result.Named{Value: enginetests.RetrieveFHIRResource(t, "Encounter", "2"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), }, StaticType: &types.List{ElementType: &types.Named{TypeName: "FHIR.Encounter"}}, }), @@ -324,6 +327,7 @@ func TestCQL_MultipleEvals(t *testing.T) { newOrFatal(t, result.List{ Value: []result.Value{ newOrFatal(t, result.Named{Value: enginetests.RetrieveFHIRResource(t, "Encounter", "1"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), + newOrFatal(t, result.Named{Value: enginetests.RetrieveFHIRResource(t, "Encounter", "2"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), }, StaticType: &types.List{ElementType: &types.Named{TypeName: "FHIR.Encounter"}}, }), diff --git a/internal/reference/reference.go b/internal/reference/reference.go index 716e77b..780e46d 100644 --- a/internal/reference/reference.go +++ b/internal/reference/reference.go @@ -56,6 +56,11 @@ type Resolver[T any, F any] struct { // defined. Aliases live in the same namespace as definitions. aliases []map[aliasKey]T + // scopedStructs hold the struct that are currently in scope for evaluation. For instance, + // an an expression like `[Encounter] O sort by start of period` places each encounter in scope, + // for the sorting criteria, and `period` is resolved against that encounter struct. + scopedStructs []T + // libs holds the qualified identifier of all named libraries that have been parsed. libs map[namedLibKey]struct{} @@ -405,6 +410,31 @@ func (r *Resolver[T, F]) ExitScope() { } } +// EnterStructScope starts a new scope for a struct. +func (r *Resolver[T, F]) EnterStructScope(q T) { + r.scopedStructs = append(r.scopedStructs, q) +} + +// ExitStructScope clears the current struct scope. +func (r *Resolver[T, F]) ExitStructScope() { + if len(r.scopedStructs) > 0 { + r.scopedStructs = r.scopedStructs[:len(r.scopedStructs)-1] + } +} + +// HasScopedStruct returns true if there is a struct in the current scope. +func (r *Resolver[T, F]) HasScopedStruct() bool { + return len(r.scopedStructs) > 0 +} + +// ScopedStruct returns the current struct scope. +func (r *Resolver[T, F]) ScopedStruct() (T, error) { + if len(r.scopedStructs) == 0 { + return zero[T](), fmt.Errorf("no scoped structs were set") + } + return r.scopedStructs[len(r.scopedStructs)-1], nil +} + // Alias creates a new alias within the current scope. When EndScope is called all aliases in the // scope will be removed. Calling ResolveLocal with the same name will return the stored type t. // Names must be unique within the CQL library. diff --git a/internal/reference/reference_test.go b/internal/reference/reference_test.go index 18896d0..ffe0904 100644 --- a/internal/reference/reference_test.go +++ b/internal/reference/reference_test.go @@ -541,6 +541,57 @@ func TestParserAliasAndResolve(t *testing.T) { } } +func TestScopedStructs(t *testing.T) { + // Test scoping and de-scoping of structs in context. + r := NewResolver[result.Value, *model.FunctionDef]() + + if r.HasScopedStruct() { + t.Errorf("HasScopedStruct() got true, want false") + } + _, err := r.ScopedStruct() + if err == nil { + t.Errorf("ScopedStruct() with no scope expected error but got success") + } + + v1 := newOrFatal(1, t) + r.EnterStructScope(v1) + if !r.HasScopedStruct() { + t.Errorf("HasScopedStruct() got false when struct was in scope") + } + + got, err := r.ScopedStruct() + if err != nil { + t.Fatalf("ScopedStruct() unexpected err: %v", err) + } + if diff := cmp.Diff(v1, got); diff != "" { + t.Errorf("ScopedStruct() diff (-want +got):\n%s", diff) + } + + v2 := newOrFatal(2, t) + r.EnterStructScope(v2) + got, err = r.ScopedStruct() + if err != nil { + t.Fatalf("ScopedStruct() unexpected err: %v", err) + } + if diff := cmp.Diff(v2, got); diff != "" { + t.Errorf("ScopedStruct() diff (-want +got):\n%s", diff) + } + + r.ExitStructScope() + got, err = r.ScopedStruct() + if err != nil { + t.Fatalf("ScopedStruct() unexpected err: %v", err) + } + if diff := cmp.Diff(v1, got); diff != "" { + t.Errorf("ScopedStruct() diff (-want +got):\n%s", diff) + } + + r.ExitStructScope() + if r.HasScopedStruct() { + t.Errorf("HasScopedStruct() got true when no struct should be in scope") + } +} + func TestResolveIncludedLibrary(t *testing.T) { // TEST SETUP - PREVIOUS PARSED LIBRARY // diff --git a/interpreter/expressions.go b/interpreter/expressions.go index f8cc222..0fe9478 100644 --- a/interpreter/expressions.go +++ b/interpreter/expressions.go @@ -59,6 +59,8 @@ func (i *interpreter) evalExpression(elem model.IExpression) (result.Value, erro return i.evalQueryLetRef(elem) case *model.AliasRef: return i.evalAliasRef(elem) + case *model.IdentifierRef: + return i.evalIdentifierRef(elem) case *model.CodeSystemRef: return i.evalCodeSystemRef(elem) case *model.ValuesetRef: @@ -305,6 +307,24 @@ func (i *interpreter) evalAliasRef(a *model.AliasRef) (result.Value, error) { return i.refs.ResolveLocal(a.Name) } +func (i *interpreter) evalIdentifierRef(r *model.IdentifierRef) (result.Value, error) { + obj, err := i.refs.ScopedStruct() + if err != nil { + return result.Value{}, err + } + + // Passing the static types here is likely unimportant, but we compute it for completeness. + aType, err := i.modelInfo.PropertyTypeSpecifier(obj.RuntimeType(), r.Name) + if err != nil { + return result.Value{}, err + } + ap, err := i.valueProperty(obj, r.Name, aType) + if err != nil { + return result.Value{}, err + } + return ap, nil +} + func (i *interpreter) evalOperandRef(a *model.OperandRef) (result.Value, error) { return i.refs.ResolveLocal(a.Name) } diff --git a/interpreter/query.go b/interpreter/query.go index fc223a0..2b2bb5d 100644 --- a/interpreter/query.go +++ b/interpreter/query.go @@ -121,7 +121,7 @@ func (i *interpreter) evalQuery(q *model.Query) (result.Value, error) { return result.Value{}, err } } else { - i.sortByColumn(finalVals, q.Sort.ByItems) + err := i.sortByColumnOrExpression(finalVals, q.Sort.ByItems) if err != nil { return result.Value{}, err } @@ -490,49 +490,58 @@ func compareNumeralInt[t float64 | int64 | int32](left, right t) int { } } -func (i *interpreter) sortByColumn(objs []result.Value, sbis []model.ISortByItem) error { - // Validate sort column types. - for _, sortItems := range sbis { - // TODO(b/316984809): Is this validation in advance necessary? What if other values (beyond - // objs[0]) have a different runtime type for the property (e.g. if they're a choice type)? - // Consider validating types inline during the sort instead. - path := sortItems.(*model.SortByColumn).Path - propertyType, err := i.modelInfo.PropertyTypeSpecifier(objs[0].RuntimeType(), path) +func (i *interpreter) dateTimeOrError(v result.Value) (result.Value, error) { + switch sr := v.GolangValue().(type) { + case result.DateTime: + return v, nil + case result.Named: + if sr.RuntimeType.Equal(&types.Named{TypeName: "FHIR.dateTime"}) { + return i.protoProperty(sr, "value", types.DateTime) + } + } + return result.Value{}, fmt.Errorf("sorting only currently supported on DateTime columns") +} + +// getSortValue returns the value to be used for the comparison-based sort. This +// is typically a field or expression on the structure being sorted. +func (i *interpreter) getSortValue(it model.ISortByItem, v result.Value) (result.Value, error) { + var rv result.Value + var err error + switch iv := it.(type) { + case *model.SortByColumn: + // Passing the static types here is likely unimportant, but we compute it for completeness. + t, err := i.modelInfo.PropertyTypeSpecifier(v.RuntimeType(), iv.Path) if err != nil { - return err + return result.Value{}, err } - columnVal, err := i.valueProperty(objs[0], path, propertyType) + rv, err = i.valueProperty(v, iv.Path, t) if err != nil { - return err + return result.Value{}, err } - // Strictly only allow DateTimes for now. - // TODO(b/316984809): add sorting support for other types. - if !columnVal.RuntimeType().Equal(types.DateTime) { - return fmt.Errorf("sort column of a query must evaluate to a date time, instead got %v", columnVal.RuntimeType()) + case *model.SortByExpression: + i.refs.EnterStructScope(v) + defer i.refs.ExitStructScope() + rv, err = i.evalExpression(iv.SortExpression) + if err != nil { + return result.Value{}, err } + default: + return result.Value{}, fmt.Errorf("internal error - unsupported sort by item type: %T", iv) } + return i.dateTimeOrError(rv) +} + +func (i *interpreter) sortByColumnOrExpression(objs []result.Value, sbis []model.ISortByItem) error { var sortErr error = nil slices.SortFunc(objs[:], func(a, b result.Value) int { - for _, sortItems := range sbis { - sortCol := sortItems.(*model.SortByColumn) - // Passing the static types here is likely unimportant, but we compute it for completeness. - aType, err := i.modelInfo.PropertyTypeSpecifier(a.RuntimeType(), sortCol.Path) - if err != nil { - sortErr = err - continue - } - ap, err := i.valueProperty(a, sortCol.Path, aType) - if err != nil { - sortErr = err - continue - } - bType, err := i.modelInfo.PropertyTypeSpecifier(b.RuntimeType(), sortCol.Path) + for _, sortItem := range sbis { + ap, err := i.getSortValue(sortItem, a) if err != nil { sortErr = err continue } - bp, err := i.valueProperty(b, sortCol.Path, bType) + bp, err := i.getSortValue(sortItem, b) if err != nil { sortErr = err continue @@ -544,7 +553,7 @@ func (i *interpreter) sortByColumn(objs []result.Value, sbis []model.ISortByItem // TODO(b/308012659): Implement dateTime comparison that doesn't take a precision. if av.Equal(bv) { continue - } else if sortCol.SortByItem.Direction == model.DESCENDING { + } else if sortItem.SortDirection() == model.DESCENDING { return bv.Compare(av) } return av.Compare(bv) diff --git a/model/model.go b/model/model.go index ebab4ea..0bf6320 100644 --- a/model/model.go +++ b/model/model.go @@ -483,7 +483,7 @@ type ReturnClause struct { // Follows format outlined in https://cql.hl7.org/elm/schema/expression.xsd. type ISortByItem interface { IElement - isSortByItem() + SortDirection() SortDirection } // SortByItem is the base abstract type for all query types. @@ -492,20 +492,25 @@ type SortByItem struct { Direction SortDirection } +// SortDirection returns the direction of the sort, e.g. ASCENDING or DESCENDING. +func (s *SortByItem) SortDirection() SortDirection { return s.Direction } + // SortByDirection enables sorting non-tuple values by direction type SortByDirection struct { *SortByItem } -func (c *SortByDirection) isSortByItem() {} - // SortByColumn enables sorting by a given column and direction. type SortByColumn struct { *SortByItem Path string } -func (c *SortByColumn) isSortByItem() {} +// SortByExpression enables sorting by an expression and direction. +type SortByExpression struct { + *SortByItem + SortExpression IExpression +} // AliasedSource is a query source with an alias. type AliasedSource struct { @@ -1158,6 +1163,14 @@ type OperandRef struct { Name string } +// IdentifierRef defines a reference to an identifier within a defined scope, such as a sort by. +// This is distinct from other references since it not a defined name, but will typically reference +// a field for some structure in scope of a sort expression. +type IdentifierRef struct { + *Expression + Name string +} + // UNARY EXPRESSION GETNAME() // GetName returns the name of the system operator. diff --git a/parser/expressions.go b/parser/expressions.go index f640f7d..eddcf98 100644 --- a/parser/expressions.go +++ b/parser/expressions.go @@ -412,6 +412,27 @@ func (v *visitor) VisitQuantityContext(ctx cql.IQuantityContext) (model.Quantity // visitor. func (v *visitor) VisitReferentialIdentifier(ctx cql.IReferentialIdentifierContext) model.IExpression { name := v.parseReferentialIdentifier(ctx) + + if v.refs.HasScopedStruct() { + sourceFn, err := v.refs.ScopedStruct() + if err != nil { + return v.badExpression(err.Error(), ctx) + } + + // If the query source has the expected property, return the identifier ref. Otherwise + // fall through to the resolution logic below. + source := sourceFn() + elementType := source.GetResultType().(*types.List).ElementType + + ptype, err := v.modelInfo.PropertyTypeSpecifier(elementType, name) + if err == nil { + return &model.IdentifierRef{ + Name: name, + Expression: model.ResultType(ptype), + } + } + } + if i := v.refs.ResolveInclude(name); i != nil { return v.badExpression(fmt.Sprintf("internal error - referential identifier %v is a local identifier to an included library", name), ctx) } diff --git a/parser/query.go b/parser/query.go index e2422d2..246e8e7 100644 --- a/parser/query.go +++ b/parser/query.go @@ -199,7 +199,6 @@ func (v *visitor) parseWhereClause(wc cql.IWhereClauseContext, q *model.Query) ( func (v *visitor) parseSortClause(sc cql.ISortClauseContext, q *model.Query) (*model.Query, error) { // TODO(b/316961394): Add check for sortability for CQL query sort columns. - // TODO(b/317008490): Implement sort by expression. if sc == nil { return q, nil } @@ -218,20 +217,43 @@ func (v *visitor) parseSortClause(sc cql.ISortClauseContext, q *model.Query) (*m }, } } else if sbi, found := maybeGetChildNode[*cql.SortByItemContext](sc.GetChildren(), nil); found { - sortDir, err := parseSortDirection(sbi.SortDirection().GetText()) + // Sort direction is optional in the "sort by" clause, and defaults to ascending. + var sortText string = "ascending" + if sbi.SortDirection() != nil { + sortText = sbi.SortDirection().GetText() + } + sortDir, err := parseSortDirection(sortText) if err != nil { return nil, err } - // TODO(b/317402356): Add static type checking for column paths. - sortByItems = []model.ISortByItem{ - &model.SortByColumn{ - SortByItem: &model.SortByItem{ - Direction: sortDir, + v.refs.EnterStructScope(func() model.IExpression { return q.Source[0] }) + defer v.refs.ExitStructScope() + + sortExpr := v.VisitExpression(sbi.ExpressionTerm()) + + switch t := sortExpr.(type) { + case *model.IdentifierRef: + sortByItems = []model.ISortByItem{ + &model.SortByColumn{ + SortByItem: &model.SortByItem{ + Direction: sortDir, + }, + Path: t.Name, }, - Path: sbi.ExpressionTerm().GetText(), - }, + } + default: + sortByItems = []model.ISortByItem{ + &model.SortByExpression{ + SortByItem: &model.SortByItem{ + Direction: sortDir, + }, + SortExpression: t, + }, + } } + + // TODO(b/317402356): Add static type checking for column paths. } else { return nil, errors.New("item or direction to sort by was not found") } diff --git a/parser/query_test.go b/parser/query_test.go index 6e4c645..bab4611 100644 --- a/parser/query_test.go +++ b/parser/query_test.go @@ -355,6 +355,50 @@ func TestQuery(t *testing.T) { }, }, }, + { + name: "Sort by expression", + cql: dedent.Dedent(` + define TESTRESULT: [Encounter] E sort by start of period`), + want: &model.Query{ + Expression: &model.Expression{ + Element: &model.Element{ResultType: &types.List{ElementType: &types.Named{TypeName: "FHIR.Encounter"}}}, + }, + Source: []*model.AliasedSource{ + { + Expression: &model.Expression{Element: &model.Element{ResultType: &types.List{ElementType: &types.Named{TypeName: "FHIR.Encounter"}}}}, + Alias: "E", + Source: &model.Retrieve{ + Expression: model.ResultType(&types.List{ElementType: &types.Named{TypeName: "FHIR.Encounter"}}), + DataType: "{http://hl7.org/fhir}Encounter", + TemplateID: "http://hl7.org/fhir/StructureDefinition/Encounter", + CodeProperty: "type", + }, + }, + }, + Sort: &model.SortClause{ + ByItems: []model.ISortByItem{ + &model.SortByExpression{ + SortByItem: &model.SortByItem{Direction: model.ASCENDING}, + SortExpression: &model.Start{ + UnaryExpression: &model.UnaryExpression{ + Expression: model.ResultType(types.DateTime), + Operand: &model.FunctionRef{ + Expression: model.ResultType(&types.Interval{PointType: types.DateTime}), + Name: "ToInterval", + LibraryName: "FHIRHelpers", + Operands: []model.IExpression{&model.IdentifierRef{ + Expression: model.ResultType(&types.Named{TypeName: "FHIR.Period"}), + Name: "period", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, { name: "Aggregate", cql: "define TESTRESULT: ({1, 2, 3}) N aggregate R starting 1: R * N", diff --git a/tests/enginetests/query_test.go b/tests/enginetests/query_test.go index c363aef..1e2894a 100644 --- a/tests/enginetests/query_test.go +++ b/tests/enginetests/query_test.go @@ -61,6 +61,7 @@ func TestQuery(t *testing.T) { wantResult: newOrFatal(t, result.List{ Value: []result.Value{ newOrFatal(t, result.Named{Value: RetrieveFHIRResource(t, "Encounter", "1"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), + newOrFatal(t, result.Named{Value: RetrieveFHIRResource(t, "Encounter", "2"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), }, StaticType: &types.List{ElementType: &types.Named{TypeName: "FHIR.Encounter"}}, }), @@ -82,6 +83,7 @@ func TestQuery(t *testing.T) { wantSourceValues: []result.Value{ newOrFatal(t, result.List{Value: []result.Value{ newOrFatal(t, result.Named{Value: RetrieveFHIRResource(t, "Encounter", "1"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), + newOrFatal(t, result.Named{Value: RetrieveFHIRResource(t, "Encounter", "2"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), }, StaticType: &types.List{ElementType: &types.Named{TypeName: "FHIR.Encounter"}}, }), @@ -144,6 +146,15 @@ func TestQuery(t *testing.T) { define TESTRESULT: [Observation] O where O.id = 'apple'`), wantResult: newOrFatal(t, result.List{Value: []result.Value{}, StaticType: &types.List{ElementType: &types.Named{TypeName: "FHIR.Observation"}}}), }, + { + name: "Where filters everything by date", + cql: dedent.Dedent(` + using FHIR version '4.0.1' + include FHIRHelpers version '4.0.1' called FHIRHelpers + + define TESTRESULT: [Observation] O where O.effective < @1980-01-01`), + wantResult: newOrFatal(t, result.List{Value: []result.Value{}, StaticType: &types.List{ElementType: &types.Named{TypeName: "FHIR.Observation"}}}), + }, { name: "Where returns null", cql: dedent.Dedent(` @@ -157,7 +168,7 @@ func TestQuery(t *testing.T) { using FHIR version '4.0.1' include FHIRHelpers version '4.0.1' called FHIRHelpers - define TESTRESULT: [Encounter] E where start of E.period < @2022-01-01 + define TESTRESULT: [Encounter] E where start of E.period < @2020-01-01 `), wantResult: newOrFatal(t, result.List{ Value: []result.Value{ @@ -288,6 +299,65 @@ func TestQuery(t *testing.T) { }, StaticType: &types.List{ElementType: types.String}}), }, + { + name: "Sort by field", + cql: dedent.Dedent(` + using FHIR version '4.0.1' + include FHIRHelpers version '4.0.1' called FHIRHelpers + define TESTRESULT: [Observation] O sort by effective`), + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Named{Value: RetrieveFHIRResource(t, "Observation", "1"), RuntimeType: &types.Named{TypeName: "FHIR.Observation"}}), + newOrFatal(t, result.Named{Value: RetrieveFHIRResource(t, "Observation", "2"), RuntimeType: &types.Named{TypeName: "FHIR.Observation"}}), + newOrFatal(t, result.Named{Value: RetrieveFHIRResource(t, "Observation", "3"), RuntimeType: &types.Named{TypeName: "FHIR.Observation"}}), + }, + StaticType: &types.List{ElementType: &types.Named{TypeName: "FHIR.Observation"}}, + }), + }, + { + name: "Sort by field desc", + cql: dedent.Dedent(` + using FHIR version '4.0.1' + include FHIRHelpers version '4.0.1' called FHIRHelpers + define TESTRESULT: [Observation] O sort by effective desc`), + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Named{Value: RetrieveFHIRResource(t, "Observation", "3"), RuntimeType: &types.Named{TypeName: "FHIR.Observation"}}), + newOrFatal(t, result.Named{Value: RetrieveFHIRResource(t, "Observation", "2"), RuntimeType: &types.Named{TypeName: "FHIR.Observation"}}), + newOrFatal(t, result.Named{Value: RetrieveFHIRResource(t, "Observation", "1"), RuntimeType: &types.Named{TypeName: "FHIR.Observation"}}), + }, + StaticType: &types.List{ElementType: &types.Named{TypeName: "FHIR.Observation"}}, + }), + }, + // Online test: https://cql-runner.dataphoria.org/ + { + name: "Sort by expression", + cql: dedent.Dedent(` + using FHIR version '4.0.1' + include FHIRHelpers version '4.0.1' called FHIRHelpers + define TESTRESULT: [Encounter] E sort by start of period`), + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Named{Value: RetrieveFHIRResource(t, "Encounter", "1"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), + newOrFatal(t, result.Named{Value: RetrieveFHIRResource(t, "Encounter", "2"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), + }, + StaticType: &types.List{ElementType: &types.Named{TypeName: "FHIR.Encounter"}}, + }), + }, + { + name: "Sort by expression expression descending", + cql: dedent.Dedent(` + using FHIR version '4.0.1' + include FHIRHelpers version '4.0.1' called FHIRHelpers + define TESTRESULT: [Encounter] E sort by start of period desc`), + wantResult: newOrFatal(t, result.List{ + Value: []result.Value{ + newOrFatal(t, result.Named{Value: RetrieveFHIRResource(t, "Encounter", "2"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), + newOrFatal(t, result.Named{Value: RetrieveFHIRResource(t, "Encounter", "1"), RuntimeType: &types.Named{TypeName: "FHIR.Encounter"}}), + }, + StaticType: &types.List{ElementType: &types.Named{TypeName: "FHIR.Encounter"}}, + }), + }, { name: "Aggregate", cql: "define TESTRESULT: ({1, 2, 3, 3, 4}) L aggregate A starting 1: A * L", diff --git a/tests/enginetests/testdata/patient_bundle.json b/tests/enginetests/testdata/patient_bundle.json index 6129d36..9a0a206 100644 --- a/tests/enginetests/testdata/patient_bundle.json +++ b/tests/enginetests/testdata/patient_bundle.json @@ -49,6 +49,25 @@ } } }, + { + "fullUrl": "fullUrl", + "resource": { + "resourceType": "Encounter", + "id": "2", + "status": "finished", + "class": { + "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code": "AMB", + "display": "ambulatory" + }, + "serviceType": { "text": "Medicine" }, + "subject": { "reference": "Patient/1" }, + "period": { + "start": "2020-11-13T11:21:26+00:00", + "end": "2020-11-13T12:39:19+00:00" + } + } + }, { "fullUrl": "fullUrl", "resource": { @@ -145,14 +164,16 @@ "display": "Glucose in Blood" } ] - } + }, + "effectiveDateTime": "2018-11-14T12:30:19+00:00" } }, { "fullUrl": "fullUrl", "resource": { "resourceType": "Observation", - "id": "3" + "id": "3", + "effectiveDateTime": "2018-11-15T12:30:19+00:00" } }, {