From 69f6278c8022439ab925f6b67862344a33877ce5 Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Mon, 30 Jun 2025 21:58:05 -0500 Subject: [PATCH 1/3] #146 add support for DateTimeComponentFrom --- interpreter/operator_datetime.go | 125 ++++++ interpreter/operator_dispatcher.go | 15 + model/model.go | 13 + parser/expressions.go | 96 +++- .../operator_datetime_component_test.go | 420 ++++++++++++++++++ tests/spectests/exclusions/exclusions.go | 18 - 6 files changed, 657 insertions(+), 30 deletions(-) create mode 100644 tests/enginetests/operator_datetime_component_test.go diff --git a/interpreter/operator_datetime.go b/interpreter/operator_datetime.go index c99f729..0220e3f 100644 --- a/interpreter/operator_datetime.go +++ b/interpreter/operator_datetime.go @@ -863,6 +863,131 @@ func validatePrecisionByType(precision model.DateTimePrecision, allowUnset bool, } } +// evalDateTimeComponentFrom extracts a specific component (year, month, day, etc.) from a date or datetime value +func (i *interpreter) evalDateTimeComponentFrom(m model.IUnaryExpression, val result.Value) (result.Value, error) { + dtComponent, ok := m.(*model.DateTimeComponentFrom) + if !ok { + return result.Value{}, fmt.Errorf("expected DateTimeComponentFrom expression, got %T", m) + } + + // Handle null values + if result.IsNull(val) { + return result.New(nil) + } + + // Determine the actual runtime type first to avoid conversion issues + switch val.RuntimeType() { + case types.DateTime: + dt, err := result.ToDateTime(val) + if err != nil { + return result.Value{}, err + } + + switch dtComponent.Component { + case model.TIMEZONEOFFSET: + // Timezone offset is always available for DateTime values regardless of precision + _, offset := dt.Date.Zone() + offsetHours := float64(offset) / 3600.0 + return result.New(offsetHours) + default: + // Check if the precision is sufficient for other components + if !precisionGreaterOrEqual(dtComponent.Component, dt.Precision) { + return result.New(nil) + } + } + + switch dtComponent.Component { + case model.YEAR: + return result.New(int32(dt.Date.Year())) + case model.MONTH: + return result.New(int32(dt.Date.Month())) + case model.DAY: + return result.New(int32(dt.Date.Day())) + case model.HOUR: + return result.New(int32(dt.Date.Hour())) + case model.MINUTE: + return result.New(int32(dt.Date.Minute())) + case model.SECOND: + return result.New(int32(dt.Date.Second())) + case model.MILLISECOND: + return result.New(int32(dt.Date.Nanosecond() / 1000000)) + case model.TIMEZONEOFFSET: + // Extract timezone offset in hours from UTC + _, offset := dt.Date.Zone() + offsetHours := float64(offset) / 3600.0 + return result.New(offsetHours) + default: + return result.Value{}, fmt.Errorf("unsupported date/time component: %v", dtComponent.Component) + } + + case types.Date: + d, err := result.ToDate(val) + if err != nil { + return result.Value{}, err + } + + // Check if the precision is sufficient + if !precisionGreaterOrEqual(dtComponent.Component, d.Precision) { + return result.New(nil) + } + + switch dtComponent.Component { + case model.YEAR: + return result.New(int32(d.Date.Year())) + case model.MONTH: + return result.New(int32(d.Date.Month())) + case model.DAY: + return result.New(int32(d.Date.Day())) + case model.HOUR, model.MINUTE, model.SECOND, model.MILLISECOND: + // These components don't exist in Date values + return result.New(nil) + default: + return result.Value{}, fmt.Errorf("unsupported date component: %v", dtComponent.Component) + } + + case types.Time: + t, err := result.ToTime(val) + if err != nil { + return result.Value{}, err + } + + switch dtComponent.Component { + case model.YEAR, model.MONTH, model.DAY: + // These components don't exist in Time values + return result.New(nil) + case model.HOUR: + // Check if the precision is sufficient + if !precisionGreaterOrEqual(dtComponent.Component, t.Precision) { + return result.New(nil) + } + return result.New(int32(t.Date.Hour())) + case model.MINUTE: + // Check if the precision is sufficient + if !precisionGreaterOrEqual(dtComponent.Component, t.Precision) { + return result.New(nil) + } + return result.New(int32(t.Date.Minute())) + case model.SECOND: + // Check if the precision is sufficient + if !precisionGreaterOrEqual(dtComponent.Component, t.Precision) { + return result.New(nil) + } + return result.New(int32(t.Date.Second())) + case model.MILLISECOND: + // Check if the precision is sufficient + if !precisionGreaterOrEqual(dtComponent.Component, t.Precision) { + return result.New(nil) + } + return result.New(int32(t.Date.Nanosecond() / 1000000)) + default: + return result.Value{}, fmt.Errorf("unsupported time component: %v", dtComponent.Component) + } + + default: + return result.Value{}, fmt.Errorf("operand is not a DateTime, Date, or Time value, got %v", val.RuntimeType()) + } +} + // precisionGreaterOrEqual returns true if l is of greater or equal precision than r. func precisionGreaterOrEqual(l, r model.DateTimePrecision) bool { if l == r { diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index af782da..765d929 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -813,6 +813,21 @@ func (i *interpreter) unaryOverloads(m model.IUnaryExpression) ([]convert.Overlo Result: evalWidthInterval, }, }, nil + case *model.DateTimeComponentFrom: + return []convert.Overload[evalUnarySignature]{ + { + Operands: []types.IType{types.Date}, + Result: i.evalDateTimeComponentFrom, + }, + { + Operands: []types.IType{types.DateTime}, + Result: i.evalDateTimeComponentFrom, + }, + { + Operands: []types.IType{types.Time}, + Result: i.evalDateTimeComponentFrom, + }, + }, nil default: return nil, fmt.Errorf("unsupported Unary Expression %v", m.GetName()) } diff --git a/model/model.go b/model/model.go index 74d2eaf..ae2fec5 100644 --- a/model/model.go +++ b/model/model.go @@ -100,6 +100,8 @@ const ( SECOND DateTimePrecision = "second" // MILLISECOND represents millisecond precision. MILLISECOND DateTimePrecision = "millisecond" + // TIMEZONEOFFSET represents timezone offset extraction. + TIMEZONEOFFSET DateTimePrecision = "timezoneoffset" ) // Unit represents the unit for a QuantityValue. @@ -912,6 +914,14 @@ type Duration struct { var _ IUnaryExpression = &Duration{} +// DateTimeComponentFrom ELM expression for extracting components from Date/DateTime/Time values. +type DateTimeComponentFrom struct { + *UnaryExpression + Component DateTimePrecision +} + +var _ IUnaryExpression = &DateTimeComponentFrom{} + // Tail ELM Expression https://cql.hl7.org/09-b-cqlreference.html#tail. type Tail struct{ *UnaryExpression } @@ -1696,3 +1706,6 @@ func (w *Width) GetName() string { return "Width" } // GetName returns the name of the system operator. func (d *Duration) GetName() string { return "Duration" } + +// GetName returns the name of the system operator. +func (d *DateTimeComponentFrom) GetName() string { return "DateTimeComponentFrom" } diff --git a/parser/expressions.go b/parser/expressions.go index 16b968d..274f4a4 100644 --- a/parser/expressions.go +++ b/parser/expressions.go @@ -188,23 +188,95 @@ func (v *visitor) VisitParenthesizedTerm(ctx *cql.ParenthesizedTermContext) mode } func (v *visitor) VisitTimeUnitExpressionTerm(ctx *cql.TimeUnitExpressionTermContext) model.IExpression { - // parses statements like: "date from expression" - // TODO: b/301606416 - Implement time units where left is dateTimePrecision. + // parses statements like: "date from expression" or "year from date" dtc := ctx.GetChild(0).(*cql.DateTimeComponentContext) + + var dateTimeComponent string switch component := dtc.GetChild(0).(type) { case antlr.TerminalNode: - dateTimeComponent := component.GetText() - switch dateTimeComponent { - case "date": - return &model.ToDate{ - UnaryExpression: &model.UnaryExpression{ - Operand: v.VisitExpression(ctx.ExpressionTerm()), - Expression: model.ResultType(types.Date), - }, - } + // Handle case like "date from expression" + dateTimeComponent = component.GetText() + case *cql.DateTimePrecisionContext: + // Handle case like "year from date", "month from date", etc. + dateTimeComponent = component.GetText() + default: + return v.badExpression(fmt.Sprintf("unsupported date time component conversion (e.g. X in 'X from expression'). got: %s, supported components: date, year, month, day, hour, minute, second, millisecond", dtc.GetText()), ctx) + } + + switch dateTimeComponent { + case "date": + return &model.ToDate{ + UnaryExpression: &model.UnaryExpression{ + Operand: v.VisitExpression(ctx.ExpressionTerm()), + Expression: model.ResultType(types.Date), + }, + } + case "year": + return &model.DateTimeComponentFrom{ + UnaryExpression: &model.UnaryExpression{ + Operand: v.VisitExpression(ctx.ExpressionTerm()), + Expression: model.ResultType(types.Integer), + }, + Component: model.YEAR, + } + case "month": + return &model.DateTimeComponentFrom{ + UnaryExpression: &model.UnaryExpression{ + Operand: v.VisitExpression(ctx.ExpressionTerm()), + Expression: model.ResultType(types.Integer), + }, + Component: model.MONTH, + } + case "day": + return &model.DateTimeComponentFrom{ + UnaryExpression: &model.UnaryExpression{ + Operand: v.VisitExpression(ctx.ExpressionTerm()), + Expression: model.ResultType(types.Integer), + }, + Component: model.DAY, + } + case "hour": + return &model.DateTimeComponentFrom{ + UnaryExpression: &model.UnaryExpression{ + Operand: v.VisitExpression(ctx.ExpressionTerm()), + Expression: model.ResultType(types.Integer), + }, + Component: model.HOUR, + } + case "minute": + return &model.DateTimeComponentFrom{ + UnaryExpression: &model.UnaryExpression{ + Operand: v.VisitExpression(ctx.ExpressionTerm()), + Expression: model.ResultType(types.Integer), + }, + Component: model.MINUTE, + } + case "second": + return &model.DateTimeComponentFrom{ + UnaryExpression: &model.UnaryExpression{ + Operand: v.VisitExpression(ctx.ExpressionTerm()), + Expression: model.ResultType(types.Integer), + }, + Component: model.SECOND, + } + case "millisecond": + return &model.DateTimeComponentFrom{ + UnaryExpression: &model.UnaryExpression{ + Operand: v.VisitExpression(ctx.ExpressionTerm()), + Expression: model.ResultType(types.Integer), + }, + Component: model.MILLISECOND, + } + case "timezoneoffset": + return &model.DateTimeComponentFrom{ + UnaryExpression: &model.UnaryExpression{ + Operand: v.VisitExpression(ctx.ExpressionTerm()), + Expression: model.ResultType(types.Decimal), + }, + Component: model.TIMEZONEOFFSET, } } - return v.badExpression(fmt.Sprintf("unsupported date time component conversion (e.g. X in 'X from expression'). got: %s, only %v supported", dtc.GetText(), "date"), ctx) + return v.badExpression(fmt.Sprintf("unsupported date time component conversion (e.g. X in 'X from expression'). got: %s, supported components: date, year, month, day, hour, minute, second, millisecond, timezoneoffset", dtc.GetText()), ctx) } func (v *visitor) VisitTupleSelectorTerm(ctx *cql.TupleSelectorTermContext) model.IExpression { diff --git a/tests/enginetests/operator_datetime_component_test.go b/tests/enginetests/operator_datetime_component_test.go new file mode 100644 index 0000000..1b0fdaf --- /dev/null +++ b/tests/enginetests/operator_datetime_component_test.go @@ -0,0 +1,420 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package enginetests + +import ( + "context" + "testing" + + "github.com/google/cql/interpreter" + "github.com/google/cql/parser" + "github.com/google/cql/result" + "github.com/google/go-cmp/cmp" + "google.golang.org/protobuf/testing/protocmp" +) + +func TestDateTimeComponentFrom(t *testing.T) { + tests := []struct { + name string + cql string + wantResult result.Value + wantErr bool + }{ + // Year extraction tests + { + name: "Year from Date", + cql: "year from @2020-03-15", + wantResult: newOrFatal(t, int32(2020)), + }, + { + name: "Year from DateTime", + cql: "year from @2020-03-15T14:30:25.123", + wantResult: newOrFatal(t, int32(2020)), + }, + { + name: "Year from Date with year precision", + cql: "year from @2023", + wantResult: newOrFatal(t, int32(2023)), + }, + + // Month extraction tests + { + name: "Month from Date", + cql: "month from @2020-03-15", + wantResult: newOrFatal(t, int32(3)), + }, + { + name: "Month from DateTime", + cql: "month from @2020-03-15T14:30:25.123", + wantResult: newOrFatal(t, int32(3)), + }, + { + name: "Month from Date with month precision", + cql: "month from @2023-05", + wantResult: newOrFatal(t, int32(5)), + }, + + // Day extraction tests + { + name: "Day from Date", + cql: "day from @2020-03-15", + wantResult: newOrFatal(t, int32(15)), + }, + { + name: "Day from DateTime", + cql: "day from @2020-03-15T14:30:25.123", + wantResult: newOrFatal(t, int32(15)), + }, + + // Hour extraction tests + { + name: "Hour from DateTime", + cql: "hour from @2020-03-15T14:30:25.123", + wantResult: newOrFatal(t, int32(14)), + }, + { + name: "Hour from Time", + cql: "hour from @T14:30:25.123", + wantResult: newOrFatal(t, int32(14)), + }, + + // Minute extraction tests + { + name: "Minute from DateTime", + cql: "minute from @2020-03-15T14:30:25.123", + wantResult: newOrFatal(t, int32(30)), + }, + { + name: "Minute from Time", + cql: "minute from @T14:30:25.123", + wantResult: newOrFatal(t, int32(30)), + }, + + // Second extraction tests + { + name: "Second from DateTime", + cql: "second from @2020-03-15T14:30:25.123", + wantResult: newOrFatal(t, int32(25)), + }, + { + name: "Second from Time", + cql: "second from @T14:30:25.123", + wantResult: newOrFatal(t, int32(25)), + }, + + // Millisecond extraction tests + { + name: "Millisecond from DateTime", + cql: "millisecond from @2020-03-15T14:30:25.123", + wantResult: newOrFatal(t, int32(123)), + }, + { + name: "Millisecond from Time", + cql: "millisecond from @T14:30:25.123", + wantResult: newOrFatal(t, int32(123)), + }, + + // Null handling tests + { + name: "Year from null", + cql: "year from (null as DateTime)", + wantResult: newOrFatal(t, nil), + }, + { + name: "Month from null", + cql: "month from (null as Date)", + wantResult: newOrFatal(t, nil), + }, + + // Precision boundary tests - requesting unavailable precision should return null + { + name: "Hour from Date returns null", + cql: "hour from @2020-03-15", + wantResult: newOrFatal(t, nil), + }, + { + name: "Minute from Date returns null", + cql: "minute from @2020-03-15", + wantResult: newOrFatal(t, nil), + }, + { + name: "Second from Date returns null", + cql: "second from @2020-03-15", + wantResult: newOrFatal(t, nil), + }, + { + name: "Millisecond from Date returns null", + cql: "millisecond from @2020-03-15", + wantResult: newOrFatal(t, nil), + }, + { + name: "Year from Time returns null", + cql: "year from @T14:30:25.123", + wantResult: newOrFatal(t, nil), + }, + { + name: "Month from Time returns null", + cql: "month from @T14:30:25.123", + wantResult: newOrFatal(t, nil), + }, + { + name: "Day from Time returns null", + cql: "day from @T14:30:25.123", + wantResult: newOrFatal(t, nil), + }, + + // Precision insufficient tests - requesting higher precision than available + { + name: "Month from year-precision Date returns null", + cql: "month from @2020", + wantResult: newOrFatal(t, nil), + }, + { + name: "Day from year-precision Date returns null", + cql: "day from @2020", + wantResult: newOrFatal(t, nil), + }, + { + name: "Day from month-precision Date returns null", + cql: "day from @2020-03", + wantResult: newOrFatal(t, nil), + }, + { + name: "Minute from hour-precision Time returns null", + cql: "minute from @T14", + wantResult: newOrFatal(t, nil), + }, + { + name: "Second from minute-precision Time returns null", + cql: "second from @T14:30", + wantResult: newOrFatal(t, nil), + }, + { + name: "Millisecond from second-precision Time returns null", + cql: "millisecond from @T14:30:25", + wantResult: newOrFatal(t, nil), + }, + + // Edge cases + { + name: "Year from leap year", + cql: "year from @2020-02-29", + wantResult: newOrFatal(t, int32(2020)), + }, + { + name: "Month from December", + cql: "month from @2020-12-31", + wantResult: newOrFatal(t, int32(12)), + }, + { + name: "Day from end of month", + cql: "day from @2020-12-31", + wantResult: newOrFatal(t, int32(31)), + }, + { + name: "Hour from midnight", + cql: "hour from @2020-01-01T00:00:00", + wantResult: newOrFatal(t, int32(0)), + }, + { + name: "Hour from end of day", + cql: "hour from @2020-01-01T23:59:59", + wantResult: newOrFatal(t, int32(23)), + }, + { + name: "Minute from start of hour", + cql: "minute from @2020-01-01T14:00:00", + wantResult: newOrFatal(t, int32(0)), + }, + { + name: "Minute from end of hour", + cql: "minute from @2020-01-01T14:59:59", + wantResult: newOrFatal(t, int32(59)), + }, + { + name: "Second from start of minute", + cql: "second from @2020-01-01T14:30:00", + wantResult: newOrFatal(t, int32(0)), + }, + { + name: "Second from end of minute", + cql: "second from @2020-01-01T14:30:59", + wantResult: newOrFatal(t, int32(59)), + }, + { + name: "Millisecond zero", + cql: "millisecond from @2020-01-01T14:30:25.000", + wantResult: newOrFatal(t, int32(0)), + }, + { + name: "Millisecond max", + cql: "millisecond from @2020-01-01T14:30:25.999", + wantResult: newOrFatal(t, int32(999)), + }, + + // Timezone offset extraction tests + { + name: "Timezone offset from DateTime with positive offset", + cql: "timezoneoffset from @2020-03-15T14:30:25.123+05:00", + wantResult: newOrFatal(t, 5.0), + }, + { + name: "Timezone offset from DateTime with negative offset", + cql: "timezoneoffset from @2020-03-15T14:30:25.123-07:00", + wantResult: newOrFatal(t, -7.0), + }, + { + name: "Timezone offset from DateTime with UTC", + cql: "timezoneoffset from @2020-03-15T14:30:25.123Z", + wantResult: newOrFatal(t, 0.0), + }, + } + + 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 { + if tc.wantErr { + return + } + t.Fatalf("Parse returned unexpected error: %v", err) + } + if tc.wantErr { + t.Fatalf("Parse succeeded, expected error") + } + + 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 TestDateTimeComponentFromPrecisionLogic(t *testing.T) { + tests := []struct { + name string + cql string + wantResult result.Value + }{ + // Test precision logic with different Date precisions + { + name: "Year from year-precision Date", + cql: "year from @2020", + wantResult: newOrFatal(t, int32(2020)), + }, + { + name: "Year from month-precision Date", + cql: "year from @2020-03", + wantResult: newOrFatal(t, int32(2020)), + }, + { + name: "Year from day-precision Date", + cql: "year from @2020-03-15", + wantResult: newOrFatal(t, int32(2020)), + }, + { + name: "Month from month-precision Date", + cql: "month from @2020-03", + wantResult: newOrFatal(t, int32(3)), + }, + { + name: "Month from day-precision Date", + cql: "month from @2020-03-15", + wantResult: newOrFatal(t, int32(3)), + }, + { + name: "Day from day-precision Date", + cql: "day from @2020-03-15", + wantResult: newOrFatal(t, int32(15)), + }, + + // Test precision logic with different Time precisions + { + name: "Hour from hour-precision Time", + cql: "hour from @T14", + wantResult: newOrFatal(t, int32(14)), + }, + { + name: "Hour from minute-precision Time", + cql: "hour from @T14:30", + wantResult: newOrFatal(t, int32(14)), + }, + { + name: "Hour from second-precision Time", + cql: "hour from @T14:30:25", + wantResult: newOrFatal(t, int32(14)), + }, + { + name: "Hour from millisecond-precision Time", + cql: "hour from @T14:30:25.123", + wantResult: newOrFatal(t, int32(14)), + }, + { + name: "Minute from minute-precision Time", + cql: "minute from @T14:30", + wantResult: newOrFatal(t, int32(30)), + }, + { + name: "Minute from second-precision Time", + cql: "minute from @T14:30:25", + wantResult: newOrFatal(t, int32(30)), + }, + { + name: "Minute from millisecond-precision Time", + cql: "minute from @T14:30:25.123", + wantResult: newOrFatal(t, int32(30)), + }, + { + name: "Second from second-precision Time", + cql: "second from @T14:30:25", + wantResult: newOrFatal(t, int32(25)), + }, + { + name: "Second from millisecond-precision Time", + cql: "second from @T14:30:25.123", + wantResult: newOrFatal(t, int32(25)), + }, + { + name: "Millisecond from millisecond-precision Time", + cql: "millisecond from @T14:30:25.123", + wantResult: newOrFatal(t, int32(123)), + }, + } + + 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) + } + }) + } +} diff --git a/tests/spectests/exclusions/exclusions.go b/tests/spectests/exclusions/exclusions.go index 50d255e..e28d757 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -89,7 +89,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "CqlDateTimeOperatorsTest.xml": { GroupExcludes: []string{ // TODO: b/342061715 - unsupported operators. - "DateTimeComponentFrom", "Duration", // TODO: b/342064491 - runtime error: invalid memory address or nil pointer dereference. "SameAs", @@ -104,19 +103,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "DateSubtract2YearsAsMonths", "DateSubtract2YearsAsMonthsRem1", "DateSubtract1Year", - "DateTimeComponentFromYear", - "DateTimeComponentFromMonth", - "DateTimeComponentFromMonthMinBoundary", - "DateTimeComponentFromDay", - "DateTimeComponentFromHour", - "DateTimeComponentFromMinute", - "DateTimeComponentFromSecond", - "DateTimeComponentFromMillisecond", - "DateTimeComponentFromTimezone", - "TimeComponentFromHour", - "TimeComponentFromMinute", - "TimeComponentFromSecond", - "TimeComponentFromMilli", "TimeAdd5Hours", "TimeAdd1Minute", "TimeAdd1Second", @@ -380,8 +366,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { NamesExcludes: []string{ // TODO: b/342064012 - Uncertain result. "DateTimeUncertain", - // TODO: b/342061715 - unsupported operators. - "DateTimeTimeUnspecified", // TODO: b/343515613 - fails with unexpected result. Technically not supported. "StringUnicodeTest", // TODO: b/343515819 - fails with unexpected result. @@ -400,8 +384,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "ToTime", }, NamesExcludes: []string{ - // TODO: b/342061715 - unsupported operators. - "ToDateTimeTimeUnspecified", // TODO: b/343515613 - fails with unexpected result. Technically not supported. "ToDateTimeMalformed", }, From 18ebf03eed72cbca0f5f6ce0626f59e1933e66e5 Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Sun, 11 Oct 2026 10:57:15 -0400 Subject: [PATCH 2/3] #146 gofmt DateTimeComponentFrom changes --- interpreter/operator_datetime.go | 12 +++++----- parser/expressions.go | 2 +- .../operator_datetime_component_test.go | 24 +++++++++---------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/interpreter/operator_datetime.go b/interpreter/operator_datetime.go index 0220e3f..89e459a 100644 --- a/interpreter/operator_datetime.go +++ b/interpreter/operator_datetime.go @@ -882,7 +882,7 @@ func (i *interpreter) evalDateTimeComponentFrom(m model.IUnaryExpression, val re if err != nil { return result.Value{}, err } - + switch dtComponent.Component { case model.TIMEZONEOFFSET: // Timezone offset is always available for DateTime values regardless of precision @@ -919,13 +919,13 @@ func (i *interpreter) evalDateTimeComponentFrom(m model.IUnaryExpression, val re default: return result.Value{}, fmt.Errorf("unsupported date/time component: %v", dtComponent.Component) } - + case types.Date: d, err := result.ToDate(val) if err != nil { return result.Value{}, err } - + // Check if the precision is sufficient if !precisionGreaterOrEqual(dtComponent.Component, d.Precision) { return result.New(nil) @@ -944,13 +944,13 @@ func (i *interpreter) evalDateTimeComponentFrom(m model.IUnaryExpression, val re default: return result.Value{}, fmt.Errorf("unsupported date component: %v", dtComponent.Component) } - + case types.Time: t, err := result.ToTime(val) if err != nil { return result.Value{}, err } - + switch dtComponent.Component { case model.YEAR, model.MONTH, model.DAY: // These components don't exist in Time values @@ -982,7 +982,7 @@ func (i *interpreter) evalDateTimeComponentFrom(m model.IUnaryExpression, val re default: return result.Value{}, fmt.Errorf("unsupported time component: %v", dtComponent.Component) } - + default: return result.Value{}, fmt.Errorf("operand is not a DateTime, Date, or Time value, got %v", val.RuntimeType()) } diff --git a/parser/expressions.go b/parser/expressions.go index 274f4a4..5ea05d7 100644 --- a/parser/expressions.go +++ b/parser/expressions.go @@ -190,7 +190,7 @@ func (v *visitor) VisitParenthesizedTerm(ctx *cql.ParenthesizedTermContext) mode func (v *visitor) VisitTimeUnitExpressionTerm(ctx *cql.TimeUnitExpressionTermContext) model.IExpression { // parses statements like: "date from expression" or "year from date" dtc := ctx.GetChild(0).(*cql.DateTimeComponentContext) - + var dateTimeComponent string switch component := dtc.GetChild(0).(type) { case antlr.TerminalNode: diff --git a/tests/enginetests/operator_datetime_component_test.go b/tests/enginetests/operator_datetime_component_test.go index 1b0fdaf..4b6180a 100644 --- a/tests/enginetests/operator_datetime_component_test.go +++ b/tests/enginetests/operator_datetime_component_test.go @@ -48,7 +48,7 @@ func TestDateTimeComponentFrom(t *testing.T) { cql: "year from @2023", wantResult: newOrFatal(t, int32(2023)), }, - + // Month extraction tests { name: "Month from Date", @@ -65,7 +65,7 @@ func TestDateTimeComponentFrom(t *testing.T) { cql: "month from @2023-05", wantResult: newOrFatal(t, int32(5)), }, - + // Day extraction tests { name: "Day from Date", @@ -77,7 +77,7 @@ func TestDateTimeComponentFrom(t *testing.T) { cql: "day from @2020-03-15T14:30:25.123", wantResult: newOrFatal(t, int32(15)), }, - + // Hour extraction tests { name: "Hour from DateTime", @@ -89,7 +89,7 @@ func TestDateTimeComponentFrom(t *testing.T) { cql: "hour from @T14:30:25.123", wantResult: newOrFatal(t, int32(14)), }, - + // Minute extraction tests { name: "Minute from DateTime", @@ -101,7 +101,7 @@ func TestDateTimeComponentFrom(t *testing.T) { cql: "minute from @T14:30:25.123", wantResult: newOrFatal(t, int32(30)), }, - + // Second extraction tests { name: "Second from DateTime", @@ -113,7 +113,7 @@ func TestDateTimeComponentFrom(t *testing.T) { cql: "second from @T14:30:25.123", wantResult: newOrFatal(t, int32(25)), }, - + // Millisecond extraction tests { name: "Millisecond from DateTime", @@ -125,7 +125,7 @@ func TestDateTimeComponentFrom(t *testing.T) { cql: "millisecond from @T14:30:25.123", wantResult: newOrFatal(t, int32(123)), }, - + // Null handling tests { name: "Year from null", @@ -137,7 +137,7 @@ func TestDateTimeComponentFrom(t *testing.T) { cql: "month from (null as Date)", wantResult: newOrFatal(t, nil), }, - + // Precision boundary tests - requesting unavailable precision should return null { name: "Hour from Date returns null", @@ -174,7 +174,7 @@ func TestDateTimeComponentFrom(t *testing.T) { cql: "day from @T14:30:25.123", wantResult: newOrFatal(t, nil), }, - + // Precision insufficient tests - requesting higher precision than available { name: "Month from year-precision Date returns null", @@ -206,7 +206,7 @@ func TestDateTimeComponentFrom(t *testing.T) { cql: "millisecond from @T14:30:25", wantResult: newOrFatal(t, nil), }, - + // Edge cases { name: "Year from leap year", @@ -263,7 +263,7 @@ func TestDateTimeComponentFrom(t *testing.T) { cql: "millisecond from @2020-01-01T14:30:25.999", wantResult: newOrFatal(t, int32(999)), }, - + // Timezone offset extraction tests { name: "Timezone offset from DateTime with positive offset", @@ -345,7 +345,7 @@ func TestDateTimeComponentFromPrecisionLogic(t *testing.T) { cql: "day from @2020-03-15", wantResult: newOrFatal(t, int32(15)), }, - + // Test precision logic with different Time precisions { name: "Hour from hour-precision Time", From eaad1024e97b7eb99c9904c4caddf2213a9027ae Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Sun, 11 Oct 2026 11:08:54 -0400 Subject: [PATCH 3/3] #146 address review comments: resolve null operand, timezoneoffset from Time returns null Resolve the DateTimeComponentFrom operand against the Date, DateTime and Time overloads in the parser, so an untyped null is wrapped in As DateTime and a non-temporal operand fails at parse time. timezoneoffset from a Time now returns null like year, month and day. Remove a dead duplicate case. --- interpreter/operator_datetime.go | 7 +- parser/expressions.go | 100 +++++++----------- .../operator_datetime_component_test.go | 20 ++++ 3 files changed, 61 insertions(+), 66 deletions(-) diff --git a/interpreter/operator_datetime.go b/interpreter/operator_datetime.go index 89e459a..e195e4f 100644 --- a/interpreter/operator_datetime.go +++ b/interpreter/operator_datetime.go @@ -911,11 +911,6 @@ func (i *interpreter) evalDateTimeComponentFrom(m model.IUnaryExpression, val re return result.New(int32(dt.Date.Second())) case model.MILLISECOND: return result.New(int32(dt.Date.Nanosecond() / 1000000)) - case model.TIMEZONEOFFSET: - // Extract timezone offset in hours from UTC - _, offset := dt.Date.Zone() - offsetHours := float64(offset) / 3600.0 - return result.New(offsetHours) default: return result.Value{}, fmt.Errorf("unsupported date/time component: %v", dtComponent.Component) } @@ -952,7 +947,7 @@ func (i *interpreter) evalDateTimeComponentFrom(m model.IUnaryExpression, val re } switch dtComponent.Component { - case model.YEAR, model.MONTH, model.DAY: + case model.YEAR, model.MONTH, model.DAY, model.TIMEZONEOFFSET: // These components don't exist in Time values return result.New(nil) case model.HOUR: diff --git a/parser/expressions.go b/parser/expressions.go index 5ea05d7..50c60e9 100644 --- a/parser/expressions.go +++ b/parser/expressions.go @@ -203,80 +203,60 @@ func (v *visitor) VisitTimeUnitExpressionTerm(ctx *cql.TimeUnitExpressionTermCon return v.badExpression(fmt.Sprintf("unsupported date time component conversion (e.g. X in 'X from expression'). got: %s, supported components: date, year, month, day, hour, minute, second, millisecond", dtc.GetText()), ctx) } - switch dateTimeComponent { - case "date": + operand := v.VisitExpression(ctx.ExpressionTerm()) + if dateTimeComponent == "date" { return &model.ToDate{ UnaryExpression: &model.UnaryExpression{ - Operand: v.VisitExpression(ctx.ExpressionTerm()), + Operand: operand, Expression: model.ResultType(types.Date), }, } + } + + resultType := types.Integer + var component model.DateTimePrecision + switch dateTimeComponent { case "year": - return &model.DateTimeComponentFrom{ - UnaryExpression: &model.UnaryExpression{ - Operand: v.VisitExpression(ctx.ExpressionTerm()), - Expression: model.ResultType(types.Integer), - }, - Component: model.YEAR, - } + component = model.YEAR case "month": - return &model.DateTimeComponentFrom{ - UnaryExpression: &model.UnaryExpression{ - Operand: v.VisitExpression(ctx.ExpressionTerm()), - Expression: model.ResultType(types.Integer), - }, - Component: model.MONTH, - } + component = model.MONTH case "day": - return &model.DateTimeComponentFrom{ - UnaryExpression: &model.UnaryExpression{ - Operand: v.VisitExpression(ctx.ExpressionTerm()), - Expression: model.ResultType(types.Integer), - }, - Component: model.DAY, - } + component = model.DAY case "hour": - return &model.DateTimeComponentFrom{ - UnaryExpression: &model.UnaryExpression{ - Operand: v.VisitExpression(ctx.ExpressionTerm()), - Expression: model.ResultType(types.Integer), - }, - Component: model.HOUR, - } + component = model.HOUR case "minute": - return &model.DateTimeComponentFrom{ - UnaryExpression: &model.UnaryExpression{ - Operand: v.VisitExpression(ctx.ExpressionTerm()), - Expression: model.ResultType(types.Integer), - }, - Component: model.MINUTE, - } + component = model.MINUTE case "second": - return &model.DateTimeComponentFrom{ - UnaryExpression: &model.UnaryExpression{ - Operand: v.VisitExpression(ctx.ExpressionTerm()), - Expression: model.ResultType(types.Integer), - }, - Component: model.SECOND, - } + component = model.SECOND case "millisecond": - return &model.DateTimeComponentFrom{ - UnaryExpression: &model.UnaryExpression{ - Operand: v.VisitExpression(ctx.ExpressionTerm()), - Expression: model.ResultType(types.Integer), - }, - Component: model.MILLISECOND, - } + component = model.MILLISECOND case "timezoneoffset": - return &model.DateTimeComponentFrom{ - UnaryExpression: &model.UnaryExpression{ - Operand: v.VisitExpression(ctx.ExpressionTerm()), - Expression: model.ResultType(types.Decimal), - }, - Component: model.TIMEZONEOFFSET, - } + component = model.TIMEZONEOFFSET + resultType = types.Decimal + default: + return v.badExpression(fmt.Sprintf("unsupported date time component conversion (e.g. X in 'X from expression'). got: %s, supported components: date, year, month, day, hour, minute, second, millisecond, timezoneoffset", dtc.GetText()), ctx) + } + + overloads := []convert.Overload[types.IType]{ + {Operands: []types.IType{types.Date}}, + {Operands: []types.IType{types.DateTime}}, + {Operands: []types.IType{types.Time}}, + } + if operand.GetResultType().Equal(types.Any) { + // A null literal matches all three overloads equally, so resolve it as DateTime. + overloads = overloads[1:2] + } + matched, err := convert.OverloadMatch([]model.IExpression{operand}, overloads, v.modelInfo, "DateTimeComponentFrom") + if err != nil { + return v.badExpression(err.Error(), ctx) + } + return &model.DateTimeComponentFrom{ + UnaryExpression: &model.UnaryExpression{ + Operand: matched.WrappedOperands[0], + Expression: model.ResultType(resultType), + }, + Component: component, } - return v.badExpression(fmt.Sprintf("unsupported date time component conversion (e.g. X in 'X from expression'). got: %s, supported components: date, year, month, day, hour, minute, second, millisecond, timezoneoffset", dtc.GetText()), ctx) } func (v *visitor) VisitTupleSelectorTerm(ctx *cql.TupleSelectorTermContext) model.IExpression { diff --git a/tests/enginetests/operator_datetime_component_test.go b/tests/enginetests/operator_datetime_component_test.go index 4b6180a..1eea279 100644 --- a/tests/enginetests/operator_datetime_component_test.go +++ b/tests/enginetests/operator_datetime_component_test.go @@ -137,6 +137,21 @@ func TestDateTimeComponentFrom(t *testing.T) { cql: "month from (null as Date)", wantResult: newOrFatal(t, nil), }, + { + name: "Year from untyped null", + cql: "year from null", + wantResult: newOrFatal(t, nil), + }, + { + name: "Second from untyped null", + cql: "second from null", + wantResult: newOrFatal(t, nil), + }, + { + name: "Year from Integer fails to resolve", + cql: "year from 5", + wantErr: true, + }, // Precision boundary tests - requesting unavailable precision should return null { @@ -174,6 +189,11 @@ func TestDateTimeComponentFrom(t *testing.T) { cql: "day from @T14:30:25.123", wantResult: newOrFatal(t, nil), }, + { + name: "Timezone offset from Time returns null", + cql: "timezoneoffset from @T14:30", + wantResult: newOrFatal(t, nil), + }, // Precision insufficient tests - requesting higher precision than available {