diff --git a/interpreter/operator_datetime.go b/interpreter/operator_datetime.go index c99f729..c4a7575 100644 --- a/interpreter/operator_datetime.go +++ b/interpreter/operator_datetime.go @@ -18,6 +18,7 @@ import ( "cmp" "errors" "fmt" + "math" "time" "github.com/google/cql/model" @@ -327,6 +328,133 @@ func evalDifferenceBetweenDate(b model.IBinaryExpression, lObj, rObj result.Valu return dateTimeDifference(l, r, p) } +// calculateMinDuration calculates the minimum possible duration between two DateTimes +// considering their precision uncertainty +func calculateMinDuration(l, r result.DateTime, p model.DateTimePrecision) (int, error) { + // For minimum duration, we want the latest possible start and earliest possible end + // This means expanding the left date to its latest possible value and right to its earliest + maxLeft := expandDateTimeToLatest(l) + minRight := expandDateTimeToEarliest(r) + + return dateTimeDuration(maxLeft, minRight, p) +} + +// calculateMaxDuration calculates the maximum possible duration between two DateTimes +// considering their precision uncertainty +func calculateMaxDuration(l, r result.DateTime, p model.DateTimePrecision) (int, error) { + // For maximum duration, we want the earliest possible start and latest possible end + // This means expanding the left date to its earliest possible value and right to its latest + minLeft := expandDateTimeToEarliest(l) + maxRight := expandDateTimeToLatest(r) + + return dateTimeDuration(minLeft, maxRight, p) +} + +// expandDateTimeToEarliest expands a DateTime to its earliest possible value given its precision +func expandDateTimeToEarliest(dt result.DateTime) result.DateTime { + // If precision is already at the finest level, return as-is + if dt.Precision == model.MILLISECOND { + return dt + } + + // Set all unspecified components to their minimum values + year := dt.Date.Year() + month := dt.Date.Month() + day := dt.Date.Day() + hour := dt.Date.Hour() + minute := dt.Date.Minute() + second := dt.Date.Second() + nanosecond := dt.Date.Nanosecond() + + switch dt.Precision { + case model.YEAR: + month = 1 + day = 1 + hour = 0 + minute = 0 + second = 0 + nanosecond = 0 + case model.MONTH: + day = 1 + hour = 0 + minute = 0 + second = 0 + nanosecond = 0 + case model.DAY: + hour = 0 + minute = 0 + second = 0 + nanosecond = 0 + case model.HOUR: + minute = 0 + second = 0 + nanosecond = 0 + case model.MINUTE: + second = 0 + nanosecond = 0 + case model.SECOND: + nanosecond = 0 + } + + return result.DateTime{ + Date: time.Date(year, month, day, hour, minute, second, nanosecond, dt.Date.Location()), + Precision: dt.Precision, + } +} + +// expandDateTimeToLatest expands a DateTime to its latest possible value given its precision +func expandDateTimeToLatest(dt result.DateTime) result.DateTime { + // If precision is already at the finest level, return as-is + if dt.Precision == model.MILLISECOND { + return dt + } + + // Set all unspecified components to their maximum values + year := dt.Date.Year() + month := dt.Date.Month() + day := dt.Date.Day() + hour := dt.Date.Hour() + minute := dt.Date.Minute() + second := dt.Date.Second() + nanosecond := dt.Date.Nanosecond() + + switch dt.Precision { + case model.YEAR: + month = 12 + day = 31 + hour = 23 + minute = 59 + second = 59 + nanosecond = 999 * int(time.Millisecond/time.Nanosecond) + case model.MONTH: + // Get the last day of the month + day = time.Date(year, month+1, 0, 0, 0, 0, 0, dt.Date.Location()).Day() + hour = 23 + minute = 59 + second = 59 + nanosecond = 999 * int(time.Millisecond/time.Nanosecond) + case model.DAY: + hour = 23 + minute = 59 + second = 59 + nanosecond = 999 * int(time.Millisecond/time.Nanosecond) + case model.HOUR: + minute = 59 + second = 59 + nanosecond = 999 * int(time.Millisecond/time.Nanosecond) + case model.MINUTE: + second = 59 + nanosecond = 999 * int(time.Millisecond/time.Nanosecond) + case model.SECOND: + nanosecond = 999 * int(time.Millisecond/time.Nanosecond) + } + + return result.DateTime{ + Date: time.Date(year, month, day, hour, minute, second, nanosecond, dt.Date.Location()), + Precision: dt.Precision, + } +} + // difference in _precision_ between(left DateTime, right DateTime) Integer // https://cql.hl7.org/09-b-cqlreference.html#difference // Returns the number of boundaries crossed between two datetimes. @@ -931,3 +1059,167 @@ func convertQuantityUpToPrecision(q result.Quantity, wantPrecision model.DateTim } return result.Quantity{}, fmt.Errorf("error: failed to reach desired precision when adding Date/DateTime to Quantity with precisions want: %v, got: %v", wantPrecision, q.Unit) } + +// duration in _precision_ of(argument Interval) Integer +// duration in _precision_ of(argument Interval) Integer +// https://cql.hl7.org/09-b-cqlreference.html#duration +// Returns the duration of the interval in the specified precision. +func (i *interpreter) evalDuration(m model.IUnaryExpression, intervalObj result.Value) (result.Value, error) { + duration := m.(*model.Duration) + if result.IsNull(intervalObj) { + return result.New(nil) + } + + interval, err := result.ToInterval(intervalObj) + if err != nil { + return result.Value{}, err + } + + startVal, err := start(intervalObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + endVal, err := end(intervalObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + + switch interval.StaticType.PointType { + case types.Date: + return durationBetween(startVal, endVal, duration.Precision, dateDurationPrecisions) + case types.DateTime: + return durationBetween(startVal, endVal, duration.Precision, dateTimeDurationPrecisions) + default: + return result.Value{}, fmt.Errorf("unsupported point type for duration got: %v, expected: types.Date, types.DateTime", interval.StaticType.PointType) + } +} + +var ( + dateDurationPrecisions = []model.DateTimePrecision{model.YEAR, model.MONTH, model.WEEK, model.DAY} + dateTimeDurationPrecisions = []model.DateTimePrecision{model.YEAR, model.MONTH, model.WEEK, model.DAY, model.HOUR, model.MINUTE, model.SECOND, model.MILLISECOND} +) + +// _precision_ between(low Date, high Date) Integer +// https://cql.hl7.org/09-b-cqlreference.html#duration +// Returns the number of whole calendar periods for the specified precision between the first and second arguments. +func evalDurationBetweenDate(b model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + m := b.(*model.DurationBetween) + return durationBetween(lObj, rObj, model.DateTimePrecision(m.Precision), dateDurationPrecisions) +} + +// _precision_ between(low DateTime, high DateTime) Integer +// https://cql.hl7.org/09-b-cqlreference.html#duration +// Returns the number of whole calendar periods for the specified precision between the first and second arguments. +func evalDurationBetweenDateTime(b model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + m := b.(*model.DurationBetween) + return durationBetween(lObj, rObj, model.DateTimePrecision(m.Precision), dateTimeDurationPrecisions) +} + +// durationBetween returns the whole periods between two Date or DateTime values, or an +// uncertainty interval when an operand is too imprecise to give a single answer. +func durationBetween(lObj, rObj result.Value, p model.DateTimePrecision, allowed []model.DateTimePrecision) (result.Value, error) { + if result.IsNull(lObj) || result.IsNull(rObj) { + return result.New(nil) + } + if err := validatePrecision(p, allowed); err != nil { + return result.Value{}, err + } + l, r, err := applyToValues(lObj, rObj, result.ToDateTime) + if err != nil { + return result.Value{}, err + } + + var hasUncertainty bool + switch p { + case model.YEAR: + hasUncertainty = l.Precision == model.YEAR || r.Precision == model.YEAR + case model.MONTH: + hasUncertainty = !precisionGreaterOrEqual(model.DAY, l.Precision) || !precisionGreaterOrEqual(model.DAY, r.Precision) + default: + // Uncertain only when an operand is coarser than the requested precision. + hasUncertainty = !precisionGreaterOrEqual(p, l.Precision) || !precisionGreaterOrEqual(p, r.Precision) + } + + if !hasUncertainty { + // Drop components finer than each value's precision, such as the time a Date may carry. + d, err := dateTimeDuration(expandDateTimeToEarliest(l), expandDateTimeToEarliest(r), p) + if err != nil { + return result.Value{}, err + } + return result.New(d) + } + + minDuration, err := calculateMinDuration(l, r, p) + if err != nil { + return result.Value{}, err + } + maxDuration, err := calculateMaxDuration(l, r, p) + if err != nil { + return result.Value{}, err + } + if minDuration == maxDuration { + return result.New(minDuration) + } + lowVal, err := result.New(minDuration) + if err != nil { + return result.Value{}, err + } + highVal, err := result.New(maxDuration) + if err != nil { + return result.Value{}, err + } + return result.New(result.Interval{ + Low: lowVal, + High: highVal, + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }) +} + +// dateTimeDuration returns the number of whole periods of the given precision from l to r, +// truncated toward zero. Unlike dateTimeDifference it does not count boundaries crossed. +func dateTimeDuration(l, r result.DateTime, p model.DateTimePrecision) (int, error) { + left, right := l.Date, r.Date.In(l.Date.Location()) + + var d int64 + switch p { + case model.YEAR, model.MONTH: + months := int64(12*(right.Year()-left.Year()) + int(right.Month()) - int(left.Month())) + // Compare the rest of the value to see if the last month is complete. + leftRest := time.Date(2000, time.January, left.Day(), left.Hour(), left.Minute(), left.Second(), left.Nanosecond(), time.UTC) + rightRest := time.Date(2000, time.January, right.Day(), right.Hour(), right.Minute(), right.Second(), right.Nanosecond(), time.UTC) + if months > 0 && rightRest.Before(leftRest) { + months-- + } else if months < 0 && rightRest.After(leftRest) { + months++ + } + d = months + if p == model.YEAR { + d = months / 12 + } + default: + // UnixMilli does not saturate like time.Sub, which overflows at about 292 years. + elapsed := right.UnixMilli() - left.UnixMilli() + switch p { + case model.WEEK: + d = elapsed / (7 * 24 * time.Hour).Milliseconds() + case model.DAY: + d = elapsed / (24 * time.Hour).Milliseconds() + case model.HOUR: + d = elapsed / time.Hour.Milliseconds() + case model.MINUTE: + d = elapsed / time.Minute.Milliseconds() + case model.SECOND: + d = elapsed / time.Second.Milliseconds() + case model.MILLISECOND: + d = elapsed + default: + return 0, fmt.Errorf("unsupported precision for duration: %v", p) + } + } + if d > math.MaxInt32 || d < math.MinInt32 { + return 0, fmt.Errorf("duration in %v overflows Integer", p) + } + return int(d), nil +} diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index af782da..be3dcd3 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -813,6 +813,17 @@ func (i *interpreter) unaryOverloads(m model.IUnaryExpression) ([]convert.Overlo Result: evalWidthInterval, }, }, nil + case *model.Duration: + return []convert.Overload[evalUnarySignature]{ + { + Operands: []types.IType{&types.Interval{PointType: types.Date}}, + Result: i.evalDuration, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.DateTime}}, + Result: i.evalDuration, + }, + }, nil default: return nil, fmt.Errorf("unsupported Unary Expression %v", m.GetName()) } @@ -1083,6 +1094,25 @@ func (i *interpreter) binaryOverloads(m model.IBinaryExpression) ([]convert.Over Result: evalDifferenceBetweenDateTime, }, }, nil + case *model.DurationBetween: + return []convert.Overload[evalBinarySignature]{ + { + Operands: []types.IType{types.Date, types.Date}, + Result: evalDurationBetweenDate, + }, + { + Operands: []types.IType{types.DateTime, types.DateTime}, + Result: evalDurationBetweenDateTime, + }, + { + Operands: []types.IType{types.DateTime, types.Date}, + Result: evalDurationBetweenDateTime, + }, + { + Operands: []types.IType{types.Date, types.DateTime}, + Result: evalDurationBetweenDateTime, + }, + }, nil case *model.In: // TODO(b/301606416): Support all other In operator overloads. return []convert.Overload[evalBinarySignature]{ diff --git a/parser/operator_expressions.go b/parser/operator_expressions.go index b4d434b..574872c 100644 --- a/parser/operator_expressions.go +++ b/parser/operator_expressions.go @@ -519,15 +519,67 @@ func (v *visitor) VisitBetweenExpression(ctx *cql.BetweenExpressionContext) mode } func (v *visitor) VisitDurationBetweenExpression(ctx *cql.DurationBetweenExpressionContext) model.IExpression { - // Get the operands - left := v.VisitExpression(ctx.ExpressionTerm(0)) - right := v.VisitExpression(ctx.ExpressionTerm(1)) + precision := stringToPrecision(pluralToSingularDateTimePrecision(ctx.PluralDateTimePrecision().GetText())) - // Return a plain BinaryExpression as expected by the tests - return &model.BinaryExpression{ - Expression: model.ResultType(types.Integer), - Operands: []model.IExpression{left, right}, + // Check if this is "duration in X of expr" or "duration in X between expr1 and expr2" + // Look for the keyword "between" - if it exists, we have two expressions + hasBetween := false + for i := 0; i < ctx.GetChildCount(); i++ { + child := ctx.GetChild(i) + if t, ok := child.(antlr.TerminalNode); ok && t.GetText() == "between" { + hasBetween = true + break + } } + + if hasBetween { + // This is "duration in X between expr1 and expr2" + exprTerms := ctx.AllExpressionTerm() + if len(exprTerms) == 2 { + // Get operands + left := v.VisitExpression(exprTerms[0]) + right := v.VisitExpression(exprTerms[1]) + + // Return a DurationBetween model + return &model.DurationBetween{ + Precision: precision, + BinaryExpression: &model.BinaryExpression{ + Operands: []model.IExpression{left, right}, + Expression: model.ResultType(types.Integer), + }, + } + } + } else { + // This is "duration in X of expr" + exprTerm := ctx.ExpressionTerm(0) + if exprTerm != nil { + // Get the interval expression + intervalExpr := v.VisitExpression(exprTerm) + + // Extract the start of the interval + startExpr, err := v.resolveFunction("", "Start", []model.IExpression{intervalExpr}, false) + if err != nil { + return v.badExpression(err.Error(), ctx) + } + + // Extract the end of the interval + endExpr, err := v.resolveFunction("", "End", []model.IExpression{intervalExpr}, false) + if err != nil { + return v.badExpression(err.Error(), ctx) + } + + // Return a DurationBetween model with Start and End as operands + return &model.DurationBetween{ + Precision: precision, + BinaryExpression: &model.BinaryExpression{ + Operands: []model.IExpression{startExpr, endExpr}, + Expression: model.ResultType(types.Integer), + }, + } + } + } + + return v.badExpression("unsupported duration between expression", ctx) } func (v *visitor) VisitExistenceExpression(ctx *cql.ExistenceExpressionContext) model.IExpression { @@ -722,15 +774,43 @@ func (v *visitor) VisitPointExtractorExpressionTerm(ctx *cql.PointExtractorExpre } func (v *visitor) VisitDurationExpressionTerm(ctx *cql.DurationExpressionTermContext) model.IExpression { + // Get the precision from the context + precision := model.UNSETDATETIMEPRECISION + for i := 0; i < ctx.GetChildCount(); i++ { + child := ctx.GetChild(i) + if childText, ok := getStringFromChild(child); ok { + // Check for all possible plural forms + switch childText { + case "days": + precision = model.DAY + case "months": + precision = model.MONTH + case "years": + precision = model.YEAR + case "hours": + precision = model.HOUR + case "minutes": + precision = model.MINUTE + case "seconds": + precision = model.SECOND + case "milliseconds": + precision = model.MILLISECOND + case "weeks": + precision = model.WEEK + } + } + } + // Get the interval operand intervalExpr := v.VisitExpression(ctx.ExpressionTerm()) - // Return a UnaryExpression for Duration as expected by the tests - resultType := types.Integer - - return &model.UnaryExpression{ - Operand: intervalExpr, - Expression: model.ResultType(resultType), + // Return a Duration model + return &model.Duration{ + UnaryExpression: &model.UnaryExpression{ + Operand: intervalExpr, + Expression: model.ResultType(types.Integer), + }, + Precision: precision, } } diff --git a/parser/operator_expressions_test.go b/parser/operator_expressions_test.go index 3be69ae..1f6b754 100644 --- a/parser/operator_expressions_test.go +++ b/parser/operator_expressions_test.go @@ -1146,29 +1146,35 @@ func TestOperatorExpressions(t *testing.T) { { name: "Duration Expression Term", cql: "duration in days of Interval[@2010-01-01, @2020-01-01]", - want: &model.UnaryExpression{ - Operand: &model.Interval{ - Low: model.NewLiteral("@2010-01-01", types.Date), - High: model.NewLiteral("@2020-01-01", types.Date), - Expression: model.ResultType(&types.Interval{PointType: types.Date}), - LowInclusive: true, - HighInclusive: true, + want: &model.Duration{ + Precision: model.DAY, + UnaryExpression: &model.UnaryExpression{ + Operand: &model.Interval{ + Low: model.NewLiteral("@2010-01-01", types.Date), + High: model.NewLiteral("@2020-01-01", types.Date), + Expression: model.ResultType(&types.Interval{PointType: types.Date}), + LowInclusive: true, + HighInclusive: true, + }, + Expression: model.ResultType(types.Integer), }, - Expression: model.ResultType(types.Integer), }, }, { name: "Duration Expression Term in Years", cql: "duration in years of Interval[@2010-01-01, @2020-01-01]", - want: &model.UnaryExpression{ - Operand: &model.Interval{ - Low: model.NewLiteral("@2010-01-01", types.Date), - High: model.NewLiteral("@2020-01-01", types.Date), - Expression: model.ResultType(&types.Interval{PointType: types.Date}), - LowInclusive: true, - HighInclusive: true, + want: &model.Duration{ + Precision: model.YEAR, + UnaryExpression: &model.UnaryExpression{ + Operand: &model.Interval{ + Low: model.NewLiteral("@2010-01-01", types.Date), + High: model.NewLiteral("@2020-01-01", types.Date), + Expression: model.ResultType(&types.Interval{PointType: types.Date}), + LowInclusive: true, + HighInclusive: true, + }, + Expression: model.ResultType(types.Integer), }, - Expression: model.ResultType(types.Integer), }, }, { @@ -1224,23 +1230,29 @@ func TestOperatorExpressions(t *testing.T) { { name: "Duration Between Expression", cql: "duration in days between @2010-01-01 and @2020-01-01", - want: &model.BinaryExpression{ - Operands: []model.IExpression{ - model.NewLiteral("@2010-01-01", types.Date), - model.NewLiteral("@2020-01-01", types.Date), + want: &model.DurationBetween{ + Precision: model.DAY, + BinaryExpression: &model.BinaryExpression{ + Operands: []model.IExpression{ + model.NewLiteral("@2010-01-01", types.Date), + model.NewLiteral("@2020-01-01", types.Date), + }, + Expression: model.ResultType(types.Integer), }, - Expression: model.ResultType(types.Integer), }, }, { name: "Duration Between Expression in Months", cql: "duration in months between @2010-01-01 and @2020-01-01", - want: &model.BinaryExpression{ - Operands: []model.IExpression{ - model.NewLiteral("@2010-01-01", types.Date), - model.NewLiteral("@2020-01-01", types.Date), + want: &model.DurationBetween{ + Precision: model.MONTH, + BinaryExpression: &model.BinaryExpression{ + Operands: []model.IExpression{ + model.NewLiteral("@2010-01-01", types.Date), + model.NewLiteral("@2020-01-01", types.Date), + }, + Expression: model.ResultType(types.Integer), }, - Expression: model.ResultType(types.Integer), }, }, } diff --git a/parser/operators.go b/parser/operators.go index 1f616c2..ae00d5b 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -2475,7 +2475,7 @@ func durationModel(precision model.DateTimePrecision) func() model.IExpression { return func() model.IExpression { return &model.Duration{ UnaryExpression: &model.UnaryExpression{ - Expression: model.ResultType(types.Quantity), + Expression: model.ResultType(types.Integer), }, Precision: precision, } diff --git a/tests/enginetests/operator_datetime_test.go b/tests/enginetests/operator_datetime_test.go index 28ba99b..7651b3d 100644 --- a/tests/enginetests/operator_datetime_test.go +++ b/tests/enginetests/operator_datetime_test.go @@ -1080,3 +1080,386 @@ func TestDateTimeConstructor_Errors(t *testing.T) { }) } } + +func TestDurationOperators(t *testing.T) { + tests := []struct { + name string + cql string + wantResult result.Value + }{ + // Duration of Interval tests + { + name: "Duration in days of date interval", + cql: "duration in days of Interval[@2012-01-01, @2012-01-31]", + wantResult: newOrFatal(t, 30), + }, + { + name: "Duration in months of date interval", + cql: "duration in months of Interval[@2012-01-01, @2012-03-31]", + wantResult: newOrFatal(t, 2), + }, + { + name: "Duration in years of date interval", + cql: "duration in years of Interval[@2012-01-01, @2014-12-31]", + wantResult: newOrFatal(t, 2), + }, + { + name: "Duration in hours of datetime interval", + cql: "duration in hours of Interval[@2012-01-01T00:00:00, @2012-01-01T12:00:00]", + wantResult: newOrFatal(t, 12), + }, + { + name: "Duration in minutes of datetime interval", + cql: "duration in minutes of Interval[@2012-01-01T00:00:00, @2012-01-01T02:30:00]", + wantResult: newOrFatal(t, 150), + }, + { + name: "Duration in seconds of datetime interval", + cql: "duration in seconds of Interval[@2012-01-01T00:00:00, @2012-01-01T00:05:30]", + wantResult: newOrFatal(t, 330), + }, + + // Duration Between tests - Date + { + name: "Years between dates", + cql: "years between @2005-05-01 and @2010-04-30", + wantResult: newOrFatal(t, 4), + }, + { + name: "Years between dates - same year", + cql: "years between @2005-05-01 and @2005-12-31", + wantResult: newOrFatal(t, 0), + }, + { + name: "Years between dates - negative", + cql: "years between @2010-05-01 and @2005-04-30", + wantResult: newOrFatal(t, -5), + }, + { + name: "Months between dates", + cql: "months between @2014-01-31 and @2014-02-01", + wantResult: newOrFatal(t, 0), + }, + { + name: "Months between dates - multiple months", + cql: "months between @2014-01-01 and @2014-06-01", + wantResult: newOrFatal(t, 5), + }, + { + name: "Days between dates", + cql: "days between @2012-01-01 and @2012-01-31", + wantResult: newOrFatal(t, 30), + }, + { + name: "Days between dates - same day", + cql: "days between @2012-01-01 and @2012-01-01", + wantResult: newOrFatal(t, 0), + }, + + // Duration Between tests - DateTime + { + name: "Years between datetimes", + cql: "years between DateTime(2005, 5, 1) and DateTime(2010, 4, 30)", + wantResult: newOrFatal(t, 4), + }, + { + name: "Months between datetimes", + cql: "months between DateTime(2014, 1, 31) and DateTime(2014, 2, 1)", + wantResult: newOrFatal(t, 0), + }, + { + name: "Days between datetimes", + cql: "days between DateTime(2010, 10, 12, 12, 5) and DateTime(2008, 8, 15, 8, 8)", + wantResult: newOrFatal(t, -788), + }, + { + name: "Hours between datetimes", + cql: "hours between DateTime(2000, 4, 1, 12) and DateTime(2000, 4, 1, 20)", + wantResult: newOrFatal(t, 8), + }, + { + name: "Minutes between datetimes", + cql: "minutes between DateTime(2005, 12, 10, 5, 16) and DateTime(2005, 12, 10, 5, 25)", + wantResult: newOrFatal(t, 9), + }, + { + name: "Seconds between datetimes", + cql: "seconds between DateTime(2000, 10, 10, 10, 5, 45) and DateTime(2000, 10, 10, 10, 5, 50)", + wantResult: newOrFatal(t, 5), + }, + { + name: "Milliseconds between datetimes", + cql: "milliseconds between DateTime(2000, 10, 10, 10, 5, 45, 500) and DateTime(2000, 10, 10, 10, 5, 45, 900)", + wantResult: newOrFatal(t, 400), + }, + + // Duration Between with uncertainty (year precision only) + { + name: "Years between with uncertainty", + cql: "years between DateTime(2005) and DateTime(2010)", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 4), + High: newOrFatal(t, 5), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + + // Edge cases + { + name: "Duration with null interval", + cql: "duration in days of (null as Interval)", + wantResult: newOrFatal(t, nil), + }, + + // Weeks + { + name: "Weeks between dates", + cql: "weeks between @2012-03-10 and @2012-03-24", + wantResult: newOrFatal(t, 2), + }, + { + name: "Weeks between datetimes", + cql: "weeks between DateTime(2012, 3, 10, 22, 5, 9) and DateTime(2012, 3, 24, 7, 19, 33)", + wantResult: newOrFatal(t, 1), + }, + + // Complex interval calculations + { + name: "Duration in days of complex interval", + cql: "duration in days of Interval[start of Interval[@2012-01-01, @2012-01-15], end of Interval[@2012-01-10, @2012-01-31]]", + wantResult: newOrFatal(t, 30), + }, + + // Leap year handling + { + name: "Duration across leap year", + cql: "days between @2012-02-28 and @2012-03-01", + wantResult: newOrFatal(t, 2), // 2012 is a leap year + }, + { + name: "Duration across non-leap year", + cql: "days between @2013-02-28 and @2013-03-01", + wantResult: newOrFatal(t, 1), // 2013 is not a leap year + }, + + // Month boundary handling + { + name: "Duration across month boundary", + cql: "days between @2012-01-31 and @2012-02-01", + wantResult: newOrFatal(t, 1), + }, + { + name: "Duration in months across year boundary", + cql: "months between @2012-11-01 and @2013-02-01", + wantResult: newOrFatal(t, 3), + }, + + // Year boundary handling + { + name: "Duration across year boundary", + cql: "days between @2012-12-31 and @2013-01-01", + wantResult: newOrFatal(t, 1), + }, + { + name: "Duration in years across multiple years", + cql: "years between @2010-01-01 and @2015-01-01", + wantResult: newOrFatal(t, 5), + }, + } + + 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) + } + + gotResult := getTESTRESULTWithSources(t, results) + if diff := cmp.Diff(tc.wantResult, gotResult, protocmp.Transform()); diff != "" { + t.Errorf("Eval diff (-want +got)\n%v", diff) + } + }) + } +} + +func TestDurationBetweenWithTimezones(t *testing.T) { + tests := []struct { + name string + cql string + wantResult result.Value + }{ + { + name: "Hours between with timezone offset", + cql: "hours between @2017-03-12T01:00:00-07:00 and @2017-03-12T03:00:00-06:00", + wantResult: newOrFatal(t, 1), + }, + { + name: "Minutes between with timezone offset", + cql: "minutes between @2017-11-05T01:30:00-06:00 and @2017-11-05T01:15:00-07:00", + wantResult: newOrFatal(t, 45), + }, + { + name: "Days between with timezone offset", + cql: "days between @2017-03-12T00:00:00-07:00 and @2017-03-13T00:00:00-06:00", + wantResult: newOrFatal(t, 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 { + 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) + } + + gotResult := getTESTRESULTWithSources(t, results) + if diff := cmp.Diff(tc.wantResult, gotResult, protocmp.Transform()); diff != "" { + t.Errorf("Eval diff (-want +got)\n%v", diff) + } + }) + } +} + +func TestDurationCountsWholePeriods(t *testing.T) { + tests := []struct { + name string + cql string + wantResult result.Value + }{ + // Difference counts boundaries crossed and must not change with duration. + { + name: "Difference in years crosses one boundary", + cql: "difference in years between @2014-12-31 and @2015-01-01", + wantResult: newOrFatal(t, 1), + }, + { + name: "Difference in months crosses one boundary", + cql: "difference in months between @2014-01-31 and @2014-02-01", + wantResult: newOrFatal(t, 1), + }, + { + name: "Difference in years between datetimes crosses one boundary", + cql: "difference in years between DateTime(2014, 12, 31) and DateTime(2015, 1, 1)", + wantResult: newOrFatal(t, 1), + }, + { + name: "Years between dates one day apart", + cql: "years between @2014-12-31 and @2015-01-01", + wantResult: newOrFatal(t, 0), + }, + { + name: "Months between negative truncates toward zero", + cql: "months between @2014-03-05 and @2014-02-04", + wantResult: newOrFatal(t, -1), + }, + { + name: "Months between negative from month end", + cql: "months between @2014-03-31 and @2014-02-28", + wantResult: newOrFatal(t, -1), + }, + { + name: "Years between negative truncates toward zero", + cql: "years between @2014-03-05 and @2013-03-04", + wantResult: newOrFatal(t, -1), + }, + { + name: "Years between negative from leap day", + cql: "years between @2016-02-29 and @2015-02-28", + wantResult: newOrFatal(t, -1), + }, + { + name: "Weeks between dates two days apart across Sunday", + cql: "weeks between @2014-01-04 and @2014-01-06", + wantResult: newOrFatal(t, 0), + }, + { + name: "Weeks between dates six days apart", + cql: "weeks between @2014-01-07 and @2014-01-13", + wantResult: newOrFatal(t, 0), + }, + { + name: "Weeks between negative", + cql: "weeks between @2014-01-14 and @2014-01-01", + wantResult: newOrFatal(t, -1), + }, + { + name: "Days between datetimes across midnight", + cql: "days between @2014-01-01T23:00:00.000+00:00 and @2014-01-02T01:00:00.000+00:00", + wantResult: newOrFatal(t, 0), + }, + { + name: "Days between datetimes 47 hours apart", + cql: "days between @2014-01-01T01:00:00.000+00:00 and @2014-01-03T00:00:00.000+00:00", + wantResult: newOrFatal(t, 1), + }, + { + name: "Duration in days of interval across midnight", + cql: "duration in days of Interval[@2014-01-01T23:00:00.000+00:00, @2014-01-02T01:00:00.000+00:00]", + wantResult: newOrFatal(t, 0), + }, + { + name: "Hours between second precision datetimes is certain", + cql: "hours between @2014-01-01T00:00:00+00:00 and @2014-01-01T05:00:00+00:00", + wantResult: newOrFatal(t, 5), + }, + { + name: "Minutes between second precision datetimes is certain", + cql: "minutes between DateTime(2014, 1, 1, 0, 0) and DateTime(2014, 1, 1, 0, 5, 0)", + wantResult: newOrFatal(t, 5), + }, + { + name: "Duration in days of interval with coarse end is uncertain", + cql: "duration in days of Interval[DateTime(2014, 1, 15), DateTime(2014, 2)]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 16), + High: newOrFatal(t, 44), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Days between dates far apart does not saturate", + cql: "days between @2012-01-01 and @9999-12-31", + wantResult: newOrFatal(t, 2917556), + }, + { + name: "Duration in weeks of date interval", + cql: "duration in weeks of Interval[@2012-01-01, @2012-01-31]", + wantResult: newOrFatal(t, 4), + }, + } + + 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/enginetests/query_test.go b/tests/enginetests/query_test.go index 0d7ac27..69d158e 100644 --- a/tests/enginetests/query_test.go +++ b/tests/enginetests/query_test.go @@ -617,6 +617,11 @@ func TestQuery(t *testing.T) { cql: "define TESTRESULT: (null as Code) l return l.code", wantResult: newOrFatal(t, nil), }, + { + name: "Duration in days", + cql: "define TESTRESULT: duration in days of Interval[@2012-01-01, @2012-01-31]", + wantResult: newOrFatal(t, 30), + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/tests/spectests/exclusions/exclusions.go b/tests/spectests/exclusions/exclusions.go index 50d255e..1995d8a 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -90,7 +90,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { GroupExcludes: []string{ // TODO: b/342061715 - unsupported operators. "DateTimeComponentFrom", - "Duration", // TODO: b/342064491 - runtime error: invalid memory address or nil pointer dereference. "SameAs", }, @@ -180,8 +179,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { // TODO: b/342064012 - Uncertain result. "DateAdd33Days", "DateSubtract33Days", - "DateTimeDurationBetweenUncertainInterval", - "DateTimeDurationBetweenUncertainInterval2", "DateTimeDurationBetweenUncertainAdd", "DateTimeDurationBetweenUncertainSubtract", "DateTimeDurationBetweenUncertainMultiply", @@ -190,13 +187,8 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "DateTimeDurationBetweenMonthUncertain2", "DateTimeDurationBetweenMonthUncertain3", "DateTimeDurationBetweenMonthUncertain4", - "DateTimeDurationBetweenMonthUncertain5", "DateTimeDurationBetweenMonthUncertain6", "DateTimeDurationBetweenMonthUncertain7", - "DurationInYears", - "DurationInWeeks", - "DurationInWeeks2", - "DurationInWeeks3", "DateTimeSubtract1YearInSeconds", "TimeDurationBetweenHour", "TimeDurationBetweenHourDiffPrecision", @@ -204,12 +196,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "TimeDurationBetweenMinute", "TimeDurationBetweenSecond", "TimeDurationBetweenMillis", - "DurationInHoursA", - "DurationInMinutesA", - "DurationInDaysA", - "DurationInHoursAA", - "DurationInMinutesAA", - "DurationInDaysAA", "DateTimeDifferenceUncertain", // TODO: b/343800835 - Error in output date comparison based on execution timestamp logic. "DateTimeComponentFromDate", @@ -378,8 +364,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "CqlTypesTest.xml": { GroupExcludes: []string{}, NamesExcludes: []string{ - // TODO: b/342064012 - Uncertain result. - "DateTimeUncertain", // TODO: b/342061715 - unsupported operators. "DateTimeTimeUnspecified", // TODO: b/343515613 - fails with unexpected result. Technically not supported.