From 3228f4b1f28ec5608ceca169c3e8538c2a44337b Mon Sep 17 00:00:00 2001 From: Miles Porter Date: Thu, 23 Jul 2026 21:03:17 +0530 Subject: [PATCH 1/2] expression: keep month and day when a year/month interval underflows to year 0 DATE_SUB('1000-01-01', INTERVAL 1000 YEAR) returned 0000-00-00 instead of 0000-01-01. addDate cleared the month and day whenever the result year hit 0, which is only correct for day/time intervals that underflow past the minimum datetime (issue #11329). Year and month intervals do calendar arithmetic, so MySQL keeps the month and day. Gate the clearing on day == 0 && nano == 0 so only day/time intervals zero them out. Issue Number: close #59789 Signed-off-by: Miles Porter --- pkg/expression/builtin_time.go | 12 +++++++++++- pkg/expression/builtin_time_test.go | 3 +++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/pkg/expression/builtin_time.go b/pkg/expression/builtin_time.go index 60b846f97c7a8..13500a315c783 100644 --- a/pkg/expression/builtin_time.go +++ b/pkg/expression/builtin_time.go @@ -3204,7 +3204,17 @@ func (du *baseDateArithmetical) addDate(ctx EvalContext, date types.Time, year, // fix https://github.com/pingcap/tidb/issues/11329 if goTime.Year() == 0 { hour, minute, second := goTime.Clock() - date.SetCoreTime(types.FromDate(0, 0, 0, hour, minute, second, goTime.Nanosecond()/1000)) + // Year/month intervals (day == 0 && nano == 0) do calendar arithmetic, so when + // they drive the year down to 0 MySQL keeps the month and day, e.g. + // DATE_SUB('1000-01-01', INTERVAL 1000 YEAR) -> 0000-01-01 (issue #59789). + // Day/time intervals instead underflow past the minimum datetime and yield a + // zero date with the wrapped time, e.g. + // DATE_ADD('0001-01-01 00:00:00', INTERVAL -2 HOUR) -> 0000-00-00 22:00:00 (issue #11329). + if day == 0 && nano == 0 { + date.SetCoreTime(types.FromDate(0, int(goTime.Month()), goTime.Day(), hour, minute, second, goTime.Nanosecond()/1000)) + } else { + date.SetCoreTime(types.FromDate(0, 0, 0, hour, minute, second, goTime.Nanosecond()/1000)) + } return date, false, nil } diff --git a/pkg/expression/builtin_time_test.go b/pkg/expression/builtin_time_test.go index 4998c27393eb6..dddc2960f9a5a 100644 --- a/pkg/expression/builtin_time_test.go +++ b/pkg/expression/builtin_time_test.go @@ -2396,6 +2396,9 @@ func TestDateArithFuncs(t *testing.T) { {"2001-02-28", -1, "2000-02-28"}, {"2004-02-29", 1, "2005-02-28"}, {"2005-02-28", -1, "2004-02-28"}, + // A YEAR interval that drives the year down to 0 keeps the month and day, + // matching MySQL instead of zeroing them out. See issue #59789. + {"1000-01-01", -1000, "0000-01-01"}, } for _, test := range testYears { From 94a7f6553bf8496f885d165a93224e1514afa0d8 Mon Sep 17 00:00:00 2001 From: Miles Porter Date: Thu, 23 Jul 2026 21:50:37 +0530 Subject: [PATCH 2/2] expression: require a year/month component before preserving calendar fields Guard the year-0 calendar branch with year != 0 || month != 0 so a zero interval (INTERVAL 0 DAY / 0 HOUR) on a year-0 date keeps the existing zero-date normalization instead of being treated as a calendar interval. Signed-off-by: Miles Porter --- pkg/expression/builtin_time.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/expression/builtin_time.go b/pkg/expression/builtin_time.go index 13500a315c783..08860b0a0426f 100644 --- a/pkg/expression/builtin_time.go +++ b/pkg/expression/builtin_time.go @@ -3204,13 +3204,15 @@ func (du *baseDateArithmetical) addDate(ctx EvalContext, date types.Time, year, // fix https://github.com/pingcap/tidb/issues/11329 if goTime.Year() == 0 { hour, minute, second := goTime.Clock() - // Year/month intervals (day == 0 && nano == 0) do calendar arithmetic, so when - // they drive the year down to 0 MySQL keeps the month and day, e.g. + // Year/month intervals do calendar arithmetic, so when they drive the year down + // to 0 MySQL keeps the month and day, e.g. // DATE_SUB('1000-01-01', INTERVAL 1000 YEAR) -> 0000-01-01 (issue #59789). // Day/time intervals instead underflow past the minimum datetime and yield a // zero date with the wrapped time, e.g. // DATE_ADD('0001-01-01 00:00:00', INTERVAL -2 HOUR) -> 0000-00-00 22:00:00 (issue #11329). - if day == 0 && nano == 0 { + // A nonzero year or month component is what distinguishes the calendar case; + // intervals without one (including a zero interval) keep the zero-date normalization. + if (year != 0 || month != 0) && day == 0 && nano == 0 { date.SetCoreTime(types.FromDate(0, int(goTime.Month()), goTime.Day(), hour, minute, second, goTime.Nanosecond()/1000)) } else { date.SetCoreTime(types.FromDate(0, 0, 0, hour, minute, second, goTime.Nanosecond()/1000))