diff --git a/internal/datehelpers/string.go b/internal/datehelpers/string.go index 3ddbda2..da8e209 100644 --- a/internal/datehelpers/string.go +++ b/internal/datehelpers/string.go @@ -38,15 +38,18 @@ func DateString(d time.Time, precision model.DateTimePrecision) (string, error) } // DateTimeString returns a CQL DateTime string representation of a DateTime. +// For Year, Month, and Day precision, we remove the 'T' value but still include the timezone. +// The correct behavior here is unclear in the spec but this seems like the correct behavior for +// now. func DateTimeString(d time.Time, precision model.DateTimePrecision) (string, error) { var dtFormat string switch precision { case model.YEAR: - dtFormat = dateTimeYear + dtFormat = dateYear case model.MONTH: - dtFormat = dateTimeMonth + dtFormat = dateMonth case model.DAY: - dtFormat = dateTimeDay + dtFormat = dateDay case model.HOUR: dtFormat = dateTimeHour case model.MINUTE: diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index 804bd82..966de5b 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -243,6 +243,49 @@ func (i *interpreter) unaryOverloads(m model.IUnaryExpression) ([]convert.Overlo Result: evalToConceptList, }, }, nil + case *model.ToString: + return []convert.Overload[evalUnarySignature]{ + { + Operands: []types.IType{types.Any}, + Result: evalToString, + }, + { + Operands: []types.IType{types.Boolean}, + Result: evalToString, + }, + { + Operands: []types.IType{types.Integer}, + Result: evalToString, + }, + { + Operands: []types.IType{types.Long}, + Result: evalToString, + }, + { + Operands: []types.IType{types.Decimal}, + Result: evalToString, + }, + { + Operands: []types.IType{types.Quantity}, + Result: evalToString, + }, + { + Operands: []types.IType{types.Ratio}, + Result: evalToString, + }, + { + Operands: []types.IType{types.Date}, + Result: evalToString, + }, + { + Operands: []types.IType{types.DateTime}, + Result: evalToString, + }, + { + Operands: []types.IType{types.Time}, + Result: evalToString, + }, + }, nil case *model.End: return []convert.Overload[evalUnarySignature]{ { diff --git a/interpreter/operator_string.go b/interpreter/operator_string.go index 224f9ef..b0b9e06 100644 --- a/interpreter/operator_string.go +++ b/interpreter/operator_string.go @@ -15,10 +15,14 @@ package interpreter import ( + "fmt" + "strconv" "strings" + "github.com/google/cql/internal/datehelpers" "github.com/google/cql/model" "github.com/google/cql/result" + "github.com/google/cql/types" ) // +(left String, right String) String @@ -41,3 +45,101 @@ func evalConcatenate(m model.INaryExpression, operands []result.Value) (result.V } return result.New(retStr.String()) } + +// ToString(Boolean) String +// ToString(Integer) String +// ToString(Long) String +// ToString(Decimal) String +// ToString(Quantity) String +// ToString(Ratio) String +// ToString(Date) String +// ToString(DateTime) String +// ToString(Time) String +// https://cql.hl7.org/09-b-cqlreference.html#tostring +// In the future we may put this logic directly onto the result.Value interface. Could be useful +// for debugging, and/or the REPL. +func evalToString(_ model.IUnaryExpression, operand result.Value) (result.Value, error) { + if result.IsNull(operand) { + return result.New(nil) + } + + switch operand.RuntimeType() { + case types.Boolean: + b, err := result.ToBool(operand) + if err != nil { + return result.Value{}, err + } + return result.New(strconv.FormatBool(b)) + case types.Integer: + i, err := result.ToInt32(operand) + if err != nil { + return result.Value{}, err + } + return result.New(strconv.FormatInt(int64(i), 10)) + case types.Long: + i, err := result.ToInt64(operand) + if err != nil { + return result.Value{}, err + } + return result.New(strconv.FormatInt(i, 10)) + case types.Decimal: + d, err := result.ToFloat64(operand) + if err != nil { + return result.Value{}, err + } + return result.New(strconv.FormatFloat(d, 'f', -1, 64)) + case types.Quantity: + q, err := result.ToQuantity(operand) + if err != nil { + return result.Value{}, err + } + return result.New(quantityToString(q)) + case types.Ratio: + r, err := result.ToRatio(operand) + if err != nil { + return result.Value{}, err + } + return result.New(fmt.Sprintf("%s:%s", quantityToString(r.Numerator), quantityToString(r.Denominator))) + case types.Date: + d, err := result.ToDateTime(operand) + if err != nil { + return result.Value{}, err + } + s, err := datehelpers.DateString(d.Date, d.Precision) + if err != nil { + return result.Value{}, err + } + // Remove the leading '@' + return result.New(s[1:]) + case types.DateTime: + d, err := result.ToDateTime(operand) + if err != nil { + return result.Value{}, err + } + s, err := datehelpers.DateTimeString(d.Date, d.Precision) + if err != nil { + return result.Value{}, err + } + // Remove the leading '@' + return result.New(s[1:]) + case types.Time: + t, err := result.ToDateTime(operand) + if err != nil { + return result.Value{}, err + } + s, err := datehelpers.TimeString(t.Date, t.Precision) + if err != nil { + return result.Value{}, err + } + // Remove the leading 'T' + return result.New(s[1:]) + default: + return result.Value{}, fmt.Errorf("unsupported operand type for ToString: %v", operand.RuntimeType()) + } +} + +// convert a quantity value to a string +func quantityToString(q result.Quantity) string { + f := strconv.FormatFloat(q.Value, 'f', -1, 64) + return fmt.Sprintf("%s '%s'", f, q.Unit) +} diff --git a/parser/operators.go b/parser/operators.go index a4820dd..da5bd68 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -395,6 +395,7 @@ func (p *Parser) loadSystemOperators() error { { name: "ToString", operands: [][]types.IType{ + {types.Any}, {types.String}, {types.Integer}, {types.Long}, diff --git a/tests/enginetests/operator_string_test.go b/tests/enginetests/operator_string_test.go index 655ab72..aa0abf8 100644 --- a/tests/enginetests/operator_string_test.go +++ b/tests/enginetests/operator_string_test.go @@ -119,3 +119,118 @@ func TestConcatenate(t *testing.T) { }) } } + +func TestToString(t *testing.T) { + tests := []struct { + name string + cql string + wantModel model.IExpression + wantResult result.Value + }{ + { + name: "ToString(true)", + cql: "ToString(true)", + wantResult: newOrFatal(t, "true"), + }, + { + name: "ToString(false)", + cql: "ToString(false)", + wantResult: newOrFatal(t, "false"), + }, + { + name: "ToString(1)", + cql: "ToString(1)", + wantResult: newOrFatal(t, "1"), + }, + { + name: "ToString(-1)", + cql: "ToString(-1)", + wantResult: newOrFatal(t, "-1"), + }, + { + name: "ToString(100000L)", + cql: "ToString(100000L)", + wantResult: newOrFatal(t, "100000"), + }, + { + name: "ToString(-100000L)", + cql: "ToString(-100000L)", + wantResult: newOrFatal(t, "-100000"), + }, + { + name: "ToString(1.42)", + cql: "ToString(1.42)", + wantResult: newOrFatal(t, "1.42"), + }, + { + name: "ToString(-1.42)", + cql: "ToString(-1.42)", + wantResult: newOrFatal(t, "-1.42"), + }, + { + name: "ToString(1 'cm')", + cql: "ToString(1 'cm')", + wantResult: newOrFatal(t, "1 'cm'"), + }, + { + name: "ToString(-1 'cm')", + cql: "ToString(-1 'cm')", + wantResult: newOrFatal(t, "-1 'cm'"), + }, + { + name: "ToString(1'g':0.1'g')", + cql: "ToString(1'g':0.1'g')", + wantResult: newOrFatal(t, "1 'g':0.1 'g'"), + }, + { + name: "ToString(@2022-01-03)", + cql: "ToString(@2022-01-03)", + wantResult: newOrFatal(t, "2022-01-03"), + }, + { + name: "ToString(@2022-01-03T12:00:00Z)", + cql: "ToString(@2022-01-03T12:00:00Z)", + wantResult: newOrFatal(t, "2022-01-03T12:00:00Z"), + }, + { + name: "ToString(DateTime(2022, 1, 3))", + cql: "ToString(DateTime(2022, 1, 3))", + wantResult: newOrFatal(t, "2022-01-03+04:00"), + }, + { + name: "ToString(@T12:01:00)", + cql: "ToString(@T12:01:00)", + wantResult: newOrFatal(t, "12:01:00"), + }, + { + name: "ToString(null as Date)", + cql: "ToString(null as Date)", + wantResult: newOrFatal(t, nil), + }, + { + name: "ToString(null)", + cql: "ToString(null)", + 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) + } + 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/enginetests/setup.go b/tests/enginetests/setup.go index 9abf26e..bda55e5 100644 --- a/tests/enginetests/setup.go +++ b/tests/enginetests/setup.go @@ -146,6 +146,9 @@ func addFHIRHelpersLib(t testing.TB, lib string) []string { return []string{lib, string(fhirHelpers)} } +// defaultInterpreterConfig returns an interpreter.Config with the default values used in the +// engine tests. +// The evaluation timestamp is fixed at Jan 1, 2024 +04:00. func defaultInterpreterConfig(t testing.TB, p *parser.Parser) interpreter.Config { return interpreter.Config{ DataModels: p.DataModel(), diff --git a/tests/spectests/exclusions/exclusions.go b/tests/spectests/exclusions/exclusions.go index 7ee12b8..cbf83b4 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -376,9 +376,12 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "StartsWith", "Substring", "Upper", - "toString tests", }, - NamesExcludes: []string{}, + NamesExcludes: []string{ + // TODO: b/346880550 - These test appear to have incorrect assertions. + "DateTimeToString1", + "DateTimeToString2", + }, }, "CqlTypesTest.xml": XMLTestFileExclusions{ GroupExcludes: []string{}, @@ -400,7 +403,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "ToBoolean", "ToConcept", "ToInteger", - "ToString", "ToTime", }, NamesExcludes: []string{