From b80d5184a729d08b736598709a10994a5af815c5 Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Tue, 1 Jul 2025 10:10:30 -0500 Subject: [PATCH 1/3] #150 add support for duration --- interpreter/operator_datetime.go | 322 +++++++++++++++++++- interpreter/operator_dispatcher.go | 30 ++ parser/operator_expressions.go | 106 ++++++- parser/operator_expressions_test.go | 64 ++-- parser/operators.go | 2 +- tests/enginetests/operator_datetime_test.go | 266 ++++++++++++++++ tests/enginetests/query_test.go | 5 + tests/spectests/exclusions/exclusions.go | 7 +- 8 files changed, 754 insertions(+), 48 deletions(-) diff --git a/interpreter/operator_datetime.go b/interpreter/operator_datetime.go index c99f729..955b8e0 100644 --- a/interpreter/operator_datetime.go +++ b/interpreter/operator_datetime.go @@ -327,6 +327,153 @@ 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) + + resultVal, err := dateTimeDifference(maxLeft, minRight, p) + if err != nil { + return 0, err + } + + val, err := result.ToInt32(resultVal) + if err != nil { + return 0, err + } + + return int(val), nil +} + +// 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) + + resultVal, err := dateTimeDifference(minLeft, maxRight, p) + if err != nil { + return 0, err + } + + val, err := result.ToInt32(resultVal) + if err != nil { + return 0, err + } + + return int(val), nil +} + +// 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. @@ -666,9 +813,24 @@ func dateTimeDifference(l, r result.DateTime, opPrecision model.DateTimePrecisio switch opPrecision { case model.YEAR: - return result.New(right.Year() - left.Year()) + years := right.Year() - left.Year() + // If the right month is before the left month, we haven't completed a full year + if right.Month() < left.Month() { + years-- + } else if right.Month() == left.Month() { + // If months are equal, check the day + if right.Day() < left.Day() { + years-- + } + } + return result.New(years) case model.MONTH: - return result.New(12*(right.Year()-left.Year()) + int((right.Month())) - int(left.Month())) + months := 12*(right.Year()-left.Year()) + int(right.Month()) - int(left.Month()) + // If the right day is before the left day, we haven't completed a full month + if right.Day() < left.Day() { + months-- + } + return result.New(months) case model.WEEK: // Weekly borders crossed are number of times a Sunday boundary has been crossed. // TODO(b/301606416): Weeks do not correctly support negative values. @@ -931,3 +1093,159 @@ 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) + } + + // Get the interval + interval, err := result.ToInterval(intervalObj) + if err != nil { + return result.Value{}, err + } + + // Get start and end of the interval + 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 + } + + // Handle null bounds + if result.IsNull(startVal) || result.IsNull(endVal) { + return result.New(nil) + } + + // Validate precision + precision := duration.Precision + allowUnsetPrec := false + if err := validatePrecisionByType(precision, allowUnsetPrec, interval.StaticType.PointType); err != nil { + return result.Value{}, err + } + + // Convert to DateTime for calculation + startDateTime, err := result.ToDateTime(startVal) + if err != nil { + return result.Value{}, err + } + endDateTime, err := result.ToDateTime(endVal) + if err != nil { + return result.Value{}, err + } + + // Calculate duration using the same logic as dateTimeDifference + return dateTimeDifference(startDateTime, endDateTime, precision) +} + +// _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) + p := model.DateTimePrecision(m.Precision) + + // Handle null values + if result.IsNull(lObj) || result.IsNull(rObj) { + return result.New(nil) + } + + // Validate date precisions + if err := validatePrecision(p, []model.DateTimePrecision{model.YEAR, model.MONTH, model.WEEK, model.DAY}); err != nil { + return result.Value{}, err + } + + // Convert both to DateTime and compute duration + l, r, err := applyToValues(lObj, rObj, result.ToDateTime) + if err != nil { + return result.Value{}, err + } + + return dateTimeDifference(l, r, p) +} + +// _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) + p := model.DateTimePrecision(m.Precision) + + // Handle null values + if result.IsNull(lObj) || result.IsNull(rObj) { + return result.New(nil) + } + + // Validate datetime precisions + if err := validatePrecision(p, []model.DateTimePrecision{model.YEAR, model.MONTH, model.WEEK, model.DAY, model.HOUR, model.MINUTE, model.SECOND, model.MILLISECOND}); err != nil { + return result.Value{}, err + } + + // Convert both to DateTime and compute duration + l, r, err := applyToValues(lObj, rObj, result.ToDateTime) + if err != nil { + return result.Value{}, err + } + + // Check if there's uncertainty due to precision differences + // Duration between should return an interval when there's uncertainty + hasUncertainty := false + switch p { + case model.YEAR: + // For year calculations, we need at least month precision to be certain + hasUncertainty = l.Precision == model.YEAR || r.Precision == model.YEAR + case model.MONTH: + // For month calculations, we need at least day precision to be certain + hasUncertainty = (l.Precision == model.YEAR || l.Precision == model.MONTH) || + (r.Precision == model.YEAR || r.Precision == model.MONTH) + default: + // For all other precisions (day, hour, minute, second, millisecond), + // we only have uncertainty if we don't have sufficient precision + hasUncertainty = !precisionGreaterOrEqual(l.Precision, p) || !precisionGreaterOrEqual(r.Precision, p) + } + + if hasUncertainty { + // Calculate the minimum and maximum possible durations + 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 min and max are the same, return a single value + if minDuration == maxDuration { + return result.New(minDuration) + } + + // Return an interval representing the uncertainty + 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}, + }) + } + + return dateTimeDifference(l, r, p) +} 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..d8abb4b 100644 --- a/tests/enginetests/operator_datetime_test.go +++ b/tests/enginetests/operator_datetime_test.go @@ -1080,3 +1080,269 @@ 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, -6), + }, + { + 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, 2), + }, + + // 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, result.Interval{ + Low: newOrFatal(t, 0), + High: newOrFatal(t, 1), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + 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, result.Interval{ + Low: newOrFatal(t, 44), + High: newOrFatal(t, 45), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + 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, 1), + }, + } + + 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) + } + }) + } +} 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..c0ca79e 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", }, @@ -193,10 +192,8 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "DateTimeDurationBetweenMonthUncertain5", "DateTimeDurationBetweenMonthUncertain6", "DateTimeDurationBetweenMonthUncertain7", - "DurationInYears", "DurationInWeeks", "DurationInWeeks2", - "DurationInWeeks3", "DateTimeSubtract1YearInSeconds", "TimeDurationBetweenHour", "TimeDurationBetweenHourDiffPrecision", @@ -205,10 +202,8 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "TimeDurationBetweenSecond", "TimeDurationBetweenMillis", "DurationInHoursA", - "DurationInMinutesA", + "DurationInMinutesA", "DurationInDaysA", - "DurationInHoursAA", - "DurationInMinutesAA", "DurationInDaysAA", "DateTimeDifferenceUncertain", // TODO: b/343800835 - Error in output date comparison based on execution timestamp logic. From 9812612e595e072e8e9fb0bcb6d56b77143f3de6 Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Sun, 11 Oct 2026 10:55:18 -0400 Subject: [PATCH 2/3] #150 gofmt duration changes --- interpreter/operator_datetime.go | 40 ++++++++++----------- tests/enginetests/operator_datetime_test.go | 25 +++++++------ tests/spectests/exclusions/exclusions.go | 2 +- 3 files changed, 33 insertions(+), 34 deletions(-) diff --git a/interpreter/operator_datetime.go b/interpreter/operator_datetime.go index 955b8e0..c914372 100644 --- a/interpreter/operator_datetime.go +++ b/interpreter/operator_datetime.go @@ -334,17 +334,17 @@ func calculateMinDuration(l, r result.DateTime, p model.DateTimePrecision) (int, // This means expanding the left date to its latest possible value and right to its earliest maxLeft := expandDateTimeToLatest(l) minRight := expandDateTimeToEarliest(r) - + resultVal, err := dateTimeDifference(maxLeft, minRight, p) if err != nil { return 0, err } - + val, err := result.ToInt32(resultVal) if err != nil { return 0, err } - + return int(val), nil } @@ -355,17 +355,17 @@ func calculateMaxDuration(l, r result.DateTime, p model.DateTimePrecision) (int, // This means expanding the left date to its earliest possible value and right to its latest minLeft := expandDateTimeToEarliest(l) maxRight := expandDateTimeToLatest(r) - + resultVal, err := dateTimeDifference(minLeft, maxRight, p) if err != nil { return 0, err } - + val, err := result.ToInt32(resultVal) if err != nil { return 0, err } - + return int(val), nil } @@ -375,7 +375,7 @@ func expandDateTimeToEarliest(dt result.DateTime) result.DateTime { if dt.Precision == model.MILLISECOND { return dt } - + // Set all unspecified components to their minimum values year := dt.Date.Year() month := dt.Date.Month() @@ -384,7 +384,7 @@ func expandDateTimeToEarliest(dt result.DateTime) result.DateTime { minute := dt.Date.Minute() second := dt.Date.Second() nanosecond := dt.Date.Nanosecond() - + switch dt.Precision { case model.YEAR: month = 1 @@ -414,7 +414,7 @@ func expandDateTimeToEarliest(dt result.DateTime) result.DateTime { case model.SECOND: nanosecond = 0 } - + return result.DateTime{ Date: time.Date(year, month, day, hour, minute, second, nanosecond, dt.Date.Location()), Precision: dt.Precision, @@ -427,7 +427,7 @@ func expandDateTimeToLatest(dt result.DateTime) result.DateTime { if dt.Precision == model.MILLISECOND { return dt } - + // Set all unspecified components to their maximum values year := dt.Date.Year() month := dt.Date.Month() @@ -436,7 +436,7 @@ func expandDateTimeToLatest(dt result.DateTime) result.DateTime { minute := dt.Date.Minute() second := dt.Date.Second() nanosecond := dt.Date.Nanosecond() - + switch dt.Precision { case model.YEAR: month = 12 @@ -467,7 +467,7 @@ func expandDateTimeToLatest(dt result.DateTime) result.DateTime { 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, @@ -1204,10 +1204,10 @@ func evalDurationBetweenDateTime(b model.IBinaryExpression, lObj, rObj result.Va hasUncertainty = l.Precision == model.YEAR || r.Precision == model.YEAR case model.MONTH: // For month calculations, we need at least day precision to be certain - hasUncertainty = (l.Precision == model.YEAR || l.Precision == model.MONTH) || - (r.Precision == model.YEAR || r.Precision == model.MONTH) + hasUncertainty = (l.Precision == model.YEAR || l.Precision == model.MONTH) || + (r.Precision == model.YEAR || r.Precision == model.MONTH) default: - // For all other precisions (day, hour, minute, second, millisecond), + // For all other precisions (day, hour, minute, second, millisecond), // we only have uncertainty if we don't have sufficient precision hasUncertainty = !precisionGreaterOrEqual(l.Precision, p) || !precisionGreaterOrEqual(r.Precision, p) } @@ -1218,7 +1218,7 @@ func evalDurationBetweenDateTime(b model.IBinaryExpression, lObj, rObj result.Va if err != nil { return result.Value{}, err } - + maxDuration, err := calculateMaxDuration(l, r, p) if err != nil { return result.Value{}, err @@ -1239,11 +1239,11 @@ func evalDurationBetweenDateTime(b model.IBinaryExpression, lObj, rObj result.Va return result.Value{}, err } return result.New(result.Interval{ - Low: lowVal, - High: highVal, - LowInclusive: true, + Low: lowVal, + High: highVal, + LowInclusive: true, HighInclusive: true, - StaticType: &types.Interval{PointType: types.Integer}, + StaticType: &types.Interval{PointType: types.Integer}, }) } diff --git a/tests/enginetests/operator_datetime_test.go b/tests/enginetests/operator_datetime_test.go index d8abb4b..8120c5f 100644 --- a/tests/enginetests/operator_datetime_test.go +++ b/tests/enginetests/operator_datetime_test.go @@ -1198,11 +1198,11 @@ func TestDurationOperators(t *testing.T) { 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, + Low: newOrFatal(t, 4), + High: newOrFatal(t, 5), + LowInclusive: true, HighInclusive: true, - StaticType: &types.Interval{PointType: types.Integer}, + StaticType: &types.Interval{PointType: types.Integer}, }), }, @@ -1232,7 +1232,6 @@ func TestDurationOperators(t *testing.T) { wantResult: newOrFatal(t, 30), }, - // Leap year handling { name: "Duration across leap year", @@ -1301,22 +1300,22 @@ func TestDurationBetweenWithTimezones(t *testing.T) { 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, result.Interval{ - Low: newOrFatal(t, 0), - High: newOrFatal(t, 1), - LowInclusive: true, + Low: newOrFatal(t, 0), + High: newOrFatal(t, 1), + LowInclusive: true, HighInclusive: true, - StaticType: &types.Interval{PointType: types.Integer}, + StaticType: &types.Interval{PointType: types.Integer}, }), }, { 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, result.Interval{ - Low: newOrFatal(t, 44), - High: newOrFatal(t, 45), - LowInclusive: true, + Low: newOrFatal(t, 44), + High: newOrFatal(t, 45), + LowInclusive: true, HighInclusive: true, - StaticType: &types.Interval{PointType: types.Integer}, + StaticType: &types.Interval{PointType: types.Integer}, }), }, { diff --git a/tests/spectests/exclusions/exclusions.go b/tests/spectests/exclusions/exclusions.go index c0ca79e..0637c0e 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -202,7 +202,7 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "TimeDurationBetweenSecond", "TimeDurationBetweenMillis", "DurationInHoursA", - "DurationInMinutesA", + "DurationInMinutesA", "DurationInDaysA", "DurationInDaysAA", "DateTimeDifferenceUncertain", From 3ad0f1aed7dcc588cd9838777a859fe35ca3dcf8 Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Sun, 11 Oct 2026 11:12:09 -0400 Subject: [PATCH 3/3] #150 address review comments: duration counts whole periods Restore the YEAR and MONTH cases of dateTimeDifference so difference in _precision_ between counts boundaries again. Duration now uses its own dateTimeDuration helper: whole calendar months and years truncated toward zero, and whole elapsed periods from UnixMilli for weeks and finer, which does not saturate like time.Sub. Uncertainty is now only reported when an operand is coarser than the requested precision, and the duration of an interval shares the same path, including week precision for Dates. --- interpreter/operator_datetime.go | 228 +++++++++----------- tests/enginetests/operator_datetime_test.go | 158 ++++++++++++-- tests/spectests/exclusions/exclusions.go | 11 - 3 files changed, 239 insertions(+), 158 deletions(-) diff --git a/interpreter/operator_datetime.go b/interpreter/operator_datetime.go index c914372..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" @@ -335,17 +336,7 @@ func calculateMinDuration(l, r result.DateTime, p model.DateTimePrecision) (int, maxLeft := expandDateTimeToLatest(l) minRight := expandDateTimeToEarliest(r) - resultVal, err := dateTimeDifference(maxLeft, minRight, p) - if err != nil { - return 0, err - } - - val, err := result.ToInt32(resultVal) - if err != nil { - return 0, err - } - - return int(val), nil + return dateTimeDuration(maxLeft, minRight, p) } // calculateMaxDuration calculates the maximum possible duration between two DateTimes @@ -356,17 +347,7 @@ func calculateMaxDuration(l, r result.DateTime, p model.DateTimePrecision) (int, minLeft := expandDateTimeToEarliest(l) maxRight := expandDateTimeToLatest(r) - resultVal, err := dateTimeDifference(minLeft, maxRight, p) - if err != nil { - return 0, err - } - - val, err := result.ToInt32(resultVal) - if err != nil { - return 0, err - } - - return int(val), nil + return dateTimeDuration(minLeft, maxRight, p) } // expandDateTimeToEarliest expands a DateTime to its earliest possible value given its precision @@ -813,24 +794,9 @@ func dateTimeDifference(l, r result.DateTime, opPrecision model.DateTimePrecisio switch opPrecision { case model.YEAR: - years := right.Year() - left.Year() - // If the right month is before the left month, we haven't completed a full year - if right.Month() < left.Month() { - years-- - } else if right.Month() == left.Month() { - // If months are equal, check the day - if right.Day() < left.Day() { - years-- - } - } - return result.New(years) + return result.New(right.Year() - left.Year()) case model.MONTH: - months := 12*(right.Year()-left.Year()) + int(right.Month()) - int(left.Month()) - // If the right day is before the left day, we haven't completed a full month - if right.Day() < left.Day() { - months-- - } - return result.New(months) + return result.New(12*(right.Year()-left.Year()) + int((right.Month())) - int(left.Month())) case model.WEEK: // Weekly borders crossed are number of times a Sunday boundary has been crossed. // TODO(b/301606416): Weeks do not correctly support negative values. @@ -1104,13 +1070,11 @@ func (i *interpreter) evalDuration(m model.IUnaryExpression, intervalObj result. return result.New(nil) } - // Get the interval interval, err := result.ToInterval(intervalObj) if err != nil { return result.Value{}, err } - // Get start and end of the interval startVal, err := start(intervalObj, &i.evaluationTimestamp) if err != nil { return result.Value{}, err @@ -1120,56 +1084,27 @@ func (i *interpreter) evalDuration(m model.IUnaryExpression, intervalObj result. return result.Value{}, err } - // Handle null bounds - if result.IsNull(startVal) || result.IsNull(endVal) { - return result.New(nil) - } - - // Validate precision - precision := duration.Precision - allowUnsetPrec := false - if err := validatePrecisionByType(precision, allowUnsetPrec, interval.StaticType.PointType); err != nil { - return result.Value{}, err - } - - // Convert to DateTime for calculation - startDateTime, err := result.ToDateTime(startVal) - if err != nil { - return result.Value{}, err - } - endDateTime, err := result.ToDateTime(endVal) - 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) } - - // Calculate duration using the same logic as dateTimeDifference - return dateTimeDifference(startDateTime, endDateTime, precision) } +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) - p := model.DateTimePrecision(m.Precision) - - // Handle null values - if result.IsNull(lObj) || result.IsNull(rObj) { - return result.New(nil) - } - - // Validate date precisions - if err := validatePrecision(p, []model.DateTimePrecision{model.YEAR, model.MONTH, model.WEEK, model.DAY}); err != nil { - return result.Value{}, err - } - - // Convert both to DateTime and compute duration - l, r, err := applyToValues(lObj, rObj, result.ToDateTime) - if err != nil { - return result.Value{}, err - } - - return dateTimeDifference(l, r, p) + return durationBetween(lObj, rObj, model.DateTimePrecision(m.Precision), dateDurationPrecisions) } // _precision_ between(low DateTime, high DateTime) Integer @@ -1177,75 +1112,114 @@ func evalDurationBetweenDate(b model.IBinaryExpression, lObj, rObj result.Value) // 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) - p := model.DateTimePrecision(m.Precision) + return durationBetween(lObj, rObj, model.DateTimePrecision(m.Precision), dateTimeDurationPrecisions) +} - // Handle null values +// 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) } - - // Validate datetime precisions - if err := validatePrecision(p, []model.DateTimePrecision{model.YEAR, model.MONTH, model.WEEK, model.DAY, model.HOUR, model.MINUTE, model.SECOND, model.MILLISECOND}); err != nil { + if err := validatePrecision(p, allowed); err != nil { return result.Value{}, err } - - // Convert both to DateTime and compute duration l, r, err := applyToValues(lObj, rObj, result.ToDateTime) if err != nil { return result.Value{}, err } - // Check if there's uncertainty due to precision differences - // Duration between should return an interval when there's uncertainty - hasUncertainty := false + var hasUncertainty bool switch p { case model.YEAR: - // For year calculations, we need at least month precision to be certain hasUncertainty = l.Precision == model.YEAR || r.Precision == model.YEAR case model.MONTH: - // For month calculations, we need at least day precision to be certain - hasUncertainty = (l.Precision == model.YEAR || l.Precision == model.MONTH) || - (r.Precision == model.YEAR || r.Precision == model.MONTH) + hasUncertainty = !precisionGreaterOrEqual(model.DAY, l.Precision) || !precisionGreaterOrEqual(model.DAY, r.Precision) default: - // For all other precisions (day, hour, minute, second, millisecond), - // we only have uncertainty if we don't have sufficient precision - hasUncertainty = !precisionGreaterOrEqual(l.Precision, p) || !precisionGreaterOrEqual(r.Precision, p) + // Uncertain only when an operand is coarser than the requested precision. + hasUncertainty = !precisionGreaterOrEqual(p, l.Precision) || !precisionGreaterOrEqual(p, r.Precision) } - if hasUncertainty { - // Calculate the minimum and maximum possible durations - minDuration, err := calculateMinDuration(l, r, p) + 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) + } - maxDuration, err := calculateMaxDuration(l, r, p) - if err != nil { - return result.Value{}, err - } + 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}, + }) +} - // If min and max are the same, return a single value - if minDuration == maxDuration { - return result.New(minDuration) - } +// 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()) - // Return an interval representing the uncertainty - lowVal, err := result.New(minDuration) - if err != nil { - return result.Value{}, err + 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++ } - highVal, err := result.New(maxDuration) - if err != nil { - return result.Value{}, err + 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) } - return result.New(result.Interval{ - Low: lowVal, - High: highVal, - LowInclusive: true, - HighInclusive: true, - StaticType: &types.Interval{PointType: types.Integer}, - }) } - - return dateTimeDifference(l, r, 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/tests/enginetests/operator_datetime_test.go b/tests/enginetests/operator_datetime_test.go index 8120c5f..7651b3d 100644 --- a/tests/enginetests/operator_datetime_test.go +++ b/tests/enginetests/operator_datetime_test.go @@ -1133,7 +1133,7 @@ func TestDurationOperators(t *testing.T) { { name: "Years between dates - negative", cql: "years between @2010-05-01 and @2005-04-30", - wantResult: newOrFatal(t, -6), + wantResult: newOrFatal(t, -5), }, { name: "Months between dates", @@ -1222,7 +1222,7 @@ func TestDurationOperators(t *testing.T) { { 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, 2), + wantResult: newOrFatal(t, 1), }, // Complex interval calculations @@ -1297,31 +1297,150 @@ func TestDurationBetweenWithTimezones(t *testing.T) { 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, result.Interval{ - Low: newOrFatal(t, 0), - High: newOrFatal(t, 1), - LowInclusive: true, - HighInclusive: true, - StaticType: &types.Interval{PointType: types.Integer}, - }), + 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: "Minutes between with timezone offset", - cql: "minutes between @2017-11-05T01:30:00-06:00 and @2017-11-05T01:15:00-07:00", + 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, 44), - High: newOrFatal(t, 45), + Low: newOrFatal(t, 16), + High: newOrFatal(t, 44), LowInclusive: true, HighInclusive: true, StaticType: &types.Interval{PointType: types.Integer}, }), }, { - 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, 1), + 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), }, } @@ -1338,8 +1457,7 @@ func TestDurationBetweenWithTimezones(t *testing.T) { t.Fatalf("Eval returned unexpected error: %v", err) } - gotResult := getTESTRESULTWithSources(t, results) - if diff := cmp.Diff(tc.wantResult, gotResult, protocmp.Transform()); diff != "" { + 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 0637c0e..1995d8a 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -179,8 +179,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { // TODO: b/342064012 - Uncertain result. "DateAdd33Days", "DateSubtract33Days", - "DateTimeDurationBetweenUncertainInterval", - "DateTimeDurationBetweenUncertainInterval2", "DateTimeDurationBetweenUncertainAdd", "DateTimeDurationBetweenUncertainSubtract", "DateTimeDurationBetweenUncertainMultiply", @@ -189,11 +187,8 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "DateTimeDurationBetweenMonthUncertain2", "DateTimeDurationBetweenMonthUncertain3", "DateTimeDurationBetweenMonthUncertain4", - "DateTimeDurationBetweenMonthUncertain5", "DateTimeDurationBetweenMonthUncertain6", "DateTimeDurationBetweenMonthUncertain7", - "DurationInWeeks", - "DurationInWeeks2", "DateTimeSubtract1YearInSeconds", "TimeDurationBetweenHour", "TimeDurationBetweenHourDiffPrecision", @@ -201,10 +196,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "TimeDurationBetweenMinute", "TimeDurationBetweenSecond", "TimeDurationBetweenMillis", - "DurationInHoursA", - "DurationInMinutesA", - "DurationInDaysA", - "DurationInDaysAA", "DateTimeDifferenceUncertain", // TODO: b/343800835 - Error in output date comparison based on execution timestamp logic. "DateTimeComponentFromDate", @@ -373,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.