From b76549cb66caf2b783115aaa6fac50fb2bc19da6 Mon Sep 17 00:00:00 2001 From: daviszhen Date: Mon, 27 Jul 2026 19:45:40 +0800 Subject: [PATCH 01/25] update --- pkg/sql/plan/function/func_unary.go | 64 +++++++++++++ pkg/sql/plan/function/func_unary_test.go | 57 ++++++++++++ pkg/sql/plan/function/list_builtIn.go | 90 +++++++++++++++++++ .../cases/function/func_datetime_hour.result | 55 +++++++----- .../cases/function/func_datetime_hour.test | 5 ++ .../function/func_datetime_minute.result | 56 +++++++----- .../cases/function/func_datetime_minute.test | 5 ++ .../function/func_datetime_second.result | 58 +++++++----- .../cases/function/func_datetime_second.test | 5 ++ 9 files changed, 326 insertions(+), 69 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index 678ada9390e83..7f5b657bd575b 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4635,6 +4635,70 @@ func TimeToSecond(ivecs []*vector.Vector, result vector.FunctionResultWrapper, p }, selectList) } +func timeStringToFixedWithNullOnError[T types.FixedSizeTExceptStrType]( + ivecs []*vector.Vector, + result vector.FunctionResultWrapper, + length int, + selectList *FunctionSelectList, + fn func(types.Time) T, +) error { + strParam := vector.GenerateFunctionStrParameter(ivecs[0]) + rs := vector.MustFunctionResult[T](result) + var zero T + + for i := uint64(0); i < uint64(length); i++ { + if selectList != nil && (selectList.IgnoreAllRow() || + (!selectList.ShouldEvalAllRow() && selectList.Contains(i))) { + if err := rs.Append(zero, true); err != nil { + return err + } + continue + } + + strVal, null := strParam.GetStrValue(i) + if null || len(strVal) == 0 { + if err := rs.Append(zero, true); err != nil { + return err + } + continue + } + + timeVal, err := types.ParseTime(functionUtil.QuickBytesToStr(strVal), 6) + if err != nil { + if err := rs.Append(zero, true); err != nil { + return err + } + continue + } + + if err := rs.Append(fn(timeVal), false); err != nil { + return err + } + } + return nil +} + +func StringToHour(ivecs []*vector.Vector, result vector.FunctionResultWrapper, _ *process.Process, length int, selectList *FunctionSelectList) error { + return timeStringToFixedWithNullOnError(ivecs, result, length, selectList, func(v types.Time) uint32 { + hour, _, _, _, _ := v.ClockFormat() + return uint32(hour) + }) +} + +func StringToMinute(ivecs []*vector.Vector, result vector.FunctionResultWrapper, _ *process.Process, length int, selectList *FunctionSelectList) error { + return timeStringToFixedWithNullOnError(ivecs, result, length, selectList, func(v types.Time) uint8 { + _, minute, _, _, _ := v.ClockFormat() + return uint8(minute) + }) +} + +func StringToSecond(ivecs []*vector.Vector, result vector.FunctionResultWrapper, _ *process.Process, length int, selectList *FunctionSelectList) error { + return timeStringToFixedWithNullOnError(ivecs, result, length, selectList, func(v types.Time) uint8 { + _, _, second, _, _ := v.ClockFormat() + return uint8(second) + }) +} + // TimeToSec returns the time argument, converted to seconds (total seconds) func TimeToSec(ivecs []*vector.Vector, result vector.FunctionResultWrapper, proc *process.Process, length int, selectList *FunctionSelectList) error { return opUnaryFixedToFixed[types.Time, int64](ivecs, result, proc, length, func(v types.Time) int64 { diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 4b2b1440b0358..ae4ea70ff4b22 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4159,6 +4159,63 @@ func TestSecond(t *testing.T) { } } +func TestStringTimeExtract(t *testing.T) { + proc := testutil.NewProcess(t) + inputs := []FunctionTestInput{ + NewFunctionTestInput(types.T_varchar.ToType(), + []string{"12:30:45", "272:59:59", "2024-12-20 15:30:45", "invalid", ""}, + []bool{false, false, false, false, false}), + } + + testCases := []struct { + name string + expect FunctionTestResult + fn fEvalFn + }{ + { + name: "hour", + expect: NewFunctionTestResult(types.T_uint32.ToType(), false, + []uint32{12, 272, 15, 0, 0}, []bool{false, false, false, true, true}), + fn: StringToHour, + }, + { + name: "minute", + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, + []uint8{30, 59, 30, 0, 0}, []bool{false, false, false, true, true}), + fn: StringToMinute, + }, + { + name: "second", + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, + []uint8{45, 59, 45, 0, 0}, []bool{false, false, false, true, true}), + fn: StringToSecond, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tcc := NewFunctionTestCase(proc, inputs, tc.expect, tc.fn) + succeed, info := tcc.Run() + require.True(t, succeed, info) + }) + } + + for _, tc := range []struct { + name string + returnType types.T + }{ + {name: "hour", returnType: types.T_uint32}, + {name: "minute", returnType: types.T_uint8}, + {name: "second", returnType: types.T_uint8}, + } { + t.Run("registered_"+tc.name, func(t *testing.T) { + fn, err := GetFunctionByName(proc.Ctx, tc.name, []types.Type{types.T_varchar.ToType()}) + require.NoError(t, err) + require.Equal(t, tc.returnType, fn.GetReturnType().Oid) + }) + } +} + func initBinaryTestCase() []tcTemp { return []tcTemp{ { diff --git a/pkg/sql/plan/function/list_builtIn.go b/pkg/sql/plan/function/list_builtIn.go index a37cdc2803bff..f25cf73fc38df 100644 --- a/pkg/sql/plan/function/list_builtIn.go +++ b/pkg/sql/plan/function/list_builtIn.go @@ -9479,6 +9479,36 @@ var supportedDateAndTimeBuiltIns = []FuncNew{ return TimeToHour }, }, + { + overloadId: 3, + args: []types.T{types.T_varchar}, + retType: func(parameters []types.Type) types.Type { + return types.T_uint32.ToType() + }, + newOp: func() executeLogicOfOverload { + return StringToHour + }, + }, + { + overloadId: 4, + args: []types.T{types.T_char}, + retType: func(parameters []types.Type) types.Type { + return types.T_uint32.ToType() + }, + newOp: func() executeLogicOfOverload { + return StringToHour + }, + }, + { + overloadId: 5, + args: []types.T{types.T_text}, + retType: func(parameters []types.Type) types.Type { + return types.T_uint32.ToType() + }, + newOp: func() executeLogicOfOverload { + return StringToHour + }, + }, }, }, @@ -9520,6 +9550,36 @@ var supportedDateAndTimeBuiltIns = []FuncNew{ return TimeToMinute }, }, + { + overloadId: 3, + args: []types.T{types.T_varchar}, + retType: func(parameters []types.Type) types.Type { + return types.T_uint8.ToType() + }, + newOp: func() executeLogicOfOverload { + return StringToMinute + }, + }, + { + overloadId: 4, + args: []types.T{types.T_char}, + retType: func(parameters []types.Type) types.Type { + return types.T_uint8.ToType() + }, + newOp: func() executeLogicOfOverload { + return StringToMinute + }, + }, + { + overloadId: 5, + args: []types.T{types.T_text}, + retType: func(parameters []types.Type) types.Type { + return types.T_uint8.ToType() + }, + newOp: func() executeLogicOfOverload { + return StringToMinute + }, + }, }, }, @@ -9860,6 +9920,36 @@ var supportedDateAndTimeBuiltIns = []FuncNew{ return TimeToSecond }, }, + { + overloadId: 3, + args: []types.T{types.T_varchar}, + retType: func(parameters []types.Type) types.Type { + return types.T_uint8.ToType() + }, + newOp: func() executeLogicOfOverload { + return StringToSecond + }, + }, + { + overloadId: 4, + args: []types.T{types.T_char}, + retType: func(parameters []types.Type) types.Type { + return types.T_uint8.ToType() + }, + newOp: func() executeLogicOfOverload { + return StringToSecond + }, + }, + { + overloadId: 5, + args: []types.T{types.T_text}, + retType: func(parameters []types.Type) types.Type { + return types.T_uint8.ToType() + }, + newOp: func() executeLogicOfOverload { + return StringToSecond + }, + }, }, }, diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index d59d208c3ec4c..249f780cd760f 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -1,49 +1,50 @@ SELECT HOUR('15:30:45') AS result1; -invalid argument parse timestamp, bad value 15:30:45 +➤ result1[4,32,0] 𝄀 +15 SELECT HOUR('2024-12-20 15:30:45') AS result2; -result2 +➤ result2[4,32,0] 𝄀 15 SELECT HOUR(NOW()) AS result3; -result3 -22 +➤ result3[-6,8,0] 𝄀 +19 SELECT HOUR(CAST('15:30:45' AS TIME)) AS time_cast; -time_cast +➤ time_cast[4,32,0] 𝄀 15 SELECT HOUR(CAST('00:00:00' AS TIME)) AS midnight; -midnight +➤ midnight[4,32,0] 𝄀 0 SELECT HOUR(CAST('23:59:59' AS TIME)) AS end_of_day; -end_of_day +➤ end_of_day[4,32,0] 𝄀 23 SELECT HOUR(CAST('272:59:59' AS TIME)) AS extended_positive; -extended_positive +➤ extended_positive[4,32,0] 𝄀 272 SELECT HOUR(CAST('-272:59:59' AS TIME)) AS extended_negative; -extended_negative +➤ extended_negative[4,32,0] 𝄀 272 SELECT HOUR(CAST('2024-12-20 10:20:30' AS DATETIME)) AS datetime_hour; -datetime_hour +➤ datetime_hour[-6,8,0] 𝄀 10 SELECT HOUR(CAST('2024-12-20 00:00:00' AS DATETIME)) AS datetime_midnight; -datetime_midnight +➤ datetime_midnight[-6,8,0] 𝄀 0 SELECT HOUR(CAST('2024-12-20 23:59:59' AS DATETIME)) AS datetime_end; -datetime_end +➤ datetime_end[-6,8,0] 𝄀 23 SELECT HOUR(CAST('2024-12-20 12:30:45' AS TIMESTAMP)) AS timestamp_hour; -timestamp_hour +➤ timestamp_hour[-6,8,0] 𝄀 12 SELECT HOUR(CAST('00:00:00' AS TIME)) AS zero_hour; -zero_hour +➤ zero_hour[4,32,0] 𝄀 0 SELECT HOUR(CAST('12:00:00' AS TIME)) AS noon; -noon +➤ noon[4,32,0] 𝄀 12 SELECT HOUR(CAST('13:00:00' AS TIME)) AS one_pm; -one_pm +➤ one_pm[4,32,0] 𝄀 13 SELECT HOUR(NULL) AS null_result; -null_result +➤ null_result[-6,8,0] 𝄀 null CREATE TABLE t1(t TIME, dt DATETIME, ts TIMESTAMP); INSERT INTO t1 VALUES @@ -51,18 +52,24 @@ INSERT INTO t1 VALUES ('00:00:00', '2024-12-20 00:00:00', '2024-12-20 00:00:00'), ('23:59:59', '2024-12-20 23:59:59', '2024-12-20 23:59:59'); SELECT t, HOUR(t) AS hour_from_time, dt, HOUR(dt) AS hour_from_datetime, ts, HOUR(ts) AS hour_from_timestamp FROM t1; -t hour_from_time dt hour_from_datetime ts hour_from_timestamp -15:30:45 15 2024-12-20 15:30:45 15 2024-12-20 15:30:45 15 -00:00:00 0 2024-12-20 00:00:00 0 2024-12-20 00:00:00 0 -23:59:59 23 2024-12-20 23:59:59 23 2024-12-20 23:59:59 23 +➤ t[92,64,0] ¦ hour_from_time[4,32,0] ¦ dt[93,64,0] ¦ hour_from_datetime[-6,8,0] ¦ ts[93,64,0] ¦ hour_from_timestamp[-6,8,0] 𝄀 +15:30:45 ¦ 15 ¦ 2024-12-20 15:30:45 ¦ 15 ¦ 2024-12-20 15:30:45 ¦ 15 𝄀 +00:00:00 ¦ 0 ¦ 2024-12-20 00:00:00 ¦ 0 ¦ 2024-12-20 00:00:00 ¦ 0 𝄀 +23:59:59 ¦ 23 ¦ 2024-12-20 23:59:59 ¦ 23 ¦ 2024-12-20 23:59:59 ¦ 23 DROP TABLE t1; CREATE TABLE t1(t TIME); INSERT INTO t1 VALUES ('15:30:45'), ('10:20:30'), ('23:59:59'); SELECT * FROM t1 WHERE HOUR(t) = 15; -t +➤ t[92,64,0] 𝄀 15:30:45 SELECT * FROM t1 WHERE HOUR(t) > 12; -t -15:30:45 +➤ t[92,64,0] 𝄀 +15:30:45 𝄀 23:59:59 DROP TABLE t1; +SELECT HOUR(CAST('12:30:00' AS VARCHAR)) AS varchar_hour, +HOUR(CAST('272:59:59' AS VARCHAR)) AS varchar_extended_hour, +HOUR(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_hour, +HOUR(CAST(NULL AS VARCHAR)) AS varchar_null_hour; +➤ varchar_hour[4,32,0] ¦ varchar_extended_hour[4,32,0] ¦ varchar_datetime_hour[4,32,0] ¦ varchar_null_hour[4,32,0] 𝄀 +12 ¦ 272 ¦ 15 ¦ null diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index 43b575079e80b..7c12701083be5 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -44,3 +44,8 @@ SELECT * FROM t1 WHERE HOUR(t) = 15; SELECT * FROM t1 WHERE HOUR(t) > 12; DROP TABLE t1; +-- VARCHAR time-string input +SELECT HOUR(CAST('12:30:00' AS VARCHAR)) AS varchar_hour, + HOUR(CAST('272:59:59' AS VARCHAR)) AS varchar_extended_hour, + HOUR(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_hour, + HOUR(CAST(NULL AS VARCHAR)) AS varchar_null_hour; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 13314f499e4e1..7226f9ffcdf9a 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -1,51 +1,63 @@ SELECT MINUTE('15:30:45') AS result1; -invalid argument parse timestamp, bad value 15:30:45 +➤ result1[-6,8,0] 𝄀 +30 SELECT MINUTE('15:30:45') AS time_with_minutes; -invalid argument parse timestamp, bad value 15:30:45 +➤ time_with_minutes[-6,8,0] 𝄀 +30 SELECT MINUTE('00:00:00') AS zero_minutes; -invalid argument parse timestamp, bad value 00:00:00 +➤ zero_minutes[-6,8,0] 𝄀 +0 SELECT MINUTE('23:59:59') AS max_minutes; -invalid argument parse timestamp, bad value 23:59:59 +➤ max_minutes[-6,8,0] 𝄀 +59 SELECT MINUTE('12:34:56') AS various_minutes; -invalid argument parse timestamp, bad value 12:34:56 +➤ various_minutes[-6,8,0] 𝄀 +34 SELECT MINUTE('2024-12-20 15:30:45') AS datetime_with_minutes; -datetime_with_minutes +➤ datetime_with_minutes[-6,8,0] 𝄀 30 SELECT MINUTE('2024-12-20 00:00:00') AS datetime_zero; -datetime_zero +➤ datetime_zero[-6,8,0] 𝄀 0 SELECT MINUTE('2024-12-20 23:59:59') AS datetime_max; -datetime_max +➤ datetime_max[-6,8,0] 𝄀 59 SELECT MINUTE(TIMESTAMP('2024-12-20 15:30:45')) AS timestamp_with_minutes; -timestamp_with_minutes +➤ timestamp_with_minutes[-6,8,0] 𝄀 30 SELECT MINUTE(NULL) AS null_input; -null_input +➤ null_input[-6,8,0] 𝄀 null CREATE TABLE t1(t TIME); INSERT INTO t1 VALUES ('15:30:45'), ('00:00:00'), ('23:59:59'), ('12:34:56'); SELECT t, MINUTE(t) AS minute FROM t1; -t minute -15:30:45 30 -00:00:00 0 -23:59:59 59 -12:34:56 34 +➤ t[92,64,0] ¦ minute[-6,8,0] 𝄀 +15:30:45 ¦ 30 𝄀 +00:00:00 ¦ 0 𝄀 +23:59:59 ¦ 59 𝄀 +12:34:56 ¦ 34 DROP TABLE t1; CREATE TABLE t1(dt DATETIME); INSERT INTO t1 VALUES ('2024-12-20 15:30:45'), ('2024-12-20 00:00:00'), ('2024-12-20 23:59:59'); SELECT dt, MINUTE(dt) AS minute FROM t1; -dt minute -2024-12-20 15:30:45 30 -2024-12-20 00:00:00 0 -2024-12-20 23:59:59 59 +➤ dt[93,64,0] ¦ minute[-6,8,0] 𝄀 +2024-12-20 15:30:45 ¦ 30 𝄀 +2024-12-20 00:00:00 ¦ 0 𝄀 +2024-12-20 23:59:59 ¦ 59 DROP TABLE t1; CREATE TABLE t1(t TIME); INSERT INTO t1 VALUES ('15:30:45'), ('00:00:00'), ('23:59:59'), ('12:34:56'); SELECT * FROM t1 WHERE MINUTE(t) > 30; -t -23:59:59 +➤ t[92,64,0] 𝄀 +23:59:59 𝄀 12:34:56 DROP TABLE t1; SELECT MINUTE('15:30:45') AS result1, MINUTE('15:00:45') AS result2, MINUTE('15:59:45') AS result3; -invalid argument parse timestamp, bad value 15:30:45 +➤ result1[-6,8,0] ¦ result2[-6,8,0] ¦ result3[-6,8,0] 𝄀 +30 ¦ 0 ¦ 59 +SELECT MINUTE(CAST('12:30:00' AS VARCHAR)) AS varchar_minute, +MINUTE(CAST('272:59:59' AS VARCHAR)) AS varchar_extended_minute, +MINUTE(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_minute, +MINUTE(CAST(NULL AS VARCHAR)) AS varchar_null_minute; +➤ varchar_minute[-6,8,0] ¦ varchar_extended_minute[-6,8,0] ¦ varchar_datetime_minute[-6,8,0] ¦ varchar_null_minute[-6,8,0] 𝄀 +30 ¦ 59 ¦ 30 ¦ null diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index 5b8bfcba6d01e..393273ba5fcb2 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -40,3 +40,8 @@ DROP TABLE t1; # Range check (should return 0-59) SELECT MINUTE('15:30:45') AS result1, MINUTE('15:00:45') AS result2, MINUTE('15:59:45') AS result3; +-- VARCHAR time-string input +SELECT MINUTE(CAST('12:30:00' AS VARCHAR)) AS varchar_minute, + MINUTE(CAST('272:59:59' AS VARCHAR)) AS varchar_extended_minute, + MINUTE(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_minute, + MINUTE(CAST(NULL AS VARCHAR)) AS varchar_null_minute; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index f99bd25aef1af..c453849bde506 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -1,52 +1,64 @@ SELECT SECOND('15:30:45') AS result1; -invalid argument parse timestamp, bad value 15:30:45 +➤ result1[-6,8,0] 𝄀 +45 SELECT SECOND('15:30:45') AS time_with_seconds; -invalid argument parse timestamp, bad value 15:30:45 +➤ time_with_seconds[-6,8,0] 𝄀 +45 SELECT SECOND('00:00:00') AS zero_seconds; -invalid argument parse timestamp, bad value 00:00:00 +➤ zero_seconds[-6,8,0] 𝄀 +0 SELECT SECOND('23:59:59') AS max_seconds; -invalid argument parse timestamp, bad value 23:59:59 +➤ max_seconds[-6,8,0] 𝄀 +59 SELECT SECOND('12:34:56') AS various_seconds; -invalid argument parse timestamp, bad value 12:34:56 +➤ various_seconds[-6,8,0] 𝄀 +56 SELECT SECOND('2024-12-20 15:30:45') AS datetime_with_seconds; -datetime_with_seconds +➤ datetime_with_seconds[-6,8,0] 𝄀 45 SELECT SECOND('2024-12-20 00:00:00') AS datetime_zero; -datetime_zero +➤ datetime_zero[-6,8,0] 𝄀 0 SELECT SECOND('2024-12-20 23:59:59') AS datetime_max; -datetime_max +➤ datetime_max[-6,8,0] 𝄀 59 SELECT SECOND(TIMESTAMP('2024-12-20 15:30:45')) AS timestamp_with_seconds; -timestamp_with_seconds +➤ timestamp_with_seconds[-6,8,0] 𝄀 45 SELECT SECOND(NULL) AS null_input; -null_input +➤ null_input[-6,8,0] 𝄀 null CREATE TABLE t1(t TIME); INSERT INTO t1 VALUES ('15:30:45'), ('00:00:00'), ('23:59:59'), ('12:34:56'); SELECT t, SECOND(t) AS second FROM t1; -t second -15:30:45 45 -00:00:00 0 -23:59:59 59 -12:34:56 56 +➤ t[92,64,0] ¦ second[-6,8,0] 𝄀 +15:30:45 ¦ 45 𝄀 +00:00:00 ¦ 0 𝄀 +23:59:59 ¦ 59 𝄀 +12:34:56 ¦ 56 DROP TABLE t1; CREATE TABLE t1(dt DATETIME); INSERT INTO t1 VALUES ('2024-12-20 15:30:45'), ('2024-12-20 00:00:00'), ('2024-12-20 23:59:59'); SELECT dt, SECOND(dt) AS second FROM t1; -dt second -2024-12-20 15:30:45 45 -2024-12-20 00:00:00 0 -2024-12-20 23:59:59 59 +➤ dt[93,64,0] ¦ second[-6,8,0] 𝄀 +2024-12-20 15:30:45 ¦ 45 𝄀 +2024-12-20 00:00:00 ¦ 0 𝄀 +2024-12-20 23:59:59 ¦ 59 DROP TABLE t1; CREATE TABLE t1(t TIME); INSERT INTO t1 VALUES ('15:30:45'), ('00:00:00'), ('23:59:59'), ('12:34:56'); SELECT * FROM t1 WHERE SECOND(t) > 30; -t -15:30:45 -23:59:59 +➤ t[92,64,0] 𝄀 +15:30:45 𝄀 +23:59:59 𝄀 12:34:56 DROP TABLE t1; SELECT SECOND('15:30:45') AS result1, SECOND('15:30:00') AS result2, SECOND('15:30:59') AS result3; -invalid argument parse timestamp, bad value 15:30:45 +➤ result1[-6,8,0] ¦ result2[-6,8,0] ¦ result3[-6,8,0] 𝄀 +45 ¦ 0 ¦ 59 +SELECT SECOND(CAST('12:30:45' AS VARCHAR)) AS varchar_second, +SECOND(CAST('272:59:59' AS VARCHAR)) AS varchar_extended_second, +SECOND(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_second, +SECOND(CAST(NULL AS VARCHAR)) AS varchar_null_second; +➤ varchar_second[-6,8,0] ¦ varchar_extended_second[-6,8,0] ¦ varchar_datetime_second[-6,8,0] ¦ varchar_null_second[-6,8,0] 𝄀 +45 ¦ 59 ¦ 45 ¦ null diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 89c2d2871fe55..51b5368ef25b2 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -40,3 +40,8 @@ DROP TABLE t1; # Range check (should return 0-59) SELECT SECOND('15:30:45') AS result1, SECOND('15:30:00') AS result2, SECOND('15:30:59') AS result3; +-- VARCHAR time-string input +SELECT SECOND(CAST('12:30:45' AS VARCHAR)) AS varchar_second, + SECOND(CAST('272:59:59' AS VARCHAR)) AS varchar_extended_second, + SECOND(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_second, + SECOND(CAST(NULL AS VARCHAR)) AS varchar_null_second; From 823d3fecd216371d7c1549f40f36a57d3d547b9e Mon Sep 17 00:00:00 2001 From: daviszhen Date: Mon, 27 Jul 2026 21:41:44 +0800 Subject: [PATCH 02/25] update --- pkg/sql/plan/function/func_unary.go | 31 +++-- pkg/sql/plan/function/func_unary_test.go | 125 +++++++++++++++--- .../cases/function/func_datetime_hour.result | 6 +- .../cases/function/func_datetime_hour.test | 4 + .../function/func_datetime_minute.result | 4 + .../cases/function/func_datetime_minute.test | 4 + .../function/func_datetime_second.result | 4 + .../cases/function/func_datetime_second.test | 4 + 8 files changed, 154 insertions(+), 28 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index 7f5b657bd575b..c1b313eac13d9 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4640,7 +4640,7 @@ func timeStringToFixedWithNullOnError[T types.FixedSizeTExceptStrType]( result vector.FunctionResultWrapper, length int, selectList *FunctionSelectList, - fn func(types.Time) T, + fn func(hour uint32, minute, second uint8) T, ) error { strParam := vector.GenerateFunctionStrParameter(ivecs[0]) rs := vector.MustFunctionResult[T](result) @@ -4663,7 +4663,16 @@ func timeStringToFixedWithNullOnError[T types.FixedSizeTExceptStrType]( continue } - timeVal, err := types.ParseTime(functionUtil.QuickBytesToStr(strVal), 6) + str := functionUtil.QuickBytesToStr(strVal) + if dt, err := types.ParseDatetime(str, 6); err == nil { + hour, minute, second := dt.Clock() + if err := rs.Append(fn(uint32(hour), uint8(minute), uint8(second)), false); err != nil { + return err + } + continue + } + + timeVal, err := types.ParseTime(str, 6) if err != nil { if err := rs.Append(zero, true); err != nil { return err @@ -4671,7 +4680,8 @@ func timeStringToFixedWithNullOnError[T types.FixedSizeTExceptStrType]( continue } - if err := rs.Append(fn(timeVal), false); err != nil { + hour, minute, second, _, _ := timeVal.ClockFormat() + if err := rs.Append(fn(uint32(hour), minute, second), false); err != nil { return err } } @@ -4679,23 +4689,20 @@ func timeStringToFixedWithNullOnError[T types.FixedSizeTExceptStrType]( } func StringToHour(ivecs []*vector.Vector, result vector.FunctionResultWrapper, _ *process.Process, length int, selectList *FunctionSelectList) error { - return timeStringToFixedWithNullOnError(ivecs, result, length, selectList, func(v types.Time) uint32 { - hour, _, _, _, _ := v.ClockFormat() - return uint32(hour) + return timeStringToFixedWithNullOnError(ivecs, result, length, selectList, func(hour uint32, _ uint8, _ uint8) uint32 { + return hour }) } func StringToMinute(ivecs []*vector.Vector, result vector.FunctionResultWrapper, _ *process.Process, length int, selectList *FunctionSelectList) error { - return timeStringToFixedWithNullOnError(ivecs, result, length, selectList, func(v types.Time) uint8 { - _, minute, _, _, _ := v.ClockFormat() - return uint8(minute) + return timeStringToFixedWithNullOnError(ivecs, result, length, selectList, func(_ uint32, minute uint8, _ uint8) uint8 { + return minute }) } func StringToSecond(ivecs []*vector.Vector, result vector.FunctionResultWrapper, _ *process.Process, length int, selectList *FunctionSelectList) error { - return timeStringToFixedWithNullOnError(ivecs, result, length, selectList, func(v types.Time) uint8 { - _, _, second, _, _ := v.ClockFormat() - return uint8(second) + return timeStringToFixedWithNullOnError(ivecs, result, length, selectList, func(_ uint32, _ uint8, second uint8) uint8 { + return second }) } diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index ae4ea70ff4b22..b2bd3f5fe4854 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4163,8 +4163,8 @@ func TestStringTimeExtract(t *testing.T) { proc := testutil.NewProcess(t) inputs := []FunctionTestInput{ NewFunctionTestInput(types.T_varchar.ToType(), - []string{"12:30:45", "272:59:59", "2024-12-20 15:30:45", "invalid", ""}, - []bool{false, false, false, false, false}), + []string{"12:30:45", "272:59:59", "2024-12-20 15:30:45", "2024-12-20", "invalid", ""}, + []bool{false, false, false, false, false, false}), } testCases := []struct { @@ -4175,19 +4175,19 @@ func TestStringTimeExtract(t *testing.T) { { name: "hour", expect: NewFunctionTestResult(types.T_uint32.ToType(), false, - []uint32{12, 272, 15, 0, 0}, []bool{false, false, false, true, true}), + []uint32{12, 272, 15, 0, 0, 0}, []bool{false, false, false, false, true, true}), fn: StringToHour, }, { name: "minute", expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{30, 59, 30, 0, 0}, []bool{false, false, false, true, true}), + []uint8{30, 59, 30, 0, 0, 0}, []bool{false, false, false, false, true, true}), fn: StringToMinute, }, { name: "second", expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{45, 59, 45, 0, 0}, []bool{false, false, false, true, true}), + []uint8{45, 59, 45, 0, 0, 0}, []bool{false, false, false, false, true, true}), fn: StringToSecond, }, } @@ -4200,19 +4200,114 @@ func TestStringTimeExtract(t *testing.T) { }) } - for _, tc := range []struct { +} + +func TestStringTimeExtractTomorrowDatetime(t *testing.T) { + proc := testutil.NewProcess(t) + tomorrow := types.Today(time.UTC) + 1 + input := tomorrow.String() + " 01:02:03" + + testCases := []struct { + name string + expect FunctionTestResult + fn fEvalFn + }{ + { + name: "hour", + expect: NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{1}, []bool{false}), + fn: StringToHour, + }, + { + name: "minute", + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{2}, []bool{false}), + fn: StringToMinute, + }, + { + name: "second", + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{3}, []bool{false}), + fn: StringToSecond, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + inputs := []FunctionTestInput{ + NewFunctionTestInput(types.T_varchar.ToType(), []string{input}, []bool{false}), + } + tcc := NewFunctionTestCase(proc, inputs, tc.expect, tc.fn) + succeed, info := tcc.Run() + require.True(t, succeed, info) + }) + } +} + +func TestStringTimeExtractRegisteredOverloads(t *testing.T) { + proc := testutil.NewProcess(t) + inputValues := []string{"12:30:45", "272:59:59", "2024-12-20 15:30:45", "2024-12-20", "invalid", ""} + wantNulls := []bool{false, false, false, false, true, true} + + typeCases := []types.T{types.T_varchar, types.T_char, types.T_text} + functionCases := []struct { name string returnType types.T + hours []uint32 + parts []uint8 }{ - {name: "hour", returnType: types.T_uint32}, - {name: "minute", returnType: types.T_uint8}, - {name: "second", returnType: types.T_uint8}, - } { - t.Run("registered_"+tc.name, func(t *testing.T) { - fn, err := GetFunctionByName(proc.Ctx, tc.name, []types.Type{types.T_varchar.ToType()}) - require.NoError(t, err) - require.Equal(t, tc.returnType, fn.GetReturnType().Oid) - }) + {name: "hour", returnType: types.T_uint32, hours: []uint32{12, 272, 15, 0, 0, 0}}, + {name: "minute", returnType: types.T_uint8, parts: []uint8{30, 59, 30, 0, 0, 0}}, + {name: "second", returnType: types.T_uint8, parts: []uint8{45, 59, 45, 0, 0, 0}}, + } + + for _, inputType := range typeCases { + for _, functionCase := range functionCases { + t.Run(functionCase.name+"/"+inputType.String(), func(t *testing.T) { + input := newVectorByType(proc.Mp(), inputType.ToType(), inputValues, nil) + defer input.Free(proc.Mp()) + + fn, err := GetFunctionByName(proc.Ctx, functionCase.name, []types.Type{inputType.ToType()}) + require.NoError(t, err) + require.Equal(t, functionCase.returnType, fn.GetReturnType().Oid) + + out, err := RunFunctionDirectly(proc, fn.GetEncodedOverloadID(), []*vector.Vector{input}, len(inputValues)) + require.NoError(t, err) + defer out.Free(proc.Mp()) + + for i, wantNull := range wantNulls { + require.Equal(t, wantNull, out.IsNull(uint64(i))) + } + switch functionCase.returnType { + case types.T_uint32: + require.Equal(t, functionCase.hours, vector.MustFixedColWithTypeCheck[uint32](out)) + case types.T_uint8: + require.Equal(t, functionCase.parts, vector.MustFixedColWithTypeCheck[uint8](out)) + } + }) + } + } +} + +func TestStringTimeExtractSelectList(t *testing.T) { + proc := testutil.NewProcess(t) + fcTC := NewFunctionTestCase(proc, + []FunctionTestInput{ + NewFunctionTestInput(types.T_varchar.ToType(), []string{"12:30:45", "invalid"}, []bool{false, false}), + }, + NewFunctionTestResult(types.T_uint32.ToType(), false, nil, nil), + StringToHour) + + require.NoError(t, fcTC.result.PreExtendAndReset(fcTC.fnLength)) + require.NoError(t, fcTC.fn(fcTC.parameters, fcTC.result, fcTC.proc, fcTC.fnLength, + &FunctionSelectList{AnyNull: true, SelectList: []bool{true, false}})) + resultVec := fcTC.result.GetResultVector() + require.Equal(t, uint32(12), vector.GetFixedAtNoTypeCheck[uint32](resultVec, 0)) + require.True(t, resultVec.IsNull(1)) + + require.NoError(t, fcTC.result.PreExtendAndReset(fcTC.fnLength)) + require.NoError(t, fcTC.fn(fcTC.parameters, fcTC.result, fcTC.proc, fcTC.fnLength, + &FunctionSelectList{AllNull: true})) + resultVec = fcTC.result.GetResultVector() + for i := 0; i < fcTC.fnLength; i++ { + require.True(t, resultVec.IsNull(uint64(i))) } } diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index 249f780cd760f..e0bbe3cb68542 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -6,7 +6,7 @@ SELECT HOUR('2024-12-20 15:30:45') AS result2; 15 SELECT HOUR(NOW()) AS result3; ➤ result3[-6,8,0] 𝄀 -19 +21 SELECT HOUR(CAST('15:30:45' AS TIME)) AS time_cast; ➤ time_cast[4,32,0] 𝄀 15 @@ -73,3 +73,7 @@ HOUR(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_hour, HOUR(CAST(NULL AS VARCHAR)) AS varchar_null_hour; ➤ varchar_hour[4,32,0] ¦ varchar_extended_hour[4,32,0] ¦ varchar_datetime_hour[4,32,0] ¦ varchar_null_hour[4,32,0] 𝄀 12 ¦ 272 ¦ 15 ¦ null +SELECT HOUR('2024-12-20') AS date_string_hour, +HOUR(CONCAT(DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY), ' 01:02:03')) AS tomorrow_datetime_hour; +➤ date_string_hour[4,32,0] ¦ tomorrow_datetime_hour[4,32,0] 𝄀 +0 ¦ 1 diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index 7c12701083be5..c820e8cd83e15 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -49,3 +49,7 @@ SELECT HOUR(CAST('12:30:00' AS VARCHAR)) AS varchar_hour, HOUR(CAST('272:59:59' AS VARCHAR)) AS varchar_extended_hour, HOUR(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_hour, HOUR(CAST(NULL AS VARCHAR)) AS varchar_null_hour; + +-- DATE and tomorrow DATETIME strings must not be converted through DATETIME.ToTime. +SELECT HOUR('2024-12-20') AS date_string_hour, + HOUR(CONCAT(DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY), ' 01:02:03')) AS tomorrow_datetime_hour; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 7226f9ffcdf9a..cecc493ec74d3 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -61,3 +61,7 @@ MINUTE(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_minute, MINUTE(CAST(NULL AS VARCHAR)) AS varchar_null_minute; ➤ varchar_minute[-6,8,0] ¦ varchar_extended_minute[-6,8,0] ¦ varchar_datetime_minute[-6,8,0] ¦ varchar_null_minute[-6,8,0] 𝄀 30 ¦ 59 ¦ 30 ¦ null +SELECT MINUTE('2024-12-20') AS date_string_minute, +MINUTE(CONCAT(DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY), ' 01:02:03')) AS tomorrow_datetime_minute; +➤ date_string_minute[-6,8,0] ¦ tomorrow_datetime_minute[-6,8,0] 𝄀 +0 ¦ 2 diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index 393273ba5fcb2..a46caacc67d04 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -45,3 +45,7 @@ SELECT MINUTE(CAST('12:30:00' AS VARCHAR)) AS varchar_minute, MINUTE(CAST('272:59:59' AS VARCHAR)) AS varchar_extended_minute, MINUTE(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_minute, MINUTE(CAST(NULL AS VARCHAR)) AS varchar_null_minute; + +-- DATE and tomorrow DATETIME strings preserve calendar clock fields. +SELECT MINUTE('2024-12-20') AS date_string_minute, + MINUTE(CONCAT(DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY), ' 01:02:03')) AS tomorrow_datetime_minute; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index c453849bde506..a197847a95e4f 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -62,3 +62,7 @@ SECOND(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_second, SECOND(CAST(NULL AS VARCHAR)) AS varchar_null_second; ➤ varchar_second[-6,8,0] ¦ varchar_extended_second[-6,8,0] ¦ varchar_datetime_second[-6,8,0] ¦ varchar_null_second[-6,8,0] 𝄀 45 ¦ 59 ¦ 45 ¦ null +SELECT SECOND('2024-12-20') AS date_string_second, +SECOND(CONCAT(DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY), ' 01:02:03')) AS tomorrow_datetime_second; +➤ date_string_second[-6,8,0] ¦ tomorrow_datetime_second[-6,8,0] 𝄀 +0 ¦ 3 diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 51b5368ef25b2..22ca4eb5c99e6 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -45,3 +45,7 @@ SELECT SECOND(CAST('12:30:45' AS VARCHAR)) AS varchar_second, SECOND(CAST('272:59:59' AS VARCHAR)) AS varchar_extended_second, SECOND(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_second, SECOND(CAST(NULL AS VARCHAR)) AS varchar_null_second; + +-- DATE and tomorrow DATETIME strings preserve calendar clock fields. +SELECT SECOND('2024-12-20') AS date_string_second, + SECOND(CONCAT(DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY), ' 01:02:03')) AS tomorrow_datetime_second; From 147f016ef95c18c5e21fb03bf66ece1c7cb3b19f Mon Sep 17 00:00:00 2001 From: daviszhen Date: Tue, 28 Jul 2026 11:57:01 +0800 Subject: [PATCH 03/25] update --- pkg/sql/plan/function/func_unary.go | 82 ++++++++++++++++--- pkg/sql/plan/function/func_unary_test.go | 47 +++++++++-- .../cases/function/func_datetime_hour.result | 7 +- .../cases/function/func_datetime_hour.test | 7 +- .../function/func_datetime_minute.result | 7 +- .../cases/function/func_datetime_minute.test | 7 +- .../function/func_datetime_second.result | 7 +- .../cases/function/func_datetime_second.test | 7 +- 8 files changed, 145 insertions(+), 26 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index d198d620e15bd..7eb4fa775c59d 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4656,31 +4656,22 @@ func timeStringToFixedWithNullOnError[T types.FixedSizeTExceptStrType]( } strVal, null := strParam.GetStrValue(i) - if null || len(strVal) == 0 { + str := strings.TrimSpace(functionUtil.QuickBytesToStr(strVal)) + if null || len(str) == 0 { if err := rs.Append(zero, true); err != nil { return err } continue } - str := functionUtil.QuickBytesToStr(strVal) - if dt, err := types.ParseDatetime(str, 6); err == nil { - hour, minute, second := dt.Clock() - if err := rs.Append(fn(uint32(hour), uint8(minute), uint8(second)), false); err != nil { - return err - } - continue - } - - timeVal, err := types.ParseTime(str, 6) - if err != nil { + hour, minute, second, ok := timeStringToClockForExtract(str) + if !ok { if err := rs.Append(zero, true); err != nil { return err } continue } - hour, minute, second, _, _ := timeVal.ClockFormat() if err := rs.Append(fn(uint32(hour), minute, second), false); err != nil { return err } @@ -4688,6 +4679,71 @@ func timeStringToFixedWithNullOnError[T types.FixedSizeTExceptStrType]( return nil } +// timeStringToClockForExtract follows MySQL Item::get_time_from_string: +// complete DATETIME strings use their clock fields; other strings use TIME +// coercion. In particular, a date-only string such as "2024-12-20" is parsed +// as the compact TIME prefix "2024", not as midnight on that date. +func timeStringToClockForExtract(str string) (uint64, uint8, uint8, bool) { + if isDatetimeStringForTimeExtract(str) { + dt, err := types.ParseDatetime(str, 6) + if err != nil { + return 0, 0, 0, false + } + hour, minute, second := dt.Clock() + return uint64(hour), uint8(minute), uint8(second), true + } + + timeVal, err := types.ParseTime(str, 6) + if err != nil { + // MySQL's str_to_time consumes a leading numeric field before reporting + // trailing non-time characters. This covers date-only strings, where + // 2024-12-20 becomes the compact TIME 00:20:24. + start := 0 + if str[0] == '-' { + start = 1 + } + end := start + for end < len(str) && str[end] >= '0' && str[end] <= '9' { + end++ + } + if end == start { + return 0, 0, 0, false + } + timeVal, err = types.ParseTime(str[start:end], 6) + if err != nil { + return 0, 0, 0, false + } + } + + hour, minute, second, _, _ := timeVal.ClockFormat() + return hour, minute, second, true +} + +func isDatetimeStringForTimeExtract(str string) bool { + if len(str) >= 14 { + compact := true + for i := 0; i < 14; i++ { + if str[i] < '0' || str[i] > '9' { + compact = false + break + } + } + if compact { + return true + } + } + + if len(str) < 12 || (str[4] != '-' && str[4] != '/') { + return false + } + for i := 0; i < 4; i++ { + if str[i] < '0' || str[i] > '9' { + return false + } + } + return str[10] == ' ' || str[10] == 'T' +} + func StringToHour(ivecs []*vector.Vector, result vector.FunctionResultWrapper, _ *process.Process, length int, selectList *FunctionSelectList) error { return timeStringToFixedWithNullOnError(ivecs, result, length, selectList, func(hour uint32, _ uint8, _ uint8) uint32 { return hour diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 41e812b752a0a..e4d009e6da276 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4163,8 +4163,11 @@ func TestStringTimeExtract(t *testing.T) { proc := testutil.NewProcess(t) inputs := []FunctionTestInput{ NewFunctionTestInput(types.T_varchar.ToType(), - []string{"12:30:45", "272:59:59", "2024-12-20 15:30:45", "2024-12-20", "invalid", ""}, - []bool{false, false, false, false, false, false}), + []string{ + "12:30:45", "272:59:59", "-272:59:59", "2 03:04:05", "123045", + "2024-12-20 15:30:45", "20241220153045", "2024-12-20", "invalid", "", " ", "\t", + }, + []bool{false, false, false, false, false, false, false, false, false, false, false, false}), } testCases := []struct { @@ -4175,19 +4178,22 @@ func TestStringTimeExtract(t *testing.T) { { name: "hour", expect: NewFunctionTestResult(types.T_uint32.ToType(), false, - []uint32{12, 272, 15, 0, 0, 0}, []bool{false, false, false, false, true, true}), + []uint32{12, 272, 272, 51, 12, 15, 15, 0, 0, 0, 0, 0}, + []bool{false, false, false, false, false, false, false, false, true, true, true, true}), fn: StringToHour, }, { name: "minute", expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{30, 59, 30, 0, 0, 0}, []bool{false, false, false, false, true, true}), + []uint8{30, 59, 59, 4, 30, 30, 30, 20, 0, 0, 0, 0}, + []bool{false, false, false, false, false, false, false, false, true, true, true, true}), fn: StringToMinute, }, { name: "second", expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{45, 59, 45, 0, 0, 0}, []bool{false, false, false, false, true, true}), + []uint8{45, 59, 59, 5, 45, 45, 45, 24, 0, 0, 0, 0}, + []bool{false, false, false, false, false, false, false, false, true, true, true, true}), fn: StringToSecond, }, } @@ -4202,6 +4208,33 @@ func TestStringTimeExtract(t *testing.T) { } +func TestStringTimeExtractWhitespace(t *testing.T) { + for _, typ := range []types.T{types.T_char, types.T_varchar, types.T_text} { + t.Run(typ.String(), func(t *testing.T) { + proc := testutil.NewProcess(t) + inputs := []FunctionTestInput{ + NewFunctionTestInput(typ.ToType(), []string{" ", "\t"}, []bool{false, false}), + } + + for _, tc := range []struct { + name string + expect FunctionTestResult + fn fEvalFn + }{ + {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{0, 0}, []bool{true, true}), StringToHour}, + {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{0, 0}, []bool{true, true}), StringToMinute}, + {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{0, 0}, []bool{true, true}), StringToSecond}, + } { + t.Run(tc.name, func(t *testing.T) { + tcc := NewFunctionTestCase(proc, inputs, tc.expect, tc.fn) + succeed, info := tcc.Run() + require.True(t, succeed, info) + }) + } + }) + } +} + func TestStringTimeExtractTomorrowDatetime(t *testing.T) { proc := testutil.NewProcess(t) tomorrow := types.Today(time.UTC) + 1 @@ -4254,8 +4287,8 @@ func TestStringTimeExtractRegisteredOverloads(t *testing.T) { parts []uint8 }{ {name: "hour", returnType: types.T_uint32, hours: []uint32{12, 272, 15, 0, 0, 0}}, - {name: "minute", returnType: types.T_uint8, parts: []uint8{30, 59, 30, 0, 0, 0}}, - {name: "second", returnType: types.T_uint8, parts: []uint8{45, 59, 45, 0, 0, 0}}, + {name: "minute", returnType: types.T_uint8, parts: []uint8{30, 59, 30, 20, 0, 0}}, + {name: "second", returnType: types.T_uint8, parts: []uint8{45, 59, 45, 24, 0, 0}}, } for _, inputType := range typeCases { diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index e0bbe3cb68542..6f7dec5ad68e3 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -6,7 +6,7 @@ SELECT HOUR('2024-12-20 15:30:45') AS result2; 15 SELECT HOUR(NOW()) AS result3; ➤ result3[-6,8,0] 𝄀 -21 +11 SELECT HOUR(CAST('15:30:45' AS TIME)) AS time_cast; ➤ time_cast[4,32,0] 𝄀 15 @@ -77,3 +77,8 @@ SELECT HOUR('2024-12-20') AS date_string_hour, HOUR(CONCAT(DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY), ' 01:02:03')) AS tomorrow_datetime_hour; ➤ date_string_hour[4,32,0] ¦ tomorrow_datetime_hour[4,32,0] 𝄀 0 ¦ 1 +SELECT HOUR(CAST(' ' AS CHAR)) AS char_spaces_hour, +HOUR(CAST('\t' AS VARCHAR)) AS varchar_tab_hour, +HOUR(CAST(' ' AS TEXT)) AS text_spaces_hour; +➤ char_spaces_hour[4,32,0] ¦ varchar_tab_hour[4,32,0] ¦ text_spaces_hour[4,32,0] 𝄀 +null ¦ null ¦ null diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index c820e8cd83e15..1f557fa355a9a 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -50,6 +50,11 @@ SELECT HOUR(CAST('12:30:00' AS VARCHAR)) AS varchar_hour, HOUR(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_hour, HOUR(CAST(NULL AS VARCHAR)) AS varchar_null_hour; --- DATE and tomorrow DATETIME strings must not be converted through DATETIME.ToTime. +-- Date-only strings use MySQL TIME coercion; complete DATETIME strings use clock fields. SELECT HOUR('2024-12-20') AS date_string_hour, HOUR(CONCAT(DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY), ' 01:02:03')) AS tomorrow_datetime_hour; + +-- Whitespace-only string values are invalid TIME inputs. +SELECT HOUR(CAST(' ' AS CHAR)) AS char_spaces_hour, + HOUR(CAST('\t' AS VARCHAR)) AS varchar_tab_hour, + HOUR(CAST(' ' AS TEXT)) AS text_spaces_hour; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index cecc493ec74d3..3c6afbd9fbbea 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -64,4 +64,9 @@ MINUTE(CAST(NULL AS VARCHAR)) AS varchar_null_minute; SELECT MINUTE('2024-12-20') AS date_string_minute, MINUTE(CONCAT(DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY), ' 01:02:03')) AS tomorrow_datetime_minute; ➤ date_string_minute[-6,8,0] ¦ tomorrow_datetime_minute[-6,8,0] 𝄀 -0 ¦ 2 +20 ¦ 2 +SELECT MINUTE(CAST(' ' AS CHAR)) AS char_spaces_minute, +MINUTE(CAST('\t' AS VARCHAR)) AS varchar_tab_minute, +MINUTE(CAST(' ' AS TEXT)) AS text_spaces_minute; +➤ char_spaces_minute[-6,8,0] ¦ varchar_tab_minute[-6,8,0] ¦ text_spaces_minute[-6,8,0] 𝄀 +null ¦ null ¦ null diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index a46caacc67d04..9205f9a63e9be 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -46,6 +46,11 @@ SELECT MINUTE(CAST('12:30:00' AS VARCHAR)) AS varchar_minute, MINUTE(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_minute, MINUTE(CAST(NULL AS VARCHAR)) AS varchar_null_minute; --- DATE and tomorrow DATETIME strings preserve calendar clock fields. +-- Date-only strings use MySQL TIME coercion; complete DATETIME strings use clock fields. SELECT MINUTE('2024-12-20') AS date_string_minute, MINUTE(CONCAT(DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY), ' 01:02:03')) AS tomorrow_datetime_minute; + +-- Whitespace-only string values are invalid TIME inputs. +SELECT MINUTE(CAST(' ' AS CHAR)) AS char_spaces_minute, + MINUTE(CAST('\t' AS VARCHAR)) AS varchar_tab_minute, + MINUTE(CAST(' ' AS TEXT)) AS text_spaces_minute; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index a197847a95e4f..676efa2abc93c 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -65,4 +65,9 @@ SECOND(CAST(NULL AS VARCHAR)) AS varchar_null_second; SELECT SECOND('2024-12-20') AS date_string_second, SECOND(CONCAT(DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY), ' 01:02:03')) AS tomorrow_datetime_second; ➤ date_string_second[-6,8,0] ¦ tomorrow_datetime_second[-6,8,0] 𝄀 -0 ¦ 3 +24 ¦ 3 +SELECT SECOND(CAST(' ' AS CHAR)) AS char_spaces_second, +SECOND(CAST('\t' AS VARCHAR)) AS varchar_tab_second, +SECOND(CAST(' ' AS TEXT)) AS text_spaces_second; +➤ char_spaces_second[-6,8,0] ¦ varchar_tab_second[-6,8,0] ¦ text_spaces_second[-6,8,0] 𝄀 +null ¦ null ¦ null diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 22ca4eb5c99e6..31ca5af88bf59 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -46,6 +46,11 @@ SELECT SECOND(CAST('12:30:45' AS VARCHAR)) AS varchar_second, SECOND(CAST('2024-12-20 15:30:45' AS VARCHAR)) AS varchar_datetime_second, SECOND(CAST(NULL AS VARCHAR)) AS varchar_null_second; --- DATE and tomorrow DATETIME strings preserve calendar clock fields. +-- Date-only strings use MySQL TIME coercion; complete DATETIME strings use clock fields. SELECT SECOND('2024-12-20') AS date_string_second, SECOND(CONCAT(DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY), ' 01:02:03')) AS tomorrow_datetime_second; + +-- Whitespace-only string values are invalid TIME inputs. +SELECT SECOND(CAST(' ' AS CHAR)) AS char_spaces_second, + SECOND(CAST('\t' AS VARCHAR)) AS varchar_tab_second, + SECOND(CAST(' ' AS TEXT)) AS text_spaces_second; From 91700c10184e6e84a2d29d464954002f08b57993 Mon Sep 17 00:00:00 2001 From: daviszhen Date: Tue, 28 Jul 2026 12:26:54 +0800 Subject: [PATCH 04/25] update --- pkg/sql/plan/function/func_unary.go | 141 ++++++++++++------ pkg/sql/plan/function/func_unary_test.go | 17 ++- .../cases/function/func_datetime_hour.result | 8 +- .../cases/function/func_datetime_hour.test | 6 + .../function/func_datetime_minute.result | 6 + .../cases/function/func_datetime_minute.test | 6 + .../function/func_datetime_second.result | 6 + .../cases/function/func_datetime_second.test | 6 + 8 files changed, 145 insertions(+), 51 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index 7eb4fa775c59d..b841c8f06f4f1 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4680,38 +4680,43 @@ func timeStringToFixedWithNullOnError[T types.FixedSizeTExceptStrType]( } // timeStringToClockForExtract follows MySQL Item::get_time_from_string: -// complete DATETIME strings use their clock fields; other strings use TIME -// coercion. In particular, a date-only string such as "2024-12-20" is parsed -// as the compact TIME prefix "2024", not as midnight on that date. +// strings long enough to be DATETIME values are parsed as DATETIME first; +// shorter strings use TIME coercion. In particular, a date-only string such as +// "2024-12-20" is parsed as the compact TIME prefix "2024", not as midnight +// on that date. func timeStringToClockForExtract(str string) (uint64, uint8, uint8, bool) { - if isDatetimeStringForTimeExtract(str) { - dt, err := types.ParseDatetime(str, 6) - if err != nil { - return 0, 0, 0, false - } - hour, minute, second := dt.Clock() - return uint64(hour), uint8(minute), uint8(second), true + datetimeString := str + if datetimeString[0] == '-' { + datetimeString = datetimeString[1:] + } + if len(datetimeString) >= 12 { + return mysqlDatetimeStringToClockForExtract(datetimeString) } timeVal, err := types.ParseTime(str, 6) if err != nil { - // MySQL's str_to_time consumes a leading numeric field before reporting - // trailing non-time characters. This covers date-only strings, where - // 2024-12-20 becomes the compact TIME 00:20:24. - start := 0 - if str[0] == '-' { - start = 1 + if prefix := mysqlTimePrefixForExtract(str); len(prefix) > 0 && len(prefix) != len(str) { + timeVal, err = types.ParseTime(prefix, 6) } - end := start - for end < len(str) && str[end] >= '0' && str[end] <= '9' { - end++ - } - if end == start { - return 0, 0, 0, false - } - timeVal, err = types.ParseTime(str[start:end], 6) if err != nil { - return 0, 0, 0, false + // MySQL's str_to_time consumes a leading numeric field before reporting + // trailing non-time characters. This covers date-only strings, where + // 2024-12-20 becomes the compact TIME 00:20:24. + start := 0 + if str[0] == '-' { + start = 1 + } + end := start + for end < len(str) && str[end] >= '0' && str[end] <= '9' { + end++ + } + if end == start { + return 0, 0, 0, false + } + timeVal, err = types.ParseTime(str[start:end], 6) + if err != nil { + return 0, 0, 0, false + } } } @@ -4719,29 +4724,81 @@ func timeStringToClockForExtract(str string) (uint64, uint8, uint8, bool) { return hour, minute, second, true } -func isDatetimeStringForTimeExtract(str string) bool { - if len(str) >= 14 { - compact := true - for i := 0; i < 14; i++ { - if str[i] < '0' || str[i] > '9' { - compact = false - break - } - } - if compact { - return true +func mysqlDatetimeStringToClockForExtract(str string) (uint64, uint8, uint8, bool) { + if dt, err := types.ParseDatetime(str, 6); err == nil { + hour, minute, second := dt.Clock() + return uint64(hour), uint8(minute), uint8(second), true + } + + // MySQL accepts both YYYYMMDDHHMMSS and YYMMDDHHMMSS. MatrixOne's + // ParseDatetime supports only the former, so retain the two-digit-year + // form locally for these TIME extract functions. + if hour, minute, second, ok := parseCompactDatetimeClockForExtract(str); ok { + return hour, minute, second, true + } + + // str_to_time keeps a complete DATETIME prefix and reports trailing text as + // a warning. The function result has no warning channel, but must retain the + // parsed clock fields. + if len(str) >= 19 && (str[4] == '-' || str[4] == '/') && (str[10] == ' ' || str[10] == 'T') { + if dt, err := types.ParseDatetime(str[:19], 6); err == nil { + hour, minute, second := dt.Clock() + return uint64(hour), uint8(minute), uint8(second), true } } + return 0, 0, 0, false +} - if len(str) < 12 || (str[4] != '-' && str[4] != '/') { - return false +func parseCompactDatetimeClockForExtract(str string) (uint64, uint8, uint8, bool) { + digitCount := 0 + for digitCount < len(str) && str[digitCount] >= '0' && str[digitCount] <= '9' { + digitCount++ } - for i := 0; i < 4; i++ { - if str[i] < '0' || str[i] > '9' { - return false + + if digitCount >= 14 { + return compactDatetimeClockForExtract(str[:14], false) + } + if digitCount >= 12 { + return compactDatetimeClockForExtract(str[:12], true) + } + return 0, 0, 0, false +} + +func compactDatetimeClockForExtract(str string, twoDigitYear bool) (uint64, uint8, uint8, bool) { + yearWidth := 4 + if twoDigitYear { + yearWidth = 2 + } + + year := 0 + for i := 0; i < yearWidth; i++ { + year = year*10 + int(str[i]-'0') + } + if twoDigitYear { + year = adjustYear(year) + } + + month := (str[yearWidth]-'0')*10 + str[yearWidth+1] - '0' + day := (str[yearWidth+2]-'0')*10 + str[yearWidth+3] - '0' + hour := (str[yearWidth+4]-'0')*10 + str[yearWidth+5] - '0' + minute := (str[yearWidth+6]-'0')*10 + str[yearWidth+7] - '0' + second := (str[yearWidth+8]-'0')*10 + str[yearWidth+9] - '0' + if !types.ValidDate(int32(year), month, day) || !types.ValidTimeInDay(hour, minute, second) { + return 0, 0, 0, false + } + return uint64(hour), minute, second, true +} + +func mysqlTimePrefixForExtract(str string) string { + end := 0 + for end < len(str) { + c := str[end] + if (c < '0' || c > '9') && c != ':' && c != '.' && c != '-' && c != ' ' { + break } + end++ } - return str[10] == ' ' || str[10] == 'T' + return str[:end] } func StringToHour(ivecs []*vector.Vector, result vector.FunctionResultWrapper, _ *process.Process, length int, selectList *FunctionSelectList) error { diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index e4d009e6da276..fcd28bd54eebe 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4165,9 +4165,10 @@ func TestStringTimeExtract(t *testing.T) { NewFunctionTestInput(types.T_varchar.ToType(), []string{ "12:30:45", "272:59:59", "-272:59:59", "2 03:04:05", "123045", - "2024-12-20 15:30:45", "20241220153045", "2024-12-20", "invalid", "", " ", "\t", + "2024-12-20 15:30:45", "20241220153045", "241220153045", "2024-12-20", + "15:30:45abc", "2024-12-20 15:30:45abc", "2024-12-20foo", "invalid", "", " ", "\t", }, - []bool{false, false, false, false, false, false, false, false, false, false, false, false}), + []bool{false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false}), } testCases := []struct { @@ -4178,22 +4179,22 @@ func TestStringTimeExtract(t *testing.T) { { name: "hour", expect: NewFunctionTestResult(types.T_uint32.ToType(), false, - []uint32{12, 272, 272, 51, 12, 15, 15, 0, 0, 0, 0, 0}, - []bool{false, false, false, false, false, false, false, false, true, true, true, true}), + []uint32{12, 272, 272, 51, 12, 15, 15, 15, 0, 15, 15, 0, 0, 0, 0, 0}, + []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true}), fn: StringToHour, }, { name: "minute", expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{30, 59, 59, 4, 30, 30, 30, 20, 0, 0, 0, 0}, - []bool{false, false, false, false, false, false, false, false, true, true, true, true}), + []uint8{30, 59, 59, 4, 30, 30, 30, 30, 20, 30, 30, 0, 0, 0, 0, 0}, + []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true}), fn: StringToMinute, }, { name: "second", expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{45, 59, 59, 5, 45, 45, 45, 24, 0, 0, 0, 0}, - []bool{false, false, false, false, false, false, false, false, true, true, true, true}), + []uint8{45, 59, 59, 5, 45, 45, 45, 45, 24, 45, 45, 0, 0, 0, 0, 0}, + []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true}), fn: StringToSecond, }, } diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index 6f7dec5ad68e3..1278adb80444f 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -6,7 +6,7 @@ SELECT HOUR('2024-12-20 15:30:45') AS result2; 15 SELECT HOUR(NOW()) AS result3; ➤ result3[-6,8,0] 𝄀 -11 +12 SELECT HOUR(CAST('15:30:45' AS TIME)) AS time_cast; ➤ time_cast[4,32,0] 𝄀 15 @@ -82,3 +82,9 @@ HOUR(CAST('\t' AS VARCHAR)) AS varchar_tab_hour, HOUR(CAST(' ' AS TEXT)) AS text_spaces_hour; ➤ char_spaces_hour[4,32,0] ¦ varchar_tab_hour[4,32,0] ¦ text_spaces_hour[4,32,0] 𝄀 null ¦ null ¦ null +SELECT HOUR('241220153045') AS compact_datetime_hour, +HOUR('15:30:45abc') AS trailing_time_hour, +HOUR('2024-12-20 15:30:45abc') AS trailing_datetime_hour, +HOUR('2024-12-20foo') AS malformed_datetime_hour; +➤ compact_datetime_hour[4,32,0] ¦ trailing_time_hour[4,32,0] ¦ trailing_datetime_hour[4,32,0] ¦ malformed_datetime_hour[4,32,0] 𝄀 +15 ¦ 15 ¦ 15 ¦ null diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index 1f557fa355a9a..7744ce47bf6a3 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -58,3 +58,9 @@ SELECT HOUR('2024-12-20') AS date_string_hour, SELECT HOUR(CAST(' ' AS CHAR)) AS char_spaces_hour, HOUR(CAST('\t' AS VARCHAR)) AS varchar_tab_hour, HOUR(CAST(' ' AS TEXT)) AS text_spaces_hour; + +-- MySQL str_to_time recognizes YYMMDDHHMMSS and keeps a valid TIME prefix. +SELECT HOUR('241220153045') AS compact_datetime_hour, + HOUR('15:30:45abc') AS trailing_time_hour, + HOUR('2024-12-20 15:30:45abc') AS trailing_datetime_hour, + HOUR('2024-12-20foo') AS malformed_datetime_hour; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 3c6afbd9fbbea..91bbf5e8d74f2 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -70,3 +70,9 @@ MINUTE(CAST('\t' AS VARCHAR)) AS varchar_tab_minute, MINUTE(CAST(' ' AS TEXT)) AS text_spaces_minute; ➤ char_spaces_minute[-6,8,0] ¦ varchar_tab_minute[-6,8,0] ¦ text_spaces_minute[-6,8,0] 𝄀 null ¦ null ¦ null +SELECT MINUTE('241220153045') AS compact_datetime_minute, +MINUTE('15:30:45abc') AS trailing_time_minute, +MINUTE('2024-12-20 15:30:45abc') AS trailing_datetime_minute, +MINUTE('2024-12-20foo') AS malformed_datetime_minute; +➤ compact_datetime_minute[-6,8,0] ¦ trailing_time_minute[-6,8,0] ¦ trailing_datetime_minute[-6,8,0] ¦ malformed_datetime_minute[-6,8,0] 𝄀 +30 ¦ 30 ¦ 30 ¦ null diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index 9205f9a63e9be..ca9045c8cca4e 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -54,3 +54,9 @@ SELECT MINUTE('2024-12-20') AS date_string_minute, SELECT MINUTE(CAST(' ' AS CHAR)) AS char_spaces_minute, MINUTE(CAST('\t' AS VARCHAR)) AS varchar_tab_minute, MINUTE(CAST(' ' AS TEXT)) AS text_spaces_minute; + +-- MySQL str_to_time recognizes YYMMDDHHMMSS and keeps a valid TIME prefix. +SELECT MINUTE('241220153045') AS compact_datetime_minute, + MINUTE('15:30:45abc') AS trailing_time_minute, + MINUTE('2024-12-20 15:30:45abc') AS trailing_datetime_minute, + MINUTE('2024-12-20foo') AS malformed_datetime_minute; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index 676efa2abc93c..71572d7ec8422 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -71,3 +71,9 @@ SECOND(CAST('\t' AS VARCHAR)) AS varchar_tab_second, SECOND(CAST(' ' AS TEXT)) AS text_spaces_second; ➤ char_spaces_second[-6,8,0] ¦ varchar_tab_second[-6,8,0] ¦ text_spaces_second[-6,8,0] 𝄀 null ¦ null ¦ null +SELECT SECOND('241220153045') AS compact_datetime_second, +SECOND('15:30:45abc') AS trailing_time_second, +SECOND('2024-12-20 15:30:45abc') AS trailing_datetime_second, +SECOND('2024-12-20foo') AS malformed_datetime_second; +➤ compact_datetime_second[-6,8,0] ¦ trailing_time_second[-6,8,0] ¦ trailing_datetime_second[-6,8,0] ¦ malformed_datetime_second[-6,8,0] 𝄀 +45 ¦ 45 ¦ 45 ¦ null diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 31ca5af88bf59..79f5b93055413 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -54,3 +54,9 @@ SELECT SECOND('2024-12-20') AS date_string_second, SELECT SECOND(CAST(' ' AS CHAR)) AS char_spaces_second, SECOND(CAST('\t' AS VARCHAR)) AS varchar_tab_second, SECOND(CAST(' ' AS TEXT)) AS text_spaces_second; + +-- MySQL str_to_time recognizes YYMMDDHHMMSS and keeps a valid TIME prefix. +SELECT SECOND('241220153045') AS compact_datetime_second, + SECOND('15:30:45abc') AS trailing_time_second, + SECOND('2024-12-20 15:30:45abc') AS trailing_datetime_second, + SECOND('2024-12-20foo') AS malformed_datetime_second; From ce45cb7f568f01fbd5619a65420f2491759e33fa Mon Sep 17 00:00:00 2001 From: daviszhen Date: Tue, 28 Jul 2026 17:29:10 +0800 Subject: [PATCH 05/25] update --- pkg/sql/plan/function/func_unary.go | 266 ++++++++++++++---- pkg/sql/plan/function/func_unary_test.go | 37 ++- .../cases/function/func_datetime_hour.result | 19 +- .../cases/function/func_datetime_hour.test | 17 ++ .../function/func_datetime_minute.result | 17 ++ .../cases/function/func_datetime_minute.test | 17 ++ .../function/func_datetime_second.result | 17 ++ .../cases/function/func_datetime_second.test | 17 ++ 8 files changed, 345 insertions(+), 62 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index b841c8f06f4f1..2984bb6d7ab7a 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4679,74 +4679,242 @@ func timeStringToFixedWithNullOnError[T types.FixedSizeTExceptStrType]( return nil } -// timeStringToClockForExtract follows MySQL Item::get_time_from_string: -// strings long enough to be DATETIME values are parsed as DATETIME first; -// shorter strings use TIME coercion. In particular, a date-only string such as -// "2024-12-20" is parsed as the compact TIME prefix "2024", not as midnight -// on that date. +// timeStringToClockForExtract follows MySQL's string-to-TIME coercion for +// HOUR, MINUTE, and SECOND. It deliberately parses only the bounded prefix +// needed for the clock fields: the general temporal parsers accept different +// grammars and must not receive arbitrary TIME-shaped user input here. func timeStringToClockForExtract(str string) (uint64, uint8, uint8, bool) { - datetimeString := str - if datetimeString[0] == '-' { - datetimeString = datetimeString[1:] + if hour, minute, second, ok := mysqlSeparatedDatetimeClockForExtract(str); ok { + return hour, minute, second, true } - if len(datetimeString) >= 12 { - return mysqlDatetimeStringToClockForExtract(datetimeString) + if hour, minute, second, ok := parseCompactDatetimeClockForExtract(str); ok { + return hour, minute, second, true } + return mysqlTimeStringToClockForExtract(str) +} - timeVal, err := types.ParseTime(str, 6) - if err != nil { - if prefix := mysqlTimePrefixForExtract(str); len(prefix) > 0 && len(prefix) != len(str) { - timeVal, err = types.ParseTime(prefix, 6) +func mysqlTimeStringToClockForExtract(str string) (uint64, uint8, uint8, bool) { + // Retain a valid TIME prefix before trailing text, for example + // "15:30:45abc". Invalid clocks such as "12:60:00" still fail because + // their complete prefix cannot be parsed as TIME. + if hour, minute, second, ok := mysqlTimePrefixClockForExtract(str); ok { + return hour, minute, second, true + } + + // MySQL coerces a complete date-only string through its leading year field: + // "2024-12-20" becomes the compact TIME 00:20:24. Do not apply this to + // malformed date-looking input, or invalid clocks would acquire a value. + if mysqlDateOnlyStringForExtract(str) { + if hour, minute, second, ok := mysqlTimePrefixClockForExtract(str[:4]); ok { + return hour, minute, second, true } - if err != nil { - // MySQL's str_to_time consumes a leading numeric field before reporting - // trailing non-time characters. This covers date-only strings, where - // 2024-12-20 becomes the compact TIME 00:20:24. - start := 0 - if str[0] == '-' { - start = 1 - } - end := start - for end < len(str) && str[end] >= '0' && str[end] <= '9' { - end++ - } - if end == start { + } + return 0, 0, 0, false +} + +func mysqlDateOnlyStringForExtract(str string) bool { + return len(str) == 10 && asciiDigits(str[:4]) && asciiDigits(str[5:7]) && + asciiDigits(str[8:10]) && (str[4] == '-' || str[4] == '/') && str[7] == str[4] +} + +func asciiDigits(str string) bool { + for i := 0; i < len(str); i++ { + if str[i] < '0' || str[i] > '9' { + return false + } + } + return true +} + +func mysqlSeparatedDatetimeClockForExtract(str string) (uint64, uint8, uint8, bool) { + pos := 0 + year, ok := mysqlFixedDigitsForExtract(str, &pos, 4) + if !ok || pos >= len(str) || (str[pos] != '-' && str[pos] != '/') { + return 0, 0, 0, false + } + separator := str[pos] + pos++ + month, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) + if !ok || pos >= len(str) || str[pos] != separator { + return 0, 0, 0, false + } + pos++ + day, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) + if !ok || pos >= len(str) || (str[pos] != ' ' && str[pos] != 'T') { + return 0, 0, 0, false + } + pos++ + hour, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) + if !ok || pos >= len(str) || str[pos] != ':' { + return 0, 0, 0, false + } + pos++ + minute, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) + if !ok || pos >= len(str) || str[pos] != ':' { + return 0, 0, 0, false + } + pos++ + second, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) + if !ok || !mysqlDatetimeDateForExtract(year, month, day) || + !types.ValidTimeInDay(uint8(hour), uint8(minute), uint8(second)) { + return 0, 0, 0, false + } + return hour, uint8(minute), uint8(second), true +} + +func mysqlDatetimeDateForExtract(year, month, day uint64) bool { + if year == 0 && month == 0 && day == 0 { + return true + } + return types.ValidDate(int32(year), uint8(month), uint8(day)) +} + +func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { + prefix := mysqlTimePrefixForExtract(str) + if len(prefix) == 0 { + return 0, 0, 0, false + } + + if prefix[0] == '-' { + prefix = prefix[1:] + } + if len(prefix) == 0 { + return 0, 0, 0, false + } + + if dot := strings.IndexByte(prefix, '.'); dot >= 0 { + if dot == len(prefix)-1 || !asciiDigits(prefix[dot+1:]) { + return 0, 0, 0, false + } + prefix = prefix[:dot] + } + + day := uint64(0) + hasDay := false + if space := strings.IndexByte(prefix, ' '); space >= 0 { + if !asciiDigits(prefix[:space]) { + return 0, 0, 0, false + } + day = mysqlClampedDigitsForExtract(prefix[:space], 35) + hasDay = true + prefix = strings.TrimLeft(prefix[space:], " ") + if len(prefix) == 0 || strings.IndexByte(prefix, ' ') >= 0 { + return 0, 0, 0, false + } + } + + hour, minute, second, ok := mysqlClockFieldsForExtract(prefix) + if !ok { + return 0, 0, 0, false + } + if hasDay { + if hour > 23 { + return 0, 0, 0, false + } + if day >= 35 || hour > 838-day*24 { + hour = 839 + } else { + hour += day * 24 + } + } + if hour > 838 { + return 838, 59, 59, true + } + return hour, minute, second, true +} + +func mysqlClockFieldsForExtract(str string) (uint64, uint8, uint8, bool) { + if len(str) == 0 { + return 0, 0, 0, false + } + firstColon := strings.IndexByte(str, ':') + if firstColon < 0 { + if !asciiDigits(str) { + return 0, 0, 0, false + } + switch len(str) { + case 1, 2: + return 0, 0, uint8(mysqlClampedDigitsForExtract(str, 59)), true + case 3, 4: + minute := mysqlClampedDigitsForExtract(str[:len(str)-2], 60) + second := mysqlClampedDigitsForExtract(str[len(str)-2:], 60) + if minute >= 60 || second >= 60 { return 0, 0, 0, false } - timeVal, err = types.ParseTime(str[start:end], 6) - if err != nil { + return 0, uint8(minute), uint8(second), true + default: + hour := mysqlClampedDigitsForExtract(str[:len(str)-4], 839) + minute := mysqlClampedDigitsForExtract(str[len(str)-4:len(str)-2], 60) + second := mysqlClampedDigitsForExtract(str[len(str)-2:], 60) + if minute >= 60 || second >= 60 { return 0, 0, 0, false } + return hour, uint8(minute), uint8(second), true } } - hour, minute, second, _, _ := timeVal.ClockFormat() - return hour, minute, second, true + secondColon := firstColon + 1 + strings.IndexByte(str[firstColon+1:], ':') + if secondColon <= firstColon { + hourText, minuteText := str[:firstColon], str[firstColon+1:] + if len(hourText) == 0 || len(minuteText) == 0 || !asciiDigits(hourText) || !asciiDigits(minuteText) { + return 0, 0, 0, false + } + hour := mysqlClampedDigitsForExtract(hourText, 839) + minute := mysqlClampedDigitsForExtract(minuteText, 60) + if minute >= 60 { + return 0, 0, 0, false + } + return hour, uint8(minute), 0, true + } + if strings.IndexByte(str[secondColon+1:], ':') >= 0 { + return 0, 0, 0, false + } + hourText, minuteText, secondText := str[:firstColon], str[firstColon+1:secondColon], str[secondColon+1:] + if len(hourText) == 0 || len(minuteText) == 0 || len(secondText) == 0 || + !asciiDigits(hourText) || !asciiDigits(minuteText) || !asciiDigits(secondText) { + return 0, 0, 0, false + } + hour := mysqlClampedDigitsForExtract(hourText, 839) + minute := mysqlClampedDigitsForExtract(minuteText, 60) + second := mysqlClampedDigitsForExtract(secondText, 60) + if minute >= 60 || second >= 60 { + return 0, 0, 0, false + } + return hour, uint8(minute), uint8(second), true } -func mysqlDatetimeStringToClockForExtract(str string) (uint64, uint8, uint8, bool) { - if dt, err := types.ParseDatetime(str, 6); err == nil { - hour, minute, second := dt.Clock() - return uint64(hour), uint8(minute), uint8(second), true +func mysqlClampedDigitsForExtract(str string, limit uint64) uint64 { + if len(str) == 0 { + return limit } + value := uint64(0) + for i := 0; i < len(str); i++ { + if str[i] < '0' || str[i] > '9' || value > (limit-uint64(str[i]-'0'))/10 { + return limit + } + value = value*10 + uint64(str[i]-'0') + } + return value +} - // MySQL accepts both YYYYMMDDHHMMSS and YYMMDDHHMMSS. MatrixOne's - // ParseDatetime supports only the former, so retain the two-digit-year - // form locally for these TIME extract functions. - if hour, minute, second, ok := parseCompactDatetimeClockForExtract(str); ok { - return hour, minute, second, true +func mysqlFixedDigitsForExtract(str string, pos *int, width int) (uint64, bool) { + if len(str)-*pos < width || !asciiDigits(str[*pos:*pos+width]) { + return 0, false } + value := mysqlClampedDigitsForExtract(str[*pos:*pos+width], math.MaxUint64) + *pos += width + return value, true +} - // str_to_time keeps a complete DATETIME prefix and reports trailing text as - // a warning. The function result has no warning channel, but must retain the - // parsed clock fields. - if len(str) >= 19 && (str[4] == '-' || str[4] == '/') && (str[10] == ' ' || str[10] == 'T') { - if dt, err := types.ParseDatetime(str[:19], 6); err == nil { - hour, minute, second := dt.Clock() - return uint64(hour), uint8(minute), uint8(second), true - } +func mysqlOneOrTwoDigitsForExtract(str string, pos *int) (uint64, bool) { + start := *pos + for *pos < len(str) && *pos-start < 2 && str[*pos] >= '0' && str[*pos] <= '9' { + *pos = *pos + 1 } - return 0, 0, 0, false + if *pos == start { + return 0, false + } + return mysqlClampedDigitsForExtract(str[start:*pos], math.MaxUint64), true } func parseCompactDatetimeClockForExtract(str string) (uint64, uint8, uint8, bool) { diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 2f67d0e5c3cf8..3d26162f9f8e4 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4167,8 +4167,12 @@ func TestStringTimeExtract(t *testing.T) { "12:30:45", "272:59:59", "-272:59:59", "2 03:04:05", "123045", "2024-12-20 15:30:45", "20241220153045", "241220153045", "2024-12-20", "15:30:45abc", "2024-12-20 15:30:45abc", "2024-12-20foo", "invalid", "", " ", "\t", + "2 03:04:05.123", "12:30:45.123456", "272:59:59.123456", "-272:59:59.123456", + "-2 03:04:05.123", "839:00:00", "-839:00:00", "20241220", "12:60:00", "12:30:60", + "2024-12-20T15:30:45.123456", " 12:34:56 ", }, - []bool{false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false}), + []bool{false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, false, false, false, false, false, false}), } testCases := []struct { @@ -4179,22 +4183,28 @@ func TestStringTimeExtract(t *testing.T) { { name: "hour", expect: NewFunctionTestResult(types.T_uint32.ToType(), false, - []uint32{12, 272, 272, 51, 12, 15, 15, 15, 0, 15, 15, 0, 0, 0, 0, 0}, - []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true}), + []uint32{12, 272, 272, 51, 12, 15, 15, 15, 0, 15, 15, 0, 0, 0, 0, 0, + 51, 12, 272, 272, 51, 838, 838, 838, 0, 0, 15, 12}, + []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true, + false, false, false, false, false, false, false, false, true, true, false, false}), fn: StringToHour, }, { name: "minute", expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{30, 59, 59, 4, 30, 30, 30, 30, 20, 30, 30, 0, 0, 0, 0, 0}, - []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true}), + []uint8{30, 59, 59, 4, 30, 30, 30, 30, 20, 30, 30, 0, 0, 0, 0, 0, + 4, 30, 59, 59, 4, 59, 59, 59, 0, 0, 30, 34}, + []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true, + false, false, false, false, false, false, false, false, true, true, false, false}), fn: StringToMinute, }, { name: "second", expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{45, 59, 59, 5, 45, 45, 45, 45, 24, 45, 45, 0, 0, 0, 0, 0}, - []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true}), + []uint8{45, 59, 59, 5, 45, 45, 45, 45, 24, 45, 45, 0, 0, 0, 0, 0, + 5, 45, 59, 59, 5, 59, 59, 59, 0, 0, 45, 56}, + []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true, + false, false, false, false, false, false, false, false, true, true, false, false}), fn: StringToSecond, }, } @@ -4277,8 +4287,11 @@ func TestStringTimeExtractTomorrowDatetime(t *testing.T) { func TestStringTimeExtractRegisteredOverloads(t *testing.T) { proc := testutil.NewProcess(t) - inputValues := []string{"12:30:45", "272:59:59", "2024-12-20 15:30:45", "2024-12-20", "invalid", ""} - wantNulls := []bool{false, false, false, false, true, true} + inputValues := []string{ + "12:30:45", "272:59:59", "2024-12-20 15:30:45", "2024-12-20", "invalid", "", + "2 03:04:05.123", "12:30:45.123456", "839:00:00", "20241220", "12:60:00", "12:30:60", + } + wantNulls := []bool{false, false, false, false, true, true, false, false, false, false, true, true} typeCases := []types.T{types.T_varchar, types.T_char, types.T_text} functionCases := []struct { @@ -4287,9 +4300,9 @@ func TestStringTimeExtractRegisteredOverloads(t *testing.T) { hours []uint32 parts []uint8 }{ - {name: "hour", returnType: types.T_uint32, hours: []uint32{12, 272, 15, 0, 0, 0}}, - {name: "minute", returnType: types.T_uint8, parts: []uint8{30, 59, 30, 20, 0, 0}}, - {name: "second", returnType: types.T_uint8, parts: []uint8{45, 59, 45, 24, 0, 0}}, + {name: "hour", returnType: types.T_uint32, hours: []uint32{12, 272, 15, 0, 0, 0, 51, 12, 838, 838, 0, 0}}, + {name: "minute", returnType: types.T_uint8, parts: []uint8{30, 59, 30, 20, 0, 0, 4, 30, 59, 59, 0, 0}}, + {name: "second", returnType: types.T_uint8, parts: []uint8{45, 59, 45, 24, 0, 0, 5, 45, 59, 59, 0, 0}}, } for _, inputType := range typeCases { diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index 1278adb80444f..94aad6cabc5f6 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -6,7 +6,7 @@ SELECT HOUR('2024-12-20 15:30:45') AS result2; 15 SELECT HOUR(NOW()) AS result3; ➤ result3[-6,8,0] 𝄀 -12 +17 SELECT HOUR(CAST('15:30:45' AS TIME)) AS time_cast; ➤ time_cast[4,32,0] 𝄀 15 @@ -88,3 +88,20 @@ HOUR('2024-12-20 15:30:45abc') AS trailing_datetime_hour, HOUR('2024-12-20foo') AS malformed_datetime_hour; ➤ compact_datetime_hour[4,32,0] ¦ trailing_time_hour[4,32,0] ¦ trailing_datetime_hour[4,32,0] ¦ malformed_datetime_hour[4,32,0] 𝄀 15 ¦ 15 ¦ 15 ¦ null +SELECT HOUR('12:34:56.789012') AS fractional_time_hour, +HOUR('2 03:04:05.9') AS day_fractional_time_hour, +HOUR('-12:34:56.789012') AS negative_fractional_time_hour, +HOUR('-2 03:04:05.9') AS negative_day_fractional_time_hour, +HOUR('839:00:00') AS overflow_time_hour, +HOUR('-839:00:00') AS negative_overflow_time_hour, +HOUR('12:60:00') AS invalid_minute_hour, +HOUR('12:34:60') AS invalid_second_hour, +HOUR('123045.987654') AS compact_time_hour, +HOUR('20241220153045.999999') AS compact_datetime_fractional_hour, +HOUR('20241220156045') AS invalid_compact_datetime_hour, +HOUR('2024-12-20') AS date_only_time_hour, +HOUR('2024-12-20T15:30:45.123456') AS t_datetime_hour, +HOUR(' 12:34:56 ') AS trimmed_time_hour, +HOUR('foo12:34:56') AS malformed_time_hour; +➤ fractional_time_hour[4,32,0] ¦ day_fractional_time_hour[4,32,0] ¦ negative_fractional_time_hour[4,32,0] ¦ negative_day_fractional_time_hour[4,32,0] ¦ overflow_time_hour[4,32,0] ¦ negative_overflow_time_hour[4,32,0] ¦ invalid_minute_hour[4,32,0] ¦ invalid_second_hour[4,32,0] ¦ compact_time_hour[4,32,0] ¦ compact_datetime_fractional_hour[4,32,0] ¦ invalid_compact_datetime_hour[4,32,0] ¦ date_only_time_hour[4,32,0] ¦ t_datetime_hour[4,32,0] ¦ trimmed_time_hour[4,32,0] ¦ malformed_time_hour[4,32,0] 𝄀 +12 ¦ 51 ¦ 12 ¦ 51 ¦ 838 ¦ 838 ¦ null ¦ null ¦ 12 ¦ 15 ¦ null ¦ 0 ¦ 15 ¦ 12 ¦ null diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index 7744ce47bf6a3..15b81954711df 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -64,3 +64,20 @@ SELECT HOUR('241220153045') AS compact_datetime_hour, HOUR('15:30:45abc') AS trailing_time_hour, HOUR('2024-12-20 15:30:45abc') AS trailing_datetime_hour, HOUR('2024-12-20foo') AS malformed_datetime_hour; + +-- String TIME coercion: fractional/day/compact forms, range clamp, and invalid fields. +SELECT HOUR('12:34:56.789012') AS fractional_time_hour, + HOUR('2 03:04:05.9') AS day_fractional_time_hour, + HOUR('-12:34:56.789012') AS negative_fractional_time_hour, + HOUR('-2 03:04:05.9') AS negative_day_fractional_time_hour, + HOUR('839:00:00') AS overflow_time_hour, + HOUR('-839:00:00') AS negative_overflow_time_hour, + HOUR('12:60:00') AS invalid_minute_hour, + HOUR('12:34:60') AS invalid_second_hour, + HOUR('123045.987654') AS compact_time_hour, + HOUR('20241220153045.999999') AS compact_datetime_fractional_hour, + HOUR('20241220156045') AS invalid_compact_datetime_hour, + HOUR('2024-12-20') AS date_only_time_hour, + HOUR('2024-12-20T15:30:45.123456') AS t_datetime_hour, + HOUR(' 12:34:56 ') AS trimmed_time_hour, + HOUR('foo12:34:56') AS malformed_time_hour; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 91bbf5e8d74f2..296278cb0e46b 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -76,3 +76,20 @@ MINUTE('2024-12-20 15:30:45abc') AS trailing_datetime_minute, MINUTE('2024-12-20foo') AS malformed_datetime_minute; ➤ compact_datetime_minute[-6,8,0] ¦ trailing_time_minute[-6,8,0] ¦ trailing_datetime_minute[-6,8,0] ¦ malformed_datetime_minute[-6,8,0] 𝄀 30 ¦ 30 ¦ 30 ¦ null +SELECT MINUTE('12:34:56.789012') AS fractional_time_minute, +MINUTE('2 03:04:05.9') AS day_fractional_time_minute, +MINUTE('-12:34:56.789012') AS negative_fractional_time_minute, +MINUTE('-2 03:04:05.9') AS negative_day_fractional_time_minute, +MINUTE('839:00:00') AS overflow_time_minute, +MINUTE('-839:00:00') AS negative_overflow_time_minute, +MINUTE('12:60:00') AS invalid_minute_minute, +MINUTE('12:34:60') AS invalid_second_minute, +MINUTE('123045.987654') AS compact_time_minute, +MINUTE('20241220153045.999999') AS compact_datetime_fractional_minute, +MINUTE('20241220156045') AS invalid_compact_datetime_minute, +MINUTE('2024-12-20') AS date_only_time_minute, +MINUTE('2024-12-20T15:30:45.123456') AS t_datetime_minute, +MINUTE(' 12:34:56 ') AS trimmed_time_minute, +MINUTE('foo12:34:56') AS malformed_time_minute; +➤ fractional_time_minute[-6,8,0] ¦ day_fractional_time_minute[-6,8,0] ¦ negative_fractional_time_minute[-6,8,0] ¦ negative_day_fractional_time_minute[-6,8,0] ¦ overflow_time_minute[-6,8,0] ¦ negative_overflow_time_minute[-6,8,0] ¦ invalid_minute_minute[-6,8,0] ¦ invalid_second_minute[-6,8,0] ¦ compact_time_minute[-6,8,0] ¦ compact_datetime_fractional_minute[-6,8,0] ¦ invalid_compact_datetime_minute[-6,8,0] ¦ date_only_time_minute[-6,8,0] ¦ t_datetime_minute[-6,8,0] ¦ trimmed_time_minute[-6,8,0] ¦ malformed_time_minute[-6,8,0] 𝄀 +34 ¦ 4 ¦ 34 ¦ 4 ¦ 59 ¦ 59 ¦ null ¦ null ¦ 30 ¦ 30 ¦ null ¦ 20 ¦ 30 ¦ 34 ¦ null diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index ca9045c8cca4e..cb83bdcf6459d 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -60,3 +60,20 @@ SELECT MINUTE('241220153045') AS compact_datetime_minute, MINUTE('15:30:45abc') AS trailing_time_minute, MINUTE('2024-12-20 15:30:45abc') AS trailing_datetime_minute, MINUTE('2024-12-20foo') AS malformed_datetime_minute; + +-- String TIME coercion: fractional/day/compact forms, range clamp, and invalid fields. +SELECT MINUTE('12:34:56.789012') AS fractional_time_minute, + MINUTE('2 03:04:05.9') AS day_fractional_time_minute, + MINUTE('-12:34:56.789012') AS negative_fractional_time_minute, + MINUTE('-2 03:04:05.9') AS negative_day_fractional_time_minute, + MINUTE('839:00:00') AS overflow_time_minute, + MINUTE('-839:00:00') AS negative_overflow_time_minute, + MINUTE('12:60:00') AS invalid_minute_minute, + MINUTE('12:34:60') AS invalid_second_minute, + MINUTE('123045.987654') AS compact_time_minute, + MINUTE('20241220153045.999999') AS compact_datetime_fractional_minute, + MINUTE('20241220156045') AS invalid_compact_datetime_minute, + MINUTE('2024-12-20') AS date_only_time_minute, + MINUTE('2024-12-20T15:30:45.123456') AS t_datetime_minute, + MINUTE(' 12:34:56 ') AS trimmed_time_minute, + MINUTE('foo12:34:56') AS malformed_time_minute; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index 71572d7ec8422..43a3891d44d60 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -77,3 +77,20 @@ SECOND('2024-12-20 15:30:45abc') AS trailing_datetime_second, SECOND('2024-12-20foo') AS malformed_datetime_second; ➤ compact_datetime_second[-6,8,0] ¦ trailing_time_second[-6,8,0] ¦ trailing_datetime_second[-6,8,0] ¦ malformed_datetime_second[-6,8,0] 𝄀 45 ¦ 45 ¦ 45 ¦ null +SELECT SECOND('12:34:56.789012') AS fractional_time_second, +SECOND('2 03:04:05.9') AS day_fractional_time_second, +SECOND('-12:34:56.789012') AS negative_fractional_time_second, +SECOND('-2 03:04:05.9') AS negative_day_fractional_time_second, +SECOND('839:00:00') AS overflow_time_second, +SECOND('-839:00:00') AS negative_overflow_time_second, +SECOND('12:60:00') AS invalid_minute_second, +SECOND('12:34:60') AS invalid_second_second, +SECOND('123045.987654') AS compact_time_second, +SECOND('20241220153045.999999') AS compact_datetime_fractional_second, +SECOND('20241220156045') AS invalid_compact_datetime_second, +SECOND('2024-12-20') AS date_only_time_second, +SECOND('2024-12-20T15:30:45.123456') AS t_datetime_second, +SECOND(' 12:34:56 ') AS trimmed_time_second, +SECOND('foo12:34:56') AS malformed_time_second; +➤ fractional_time_second[-6,8,0] ¦ day_fractional_time_second[-6,8,0] ¦ negative_fractional_time_second[-6,8,0] ¦ negative_day_fractional_time_second[-6,8,0] ¦ overflow_time_second[-6,8,0] ¦ negative_overflow_time_second[-6,8,0] ¦ invalid_minute_second[-6,8,0] ¦ invalid_second_second[-6,8,0] ¦ compact_time_second[-6,8,0] ¦ compact_datetime_fractional_second[-6,8,0] ¦ invalid_compact_datetime_second[-6,8,0] ¦ date_only_time_second[-6,8,0] ¦ t_datetime_second[-6,8,0] ¦ trimmed_time_second[-6,8,0] ¦ malformed_time_second[-6,8,0] 𝄀 +56 ¦ 5 ¦ 56 ¦ 5 ¦ 59 ¦ 59 ¦ null ¦ null ¦ 45 ¦ 45 ¦ null ¦ 24 ¦ 45 ¦ 56 ¦ null diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 79f5b93055413..c25d80ae5e0f6 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -60,3 +60,20 @@ SELECT SECOND('241220153045') AS compact_datetime_second, SECOND('15:30:45abc') AS trailing_time_second, SECOND('2024-12-20 15:30:45abc') AS trailing_datetime_second, SECOND('2024-12-20foo') AS malformed_datetime_second; + +-- String TIME coercion: fractional/day/compact forms, range clamp, and invalid fields. +SELECT SECOND('12:34:56.789012') AS fractional_time_second, + SECOND('2 03:04:05.9') AS day_fractional_time_second, + SECOND('-12:34:56.789012') AS negative_fractional_time_second, + SECOND('-2 03:04:05.9') AS negative_day_fractional_time_second, + SECOND('839:00:00') AS overflow_time_second, + SECOND('-839:00:00') AS negative_overflow_time_second, + SECOND('12:60:00') AS invalid_minute_second, + SECOND('12:34:60') AS invalid_second_second, + SECOND('123045.987654') AS compact_time_second, + SECOND('20241220153045.999999') AS compact_datetime_fractional_second, + SECOND('20241220156045') AS invalid_compact_datetime_second, + SECOND('2024-12-20') AS date_only_time_second, + SECOND('2024-12-20T15:30:45.123456') AS t_datetime_second, + SECOND(' 12:34:56 ') AS trimmed_time_second, + SECOND('foo12:34:56') AS malformed_time_second; From 9760c4f7d652f951d66f2102b676552c0a7fc04d Mon Sep 17 00:00:00 2001 From: daviszhen Date: Tue, 28 Jul 2026 17:48:01 +0800 Subject: [PATCH 06/25] update --- pkg/sql/plan/function/func_unary.go | 64 +++++++++++++------ pkg/sql/plan/function/func_unary_test.go | 46 +++++++++++++ .../cases/function/func_datetime_hour.result | 7 ++ .../cases/function/func_datetime_hour.test | 7 ++ .../function/func_datetime_minute.result | 7 ++ .../cases/function/func_datetime_minute.test | 7 ++ .../function/func_datetime_second.result | 7 ++ .../cases/function/func_datetime_second.test | 7 ++ 8 files changed, 132 insertions(+), 20 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index 2984bb6d7ab7a..e2b3e6d768c6e 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4684,11 +4684,17 @@ func timeStringToFixedWithNullOnError[T types.FixedSizeTExceptStrType]( // needed for the clock fields: the general temporal parsers accept different // grammars and must not receive arbitrary TIME-shaped user input here. func timeStringToClockForExtract(str string) (uint64, uint8, uint8, bool) { - if hour, minute, second, ok := mysqlSeparatedDatetimeClockForExtract(str); ok { - return hour, minute, second, true + if str[0] == '-' { + str = str[1:] + if len(str) == 0 { + return 0, 0, 0, false + } } - if hour, minute, second, ok := parseCompactDatetimeClockForExtract(str); ok { - return hour, minute, second, true + if result := mysqlSeparatedDatetimeClockForExtract(str); result.matched { + return result.hour, result.minute, result.second, result.valid + } + if result := parseCompactDatetimeClockForExtract(str); result.matched { + return result.hour, result.minute, result.second, result.valid } return mysqlTimeStringToClockForExtract(str) } @@ -4726,40 +4732,55 @@ func asciiDigits(str string) bool { return true } -func mysqlSeparatedDatetimeClockForExtract(str string) (uint64, uint8, uint8, bool) { +type timeExtractParseResult struct { + hour uint64 + minute, second uint8 + matched, valid bool +} + +func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { + if len(str) < 6 || !asciiDigits(str[:4]) || (str[4] != '-' && str[4] != '/') || + (strings.IndexByte(str[5:], ' ') < 0 && strings.IndexByte(str[5:], 'T') < 0) { + return timeExtractParseResult{} + } + result := timeExtractParseResult{matched: true} pos := 0 year, ok := mysqlFixedDigitsForExtract(str, &pos, 4) if !ok || pos >= len(str) || (str[pos] != '-' && str[pos] != '/') { - return 0, 0, 0, false + return result } separator := str[pos] pos++ month, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) if !ok || pos >= len(str) || str[pos] != separator { - return 0, 0, 0, false + return result } pos++ day, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) if !ok || pos >= len(str) || (str[pos] != ' ' && str[pos] != 'T') { - return 0, 0, 0, false + return result } pos++ hour, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) if !ok || pos >= len(str) || str[pos] != ':' { - return 0, 0, 0, false + return result } pos++ minute, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) if !ok || pos >= len(str) || str[pos] != ':' { - return 0, 0, 0, false + return result } pos++ second, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) if !ok || !mysqlDatetimeDateForExtract(year, month, day) || !types.ValidTimeInDay(uint8(hour), uint8(minute), uint8(second)) { - return 0, 0, 0, false + return result } - return hour, uint8(minute), uint8(second), true + result.hour = hour + result.minute = uint8(minute) + result.second = uint8(second) + result.valid = true + return result } func mysqlDatetimeDateForExtract(year, month, day uint64) bool { @@ -4808,9 +4829,6 @@ func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { return 0, 0, 0, false } if hasDay { - if hour > 23 { - return 0, 0, 0, false - } if day >= 35 || hour > 838-day*24 { hour = 839 } else { @@ -4834,7 +4852,11 @@ func mysqlClockFieldsForExtract(str string) (uint64, uint8, uint8, bool) { } switch len(str) { case 1, 2: - return 0, 0, uint8(mysqlClampedDigitsForExtract(str, 59)), true + second := mysqlClampedDigitsForExtract(str, 60) + if second >= 60 { + return 0, 0, 0, false + } + return 0, 0, uint8(second), true case 3, 4: minute := mysqlClampedDigitsForExtract(str[:len(str)-2], 60) second := mysqlClampedDigitsForExtract(str[len(str)-2:], 60) @@ -4917,19 +4939,21 @@ func mysqlOneOrTwoDigitsForExtract(str string, pos *int) (uint64, bool) { return mysqlClampedDigitsForExtract(str[start:*pos], math.MaxUint64), true } -func parseCompactDatetimeClockForExtract(str string) (uint64, uint8, uint8, bool) { +func parseCompactDatetimeClockForExtract(str string) timeExtractParseResult { digitCount := 0 for digitCount < len(str) && str[digitCount] >= '0' && str[digitCount] <= '9' { digitCount++ } if digitCount >= 14 { - return compactDatetimeClockForExtract(str[:14], false) + hour, minute, second, ok := compactDatetimeClockForExtract(str[:14], false) + return timeExtractParseResult{hour: hour, minute: minute, second: second, matched: true, valid: ok} } if digitCount >= 12 { - return compactDatetimeClockForExtract(str[:12], true) + hour, minute, second, ok := compactDatetimeClockForExtract(str[:12], true) + return timeExtractParseResult{hour: hour, minute: minute, second: second, matched: true, valid: ok} } - return 0, 0, 0, false + return timeExtractParseResult{} } func compactDatetimeClockForExtract(str string, twoDigitYear bool) (uint64, uint8, uint8, bool) { diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 3d26162f9f8e4..66f438af2682a 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4333,6 +4333,52 @@ func TestStringTimeExtractRegisteredOverloads(t *testing.T) { } } +func TestStringTimeExtractMySQLBoundaryRegressions(t *testing.T) { + proc := testutil.NewProcess(t) + inputs := []FunctionTestInput{ + NewFunctionTestInput(types.T_varchar.ToType(), []string{ + "-2024-12-20 15:30:45", + "-20241220153045", + "20240230010203", + "2 30:00:00", + "60", + }, []bool{false, false, false, false, false}), + } + + testCases := []struct { + name string + expect FunctionTestResult + fn fEvalFn + }{ + { + name: "hour", + expect: NewFunctionTestResult(types.T_uint32.ToType(), false, + []uint32{15, 15, 0, 78, 0}, []bool{false, false, true, false, true}), + fn: StringToHour, + }, + { + name: "minute", + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, + []uint8{30, 30, 0, 0, 0}, []bool{false, false, true, false, true}), + fn: StringToMinute, + }, + { + name: "second", + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, + []uint8{45, 45, 0, 0, 0}, []bool{false, false, true, false, true}), + fn: StringToSecond, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tcc := NewFunctionTestCase(proc, inputs, tc.expect, tc.fn) + succeed, info := tcc.Run() + require.True(t, succeed, info) + }) + } +} + func TestStringTimeExtractSelectList(t *testing.T) { proc := testutil.NewProcess(t) fcTC := NewFunctionTestCase(proc, diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index 94aad6cabc5f6..ce7b505a8b70a 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -105,3 +105,10 @@ HOUR(' 12:34:56 ') AS trimmed_time_hour, HOUR('foo12:34:56') AS malformed_time_hour; ➤ fractional_time_hour[4,32,0] ¦ day_fractional_time_hour[4,32,0] ¦ negative_fractional_time_hour[4,32,0] ¦ negative_day_fractional_time_hour[4,32,0] ¦ overflow_time_hour[4,32,0] ¦ negative_overflow_time_hour[4,32,0] ¦ invalid_minute_hour[4,32,0] ¦ invalid_second_hour[4,32,0] ¦ compact_time_hour[4,32,0] ¦ compact_datetime_fractional_hour[4,32,0] ¦ invalid_compact_datetime_hour[4,32,0] ¦ date_only_time_hour[4,32,0] ¦ t_datetime_hour[4,32,0] ¦ trimmed_time_hour[4,32,0] ¦ malformed_time_hour[4,32,0] 𝄀 12 ¦ 51 ¦ 12 ¦ 51 ¦ 838 ¦ 838 ¦ null ¦ null ¦ 12 ¦ 15 ¦ null ¦ 0 ¦ 15 ¦ 12 ¦ null +SELECT HOUR('-2024-12-20 15:30:45') AS negative_datetime_hour, +HOUR('-20241220153045') AS negative_compact_datetime_hour, +HOUR('20240230010203') AS invalid_calendar_compact_datetime_hour, +HOUR('2 30:00:00') AS extended_day_time_hour, +HOUR('60') AS invalid_compact_second_hour; +➤ negative_datetime_hour[4,32,0] ¦ negative_compact_datetime_hour[4,32,0] ¦ invalid_calendar_compact_datetime_hour[4,32,0] ¦ extended_day_time_hour[4,32,0] ¦ invalid_compact_second_hour[4,32,0] 𝄀 +15 ¦ 15 ¦ null ¦ 78 ¦ null diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index 15b81954711df..6c32365f68086 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -81,3 +81,10 @@ SELECT HOUR('12:34:56.789012') AS fractional_time_hour, HOUR('2024-12-20T15:30:45.123456') AS t_datetime_hour, HOUR(' 12:34:56 ') AS trimmed_time_hour, HOUR('foo12:34:56') AS malformed_time_hour; + +-- Signed DATETIME, invalid compact DATETIME, extended day-TIME, and invalid compact seconds. +SELECT HOUR('-2024-12-20 15:30:45') AS negative_datetime_hour, + HOUR('-20241220153045') AS negative_compact_datetime_hour, + HOUR('20240230010203') AS invalid_calendar_compact_datetime_hour, + HOUR('2 30:00:00') AS extended_day_time_hour, + HOUR('60') AS invalid_compact_second_hour; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 296278cb0e46b..91ab5a7bc0c2f 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -93,3 +93,10 @@ MINUTE(' 12:34:56 ') AS trimmed_time_minute, MINUTE('foo12:34:56') AS malformed_time_minute; ➤ fractional_time_minute[-6,8,0] ¦ day_fractional_time_minute[-6,8,0] ¦ negative_fractional_time_minute[-6,8,0] ¦ negative_day_fractional_time_minute[-6,8,0] ¦ overflow_time_minute[-6,8,0] ¦ negative_overflow_time_minute[-6,8,0] ¦ invalid_minute_minute[-6,8,0] ¦ invalid_second_minute[-6,8,0] ¦ compact_time_minute[-6,8,0] ¦ compact_datetime_fractional_minute[-6,8,0] ¦ invalid_compact_datetime_minute[-6,8,0] ¦ date_only_time_minute[-6,8,0] ¦ t_datetime_minute[-6,8,0] ¦ trimmed_time_minute[-6,8,0] ¦ malformed_time_minute[-6,8,0] 𝄀 34 ¦ 4 ¦ 34 ¦ 4 ¦ 59 ¦ 59 ¦ null ¦ null ¦ 30 ¦ 30 ¦ null ¦ 20 ¦ 30 ¦ 34 ¦ null +SELECT MINUTE('-2024-12-20 15:30:45') AS negative_datetime_minute, +MINUTE('-20241220153045') AS negative_compact_datetime_minute, +MINUTE('20240230010203') AS invalid_calendar_compact_datetime_minute, +MINUTE('2 30:00:00') AS extended_day_time_minute, +MINUTE('60') AS invalid_compact_second_minute; +➤ negative_datetime_minute[-6,8,0] ¦ negative_compact_datetime_minute[-6,8,0] ¦ invalid_calendar_compact_datetime_minute[-6,8,0] ¦ extended_day_time_minute[-6,8,0] ¦ invalid_compact_second_minute[-6,8,0] 𝄀 +30 ¦ 30 ¦ null ¦ 0 ¦ null diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index cb83bdcf6459d..60b7c562bad19 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -77,3 +77,10 @@ SELECT MINUTE('12:34:56.789012') AS fractional_time_minute, MINUTE('2024-12-20T15:30:45.123456') AS t_datetime_minute, MINUTE(' 12:34:56 ') AS trimmed_time_minute, MINUTE('foo12:34:56') AS malformed_time_minute; + +-- Signed DATETIME, invalid compact DATETIME, extended day-TIME, and invalid compact seconds. +SELECT MINUTE('-2024-12-20 15:30:45') AS negative_datetime_minute, + MINUTE('-20241220153045') AS negative_compact_datetime_minute, + MINUTE('20240230010203') AS invalid_calendar_compact_datetime_minute, + MINUTE('2 30:00:00') AS extended_day_time_minute, + MINUTE('60') AS invalid_compact_second_minute; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index 43a3891d44d60..21fabd0f4c108 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -94,3 +94,10 @@ SECOND(' 12:34:56 ') AS trimmed_time_second, SECOND('foo12:34:56') AS malformed_time_second; ➤ fractional_time_second[-6,8,0] ¦ day_fractional_time_second[-6,8,0] ¦ negative_fractional_time_second[-6,8,0] ¦ negative_day_fractional_time_second[-6,8,0] ¦ overflow_time_second[-6,8,0] ¦ negative_overflow_time_second[-6,8,0] ¦ invalid_minute_second[-6,8,0] ¦ invalid_second_second[-6,8,0] ¦ compact_time_second[-6,8,0] ¦ compact_datetime_fractional_second[-6,8,0] ¦ invalid_compact_datetime_second[-6,8,0] ¦ date_only_time_second[-6,8,0] ¦ t_datetime_second[-6,8,0] ¦ trimmed_time_second[-6,8,0] ¦ malformed_time_second[-6,8,0] 𝄀 56 ¦ 5 ¦ 56 ¦ 5 ¦ 59 ¦ 59 ¦ null ¦ null ¦ 45 ¦ 45 ¦ null ¦ 24 ¦ 45 ¦ 56 ¦ null +SELECT SECOND('-2024-12-20 15:30:45') AS negative_datetime_second, +SECOND('-20241220153045') AS negative_compact_datetime_second, +SECOND('20240230010203') AS invalid_calendar_compact_datetime_second, +SECOND('2 30:00:00') AS extended_day_time_second, +SECOND('60') AS invalid_compact_second_second; +➤ negative_datetime_second[-6,8,0] ¦ negative_compact_datetime_second[-6,8,0] ¦ invalid_calendar_compact_datetime_second[-6,8,0] ¦ extended_day_time_second[-6,8,0] ¦ invalid_compact_second_second[-6,8,0] 𝄀 +45 ¦ 45 ¦ null ¦ 0 ¦ null diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index c25d80ae5e0f6..bd63691bccb08 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -77,3 +77,10 @@ SELECT SECOND('12:34:56.789012') AS fractional_time_second, SECOND('2024-12-20T15:30:45.123456') AS t_datetime_second, SECOND(' 12:34:56 ') AS trimmed_time_second, SECOND('foo12:34:56') AS malformed_time_second; + +-- Signed DATETIME, invalid compact DATETIME, extended day-TIME, and invalid compact seconds. +SELECT SECOND('-2024-12-20 15:30:45') AS negative_datetime_second, + SECOND('-20241220153045') AS negative_compact_datetime_second, + SECOND('20240230010203') AS invalid_calendar_compact_datetime_second, + SECOND('2 30:00:00') AS extended_day_time_second, + SECOND('60') AS invalid_compact_second_second; From b79e709d6b34d255d84396db6fc4520455488e45 Mon Sep 17 00:00:00 2001 From: daviszhen Date: Tue, 28 Jul 2026 19:03:40 +0800 Subject: [PATCH 07/25] update --- pkg/sql/plan/function/func_unary.go | 102 ++++++++++++------ pkg/sql/plan/function/func_unary_test.go | 19 +++- .../cases/function/func_datetime_hour.result | 12 ++- .../cases/function/func_datetime_hour.test | 10 ++ .../function/func_datetime_minute.result | 10 ++ .../cases/function/func_datetime_minute.test | 10 ++ .../function/func_datetime_second.result | 10 ++ .../cases/function/func_datetime_second.test | 10 ++ 8 files changed, 143 insertions(+), 40 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index e2b3e6d768c6e..b4de792b5ed5d 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4700,6 +4700,10 @@ func timeStringToClockForExtract(str string) (uint64, uint8, uint8, bool) { } func mysqlTimeStringToClockForExtract(str string) (uint64, uint8, uint8, bool) { + if mysqlLeadingDigitsExceedUint32(str) { + return 0, 0, 0, false + } + // Retain a valid TIME prefix before trailing text, for example // "15:30:45abc". Invalid clocks such as "12:60:00" still fail because // their complete prefix cannot be parsed as TIME. @@ -4723,6 +4727,18 @@ func mysqlDateOnlyStringForExtract(str string) bool { asciiDigits(str[8:10]) && (str[4] == '-' || str[4] == '/') && str[7] == str[4] } +func mysqlLeadingDigitsExceedUint32(str string) bool { + value := uint64(0) + for i := 0; i < len(str) && str[i] >= '0' && str[i] <= '9'; i++ { + digit := uint64(str[i] - '0') + if value > (math.MaxUint32-digit)/10 { + return true + } + value = value*10 + digit + } + return false +} + func asciiDigits(str string) bool { for i := 0; i < len(str); i++ { if str[i] < '0' || str[i] > '9' { @@ -4739,41 +4755,52 @@ type timeExtractParseResult struct { } func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { - if len(str) < 6 || !asciiDigits(str[:4]) || (str[4] != '-' && str[4] != '/') || - (strings.IndexByte(str[5:], ' ') < 0 && strings.IndexByte(str[5:], 'T') < 0) { + pos := 0 + year, yearDigits, ok := mysqlVariableDigitsForExtract(str, &pos) + if !ok || yearDigits > 4 || pos >= len(str) || !mysqlDateSeparatorForExtract(str[pos]) { return timeExtractParseResult{} } - result := timeExtractParseResult{matched: true} - pos := 0 - year, ok := mysqlFixedDigitsForExtract(str, &pos, 4) - if !ok || pos >= len(str) || (str[pos] != '-' && str[pos] != '/') { - return result + if yearDigits == 2 { + year = uint64(adjustYear(int(year))) } separator := str[pos] pos++ - month, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) + month, _, ok := mysqlVariableDigitsForExtract(str, &pos) if !ok || pos >= len(str) || str[pos] != separator { - return result + return timeExtractParseResult{} } pos++ - day, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) + day, _, ok := mysqlVariableDigitsForExtract(str, &pos) if !ok || pos >= len(str) || (str[pos] != ' ' && str[pos] != 'T') { - return result + // A complete date without a clock is still handled by the TIME path. + return timeExtractParseResult{} } - pos++ - hour, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) + result := timeExtractParseResult{matched: true} + if str[pos] == 'T' { + pos++ + } else { + for pos < len(str) && str[pos] == ' ' { + pos++ + } + } + hour, _, ok := mysqlVariableDigitsForExtract(str, &pos) if !ok || pos >= len(str) || str[pos] != ':' { return result } pos++ - minute, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) + minute, _, ok := mysqlVariableDigitsForExtract(str, &pos) if !ok || pos >= len(str) || str[pos] != ':' { return result } pos++ - second, ok := mysqlOneOrTwoDigitsForExtract(str, &pos) - if !ok || !mysqlDatetimeDateForExtract(year, month, day) || - !types.ValidTimeInDay(uint8(hour), uint8(minute), uint8(second)) { + second := uint64(0) + if pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { + second, _, ok = mysqlVariableDigitsForExtract(str, &pos) + if !ok { + return result + } + } + if !mysqlDatetimeDateForExtract(year, month, day) || hour > 23 || minute > 59 || second > 59 { return result } result.hour = hour @@ -4784,12 +4811,22 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { } func mysqlDatetimeDateForExtract(year, month, day uint64) bool { - if year == 0 && month == 0 && day == 0 { - return true + if year > 9999 || month > 12 || day > 31 { + return false + } + if month == 0 || day == 0 { + return year != 0 || (month == 0 && day == 0) + } + if year == 0 { + return false } return types.ValidDate(int32(year), uint8(month), uint8(day)) } +func mysqlDateSeparatorForExtract(c byte) bool { + return c == '-' || c == '/' || c == ':' +} + func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { prefix := mysqlTimePrefixForExtract(str) if len(prefix) == 0 { @@ -4802,9 +4839,13 @@ func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { if len(prefix) == 0 { return 0, 0, 0, false } + prefix = strings.TrimLeft(prefix, " ") + if len(prefix) == 0 { + return 0, 0, 0, false + } if dot := strings.IndexByte(prefix, '.'); dot >= 0 { - if dot == len(prefix)-1 || !asciiDigits(prefix[dot+1:]) { + if dot < len(prefix)-1 && !asciiDigits(prefix[dot+1:]) { return 0, 0, 0, false } prefix = prefix[:dot] @@ -4813,7 +4854,7 @@ func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { day := uint64(0) hasDay := false if space := strings.IndexByte(prefix, ' '); space >= 0 { - if !asciiDigits(prefix[:space]) { + if space == 0 || !asciiDigits(prefix[:space]) { return 0, 0, 0, false } day = mysqlClampedDigitsForExtract(prefix[:space], 35) @@ -4919,24 +4960,15 @@ func mysqlClampedDigitsForExtract(str string, limit uint64) uint64 { return value } -func mysqlFixedDigitsForExtract(str string, pos *int, width int) (uint64, bool) { - if len(str)-*pos < width || !asciiDigits(str[*pos:*pos+width]) { - return 0, false - } - value := mysqlClampedDigitsForExtract(str[*pos:*pos+width], math.MaxUint64) - *pos += width - return value, true -} - -func mysqlOneOrTwoDigitsForExtract(str string, pos *int) (uint64, bool) { +func mysqlVariableDigitsForExtract(str string, pos *int) (uint64, int, bool) { start := *pos - for *pos < len(str) && *pos-start < 2 && str[*pos] >= '0' && str[*pos] <= '9' { + for *pos < len(str) && str[*pos] >= '0' && str[*pos] <= '9' { *pos = *pos + 1 } if *pos == start { - return 0, false + return 0, 0, false } - return mysqlClampedDigitsForExtract(str[start:*pos], math.MaxUint64), true + return mysqlClampedDigitsForExtract(str[start:*pos], math.MaxUint64), *pos - start, true } func parseCompactDatetimeClockForExtract(str string) timeExtractParseResult { @@ -4975,7 +5007,7 @@ func compactDatetimeClockForExtract(str string, twoDigitYear bool) (uint64, uint hour := (str[yearWidth+4]-'0')*10 + str[yearWidth+5] - '0' minute := (str[yearWidth+6]-'0')*10 + str[yearWidth+7] - '0' second := (str[yearWidth+8]-'0')*10 + str[yearWidth+9] - '0' - if !types.ValidDate(int32(year), month, day) || !types.ValidTimeInDay(hour, minute, second) { + if !mysqlDatetimeDateForExtract(uint64(year), uint64(month), uint64(day)) || !types.ValidTimeInDay(hour, minute, second) { return 0, 0, 0, false } return uint64(hour), minute, second, true diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 66f438af2682a..a98e6b764ef95 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4342,7 +4342,15 @@ func TestStringTimeExtractMySQLBoundaryRegressions(t *testing.T) { "20240230010203", "2 30:00:00", "60", - }, []bool{false, false, false, false, false}), + "24-12-20 15:30:45", + "2000:01:01 12:34:56", + "1998-01-01 00:00:009", + "2024-12-20 15:30:4560", + "2001-11-00 01:02:03", + "99999990000", + "- 12:34:56", + "12:34:56.", + }, []bool{false, false, false, false, false, false, false, false, false, false, false, false, false}), } testCases := []struct { @@ -4353,19 +4361,22 @@ func TestStringTimeExtractMySQLBoundaryRegressions(t *testing.T) { { name: "hour", expect: NewFunctionTestResult(types.T_uint32.ToType(), false, - []uint32{15, 15, 0, 78, 0}, []bool{false, false, true, false, true}), + []uint32{15, 15, 0, 78, 0, 15, 12, 0, 0, 1, 0, 12, 12}, + []bool{false, false, true, false, true, false, false, false, true, false, true, false, false}), fn: StringToHour, }, { name: "minute", expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{30, 30, 0, 0, 0}, []bool{false, false, true, false, true}), + []uint8{30, 30, 0, 0, 0, 30, 34, 0, 0, 2, 0, 34, 34}, + []bool{false, false, true, false, true, false, false, false, true, false, true, false, false}), fn: StringToMinute, }, { name: "second", expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{45, 45, 0, 0, 0}, []bool{false, false, true, false, true}), + []uint8{45, 45, 0, 0, 0, 45, 56, 9, 0, 3, 0, 56, 56}, + []bool{false, false, true, false, true, false, false, false, true, false, true, false, false}), fn: StringToSecond, }, } diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index ce7b505a8b70a..20bae56626fbf 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -6,7 +6,7 @@ SELECT HOUR('2024-12-20 15:30:45') AS result2; 15 SELECT HOUR(NOW()) AS result3; ➤ result3[-6,8,0] 𝄀 -17 +19 SELECT HOUR(CAST('15:30:45' AS TIME)) AS time_cast; ➤ time_cast[4,32,0] 𝄀 15 @@ -112,3 +112,13 @@ HOUR('2 30:00:00') AS extended_day_time_hour, HOUR('60') AS invalid_compact_second_hour; ➤ negative_datetime_hour[4,32,0] ¦ negative_compact_datetime_hour[4,32,0] ¦ invalid_calendar_compact_datetime_hour[4,32,0] ¦ extended_day_time_hour[4,32,0] ¦ invalid_compact_second_hour[4,32,0] 𝄀 15 ¦ 15 ¦ null ¦ 78 ¦ null +SELECT HOUR('24-12-20 15:30:45') AS two_digit_year_hour, +HOUR('2000:01:01 12:34:56') AS colon_datetime_hour, +HOUR('1998-01-01 00:00:009') AS wide_second_datetime_hour, +HOUR('2024-12-20 15:30:4560') AS invalid_wide_second_datetime_hour, +HOUR('2001-11-00 01:02:03') AS incomplete_datetime_hour, +HOUR('99999990000') AS uint32_overflow_hour, +HOUR('- 12:34:56') AS signed_space_time_hour, +HOUR('12:34:56.') AS trailing_dot_time_hour; +➤ two_digit_year_hour[4,32,0] ¦ colon_datetime_hour[4,32,0] ¦ wide_second_datetime_hour[4,32,0] ¦ invalid_wide_second_datetime_hour[4,32,0] ¦ incomplete_datetime_hour[4,32,0] ¦ uint32_overflow_hour[4,32,0] ¦ signed_space_time_hour[4,32,0] ¦ trailing_dot_time_hour[4,32,0] 𝄀 +15 ¦ 12 ¦ 0 ¦ null ¦ 1 ¦ null ¦ 12 ¦ 12 diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index 6c32365f68086..510c8f0bf29df 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -88,3 +88,13 @@ SELECT HOUR('-2024-12-20 15:30:45') AS negative_datetime_hour, HOUR('20240230010203') AS invalid_calendar_compact_datetime_hour, HOUR('2 30:00:00') AS extended_day_time_hour, HOUR('60') AS invalid_compact_second_hour; + +-- MySQL DATETIME grammar and numeric overflow for string TIME coercion. +SELECT HOUR('24-12-20 15:30:45') AS two_digit_year_hour, + HOUR('2000:01:01 12:34:56') AS colon_datetime_hour, + HOUR('1998-01-01 00:00:009') AS wide_second_datetime_hour, + HOUR('2024-12-20 15:30:4560') AS invalid_wide_second_datetime_hour, + HOUR('2001-11-00 01:02:03') AS incomplete_datetime_hour, + HOUR('99999990000') AS uint32_overflow_hour, + HOUR('- 12:34:56') AS signed_space_time_hour, + HOUR('12:34:56.') AS trailing_dot_time_hour; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 91ab5a7bc0c2f..aaf88c363db52 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -100,3 +100,13 @@ MINUTE('2 30:00:00') AS extended_day_time_minute, MINUTE('60') AS invalid_compact_second_minute; ➤ negative_datetime_minute[-6,8,0] ¦ negative_compact_datetime_minute[-6,8,0] ¦ invalid_calendar_compact_datetime_minute[-6,8,0] ¦ extended_day_time_minute[-6,8,0] ¦ invalid_compact_second_minute[-6,8,0] 𝄀 30 ¦ 30 ¦ null ¦ 0 ¦ null +SELECT MINUTE('24-12-20 15:30:45') AS two_digit_year_minute, +MINUTE('2000:01:01 12:34:56') AS colon_datetime_minute, +MINUTE('1998-01-01 00:00:009') AS wide_second_datetime_minute, +MINUTE('2024-12-20 15:30:4560') AS invalid_wide_second_datetime_minute, +MINUTE('2001-11-00 01:02:03') AS incomplete_datetime_minute, +MINUTE('99999990000') AS uint32_overflow_minute, +MINUTE('- 12:34:56') AS signed_space_time_minute, +MINUTE('12:34:56.') AS trailing_dot_time_minute; +➤ two_digit_year_minute[-6,8,0] ¦ colon_datetime_minute[-6,8,0] ¦ wide_second_datetime_minute[-6,8,0] ¦ invalid_wide_second_datetime_minute[-6,8,0] ¦ incomplete_datetime_minute[-6,8,0] ¦ uint32_overflow_minute[-6,8,0] ¦ signed_space_time_minute[-6,8,0] ¦ trailing_dot_time_minute[-6,8,0] 𝄀 +30 ¦ 34 ¦ 0 ¦ null ¦ 2 ¦ null ¦ 34 ¦ 34 diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index 60b7c562bad19..9aacbf69a4f93 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -84,3 +84,13 @@ SELECT MINUTE('-2024-12-20 15:30:45') AS negative_datetime_minute, MINUTE('20240230010203') AS invalid_calendar_compact_datetime_minute, MINUTE('2 30:00:00') AS extended_day_time_minute, MINUTE('60') AS invalid_compact_second_minute; + +-- MySQL DATETIME grammar and numeric overflow for string TIME coercion. +SELECT MINUTE('24-12-20 15:30:45') AS two_digit_year_minute, + MINUTE('2000:01:01 12:34:56') AS colon_datetime_minute, + MINUTE('1998-01-01 00:00:009') AS wide_second_datetime_minute, + MINUTE('2024-12-20 15:30:4560') AS invalid_wide_second_datetime_minute, + MINUTE('2001-11-00 01:02:03') AS incomplete_datetime_minute, + MINUTE('99999990000') AS uint32_overflow_minute, + MINUTE('- 12:34:56') AS signed_space_time_minute, + MINUTE('12:34:56.') AS trailing_dot_time_minute; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index 21fabd0f4c108..febebe46973df 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -101,3 +101,13 @@ SECOND('2 30:00:00') AS extended_day_time_second, SECOND('60') AS invalid_compact_second_second; ➤ negative_datetime_second[-6,8,0] ¦ negative_compact_datetime_second[-6,8,0] ¦ invalid_calendar_compact_datetime_second[-6,8,0] ¦ extended_day_time_second[-6,8,0] ¦ invalid_compact_second_second[-6,8,0] 𝄀 45 ¦ 45 ¦ null ¦ 0 ¦ null +SELECT SECOND('24-12-20 15:30:45') AS two_digit_year_second, +SECOND('2000:01:01 12:34:56') AS colon_datetime_second, +SECOND('1998-01-01 00:00:009') AS wide_second_datetime_second, +SECOND('2024-12-20 15:30:4560') AS invalid_wide_second_datetime_second, +SECOND('2001-11-00 01:02:03') AS incomplete_datetime_second, +SECOND('99999990000') AS uint32_overflow_second, +SECOND('- 12:34:56') AS signed_space_time_second, +SECOND('12:34:56.') AS trailing_dot_time_second; +➤ two_digit_year_second[-6,8,0] ¦ colon_datetime_second[-6,8,0] ¦ wide_second_datetime_second[-6,8,0] ¦ invalid_wide_second_datetime_second[-6,8,0] ¦ incomplete_datetime_second[-6,8,0] ¦ uint32_overflow_second[-6,8,0] ¦ signed_space_time_second[-6,8,0] ¦ trailing_dot_time_second[-6,8,0] 𝄀 +45 ¦ 56 ¦ 9 ¦ null ¦ 3 ¦ null ¦ 56 ¦ 56 diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index bd63691bccb08..8e75fc9b46718 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -84,3 +84,13 @@ SELECT SECOND('-2024-12-20 15:30:45') AS negative_datetime_second, SECOND('20240230010203') AS invalid_calendar_compact_datetime_second, SECOND('2 30:00:00') AS extended_day_time_second, SECOND('60') AS invalid_compact_second_second; + +-- MySQL DATETIME grammar and numeric overflow for string TIME coercion. +SELECT SECOND('24-12-20 15:30:45') AS two_digit_year_second, + SECOND('2000:01:01 12:34:56') AS colon_datetime_second, + SECOND('1998-01-01 00:00:009') AS wide_second_datetime_second, + SECOND('2024-12-20 15:30:4560') AS invalid_wide_second_datetime_second, + SECOND('2001-11-00 01:02:03') AS incomplete_datetime_second, + SECOND('99999990000') AS uint32_overflow_second, + SECOND('- 12:34:56') AS signed_space_time_second, + SECOND('12:34:56.') AS trailing_dot_time_second; From 5d2357826f17795fff68a8eaf93bd7e90650d0cf Mon Sep 17 00:00:00 2001 From: daviszhen Date: Wed, 29 Jul 2026 11:06:06 +0800 Subject: [PATCH 08/25] update --- pkg/sql/plan/function/func_unary.go | 43 +++++++++++-------- pkg/sql/plan/function/func_unary_test.go | 35 +++++++++++++-- .../cases/function/func_datetime_hour.result | 11 ++++- .../cases/function/func_datetime_hour.test | 7 +++ .../function/func_datetime_minute.result | 9 +++- .../cases/function/func_datetime_minute.test | 7 +++ .../function/func_datetime_second.result | 9 +++- .../cases/function/func_datetime_second.test | 7 +++ 8 files changed, 104 insertions(+), 24 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index b4de792b5ed5d..3cff220daf587 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4680,9 +4680,15 @@ func timeStringToFixedWithNullOnError[T types.FixedSizeTExceptStrType]( } // timeStringToClockForExtract follows MySQL's string-to-TIME coercion for -// HOUR, MINUTE, and SECOND. It deliberately parses only the bounded prefix -// needed for the clock fields: the general temporal parsers accept different -// grammars and must not receive arbitrary TIME-shaped user input here. +// HOUR, MINUTE, and SECOND. Its input contract is deliberately narrow: +// +// - TIME and space-separated DATETIME strings return their clock fields. +// - Date-only and ISO-T date prefixes coerce as compact TIME (00:MM:YY). +// - Zero date components do not discard an otherwise valid clock. +// - Other malformed date-looking strings return NULL. +// +// The general temporal parsers accept different grammars and must not receive +// arbitrary TIME-shaped user input here. func timeStringToClockForExtract(str string) (uint64, uint8, uint8, bool) { if str[0] == '-' { str = str[1:] @@ -4712,9 +4718,11 @@ func mysqlTimeStringToClockForExtract(str string) (uint64, uint8, uint8, bool) { } // MySQL coerces a complete date-only string through its leading year field: - // "2024-12-20" becomes the compact TIME 00:20:24. Do not apply this to - // malformed date-looking input, or invalid clocks would acquire a value. - if mysqlDateOnlyStringForExtract(str) { + // "2024-12-20" becomes the compact TIME 00:20:24. An ISO-T suffix does + // not make it a DATETIME for this TIME coercion, so it follows the same + // date-prefix rule. Do not apply this to other malformed date-looking input, + // or invalid clocks would acquire a value. + if mysqlDatePrefixTimeStringForExtract(str) { if hour, minute, second, ok := mysqlTimePrefixClockForExtract(str[:4]); ok { return hour, minute, second, true } @@ -4727,6 +4735,11 @@ func mysqlDateOnlyStringForExtract(str string) bool { asciiDigits(str[8:10]) && (str[4] == '-' || str[4] == '/') && str[7] == str[4] } +func mysqlDatePrefixTimeStringForExtract(str string) bool { + return mysqlDateOnlyStringForExtract(str) || + (len(str) > 10 && str[10] == 'T' && mysqlDateOnlyStringForExtract(str[:10])) +} + func mysqlLeadingDigitsExceedUint32(str string) bool { value := uint64(0) for i := 0; i < len(str) && str[i] >= '0' && str[i] <= '9'; i++ { @@ -4771,17 +4784,13 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { } pos++ day, _, ok := mysqlVariableDigitsForExtract(str, &pos) - if !ok || pos >= len(str) || (str[pos] != ' ' && str[pos] != 'T') { + if !ok || pos >= len(str) || str[pos] != ' ' { // A complete date without a clock is still handled by the TIME path. return timeExtractParseResult{} } result := timeExtractParseResult{matched: true} - if str[pos] == 'T' { + for pos < len(str) && str[pos] == ' ' { pos++ - } else { - for pos < len(str) && str[pos] == ' ' { - pos++ - } } hour, _, ok := mysqlVariableDigitsForExtract(str, &pos) if !ok || pos >= len(str) || str[pos] != ':' { @@ -4814,11 +4823,11 @@ func mysqlDatetimeDateForExtract(year, month, day uint64) bool { if year > 9999 || month > 12 || day > 31 { return false } - if month == 0 || day == 0 { - return year != 0 || (month == 0 && day == 0) - } - if year == 0 { - return false + // String-to-TIME coercion retains a valid clock even when the date has a + // zero component. The calendar is incomplete, but HOUR/MINUTE/SECOND do + // not need to reconstruct it. Fully specified dates still need validation. + if year == 0 || month == 0 || day == 0 { + return true } return types.ValidDate(int32(year), uint8(month), uint8(day)) } diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index a98e6b764ef95..1d40463654c1b 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4184,7 +4184,7 @@ func TestStringTimeExtract(t *testing.T) { name: "hour", expect: NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{12, 272, 272, 51, 12, 15, 15, 15, 0, 15, 15, 0, 0, 0, 0, 0, - 51, 12, 272, 272, 51, 838, 838, 838, 0, 0, 15, 12}, + 51, 12, 272, 272, 51, 838, 838, 838, 0, 0, 0, 12}, []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true, false, false, false, false, false, false, false, false, true, true, false, false}), fn: StringToHour, @@ -4193,7 +4193,7 @@ func TestStringTimeExtract(t *testing.T) { name: "minute", expect: NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{30, 59, 59, 4, 30, 30, 30, 30, 20, 30, 30, 0, 0, 0, 0, 0, - 4, 30, 59, 59, 4, 59, 59, 59, 0, 0, 30, 34}, + 4, 30, 59, 59, 4, 59, 59, 59, 0, 0, 20, 34}, []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true, false, false, false, false, false, false, false, false, true, true, false, false}), fn: StringToMinute, @@ -4202,7 +4202,7 @@ func TestStringTimeExtract(t *testing.T) { name: "second", expect: NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{45, 59, 59, 5, 45, 45, 45, 45, 24, 45, 45, 0, 0, 0, 0, 0, - 5, 45, 59, 59, 5, 59, 59, 59, 0, 0, 45, 56}, + 5, 45, 59, 59, 5, 59, 59, 59, 0, 0, 24, 56}, []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true, false, false, false, false, false, false, false, false, true, true, false, false}), fn: StringToSecond, @@ -4219,6 +4219,35 @@ func TestStringTimeExtract(t *testing.T) { } +func TestStringTimeExtractZeroDateAndISOSeparator(t *testing.T) { + proc := testutil.NewProcess(t) + inputs := []FunctionTestInput{ + NewFunctionTestInput(types.T_varchar.ToType(), []string{ + "0000-01-01 12:34:56", + "2024-00-01 11:22:33", + "2024-01-00 10:20:30", + "0000-00-00 09:08:07", + "2024-12-20T15:30:45.123456", + }, nil), + } + + for _, tc := range []struct { + name string + expect FunctionTestResult + fn fEvalFn + }{ + {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{12, 11, 10, 9, 0}, nil), StringToHour}, + {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{34, 22, 20, 8, 20}, nil), StringToMinute}, + {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{56, 33, 30, 7, 24}, nil), StringToSecond}, + } { + t.Run(tc.name, func(t *testing.T) { + tcc := NewFunctionTestCase(proc, inputs, tc.expect, tc.fn) + succeed, info := tcc.Run() + require.True(t, succeed, info) + }) + } +} + func TestStringTimeExtractWhitespace(t *testing.T) { for _, typ := range []types.T{types.T_char, types.T_varchar, types.T_text} { t.Run(typ.String(), func(t *testing.T) { diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index 20bae56626fbf..a39d2df6648cf 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -6,7 +6,7 @@ SELECT HOUR('2024-12-20 15:30:45') AS result2; 15 SELECT HOUR(NOW()) AS result3; ➤ result3[-6,8,0] 𝄀 -19 +11 SELECT HOUR(CAST('15:30:45' AS TIME)) AS time_cast; ➤ time_cast[4,32,0] 𝄀 15 @@ -104,7 +104,7 @@ HOUR('2024-12-20T15:30:45.123456') AS t_datetime_hour, HOUR(' 12:34:56 ') AS trimmed_time_hour, HOUR('foo12:34:56') AS malformed_time_hour; ➤ fractional_time_hour[4,32,0] ¦ day_fractional_time_hour[4,32,0] ¦ negative_fractional_time_hour[4,32,0] ¦ negative_day_fractional_time_hour[4,32,0] ¦ overflow_time_hour[4,32,0] ¦ negative_overflow_time_hour[4,32,0] ¦ invalid_minute_hour[4,32,0] ¦ invalid_second_hour[4,32,0] ¦ compact_time_hour[4,32,0] ¦ compact_datetime_fractional_hour[4,32,0] ¦ invalid_compact_datetime_hour[4,32,0] ¦ date_only_time_hour[4,32,0] ¦ t_datetime_hour[4,32,0] ¦ trimmed_time_hour[4,32,0] ¦ malformed_time_hour[4,32,0] 𝄀 -12 ¦ 51 ¦ 12 ¦ 51 ¦ 838 ¦ 838 ¦ null ¦ null ¦ 12 ¦ 15 ¦ null ¦ 0 ¦ 15 ¦ 12 ¦ null +12 ¦ 51 ¦ 12 ¦ 51 ¦ 838 ¦ 838 ¦ null ¦ null ¦ 12 ¦ 15 ¦ null ¦ 0 ¦ 0 ¦ 12 ¦ null SELECT HOUR('-2024-12-20 15:30:45') AS negative_datetime_hour, HOUR('-20241220153045') AS negative_compact_datetime_hour, HOUR('20240230010203') AS invalid_calendar_compact_datetime_hour, @@ -122,3 +122,10 @@ HOUR('- 12:34:56') AS signed_space_time_hour, HOUR('12:34:56.') AS trailing_dot_time_hour; ➤ two_digit_year_hour[4,32,0] ¦ colon_datetime_hour[4,32,0] ¦ wide_second_datetime_hour[4,32,0] ¦ invalid_wide_second_datetime_hour[4,32,0] ¦ incomplete_datetime_hour[4,32,0] ¦ uint32_overflow_hour[4,32,0] ¦ signed_space_time_hour[4,32,0] ¦ trailing_dot_time_hour[4,32,0] 𝄀 15 ¦ 12 ¦ 0 ¦ null ¦ 1 ¦ null ¦ 12 ¦ 12 +SELECT HOUR('0000-01-01 12:34:56') AS zero_year_datetime_hour, +HOUR('2024-00-01 11:22:33') AS zero_month_datetime_hour, +HOUR('2024-01-00 10:20:30') AS zero_day_datetime_hour, +HOUR('0000-00-00 09:08:07') AS zero_date_datetime_hour, +HOUR('2024-12-20T15:30:45.123456') AS iso_t_datetime_hour; +➤ zero_year_datetime_hour[4,32,0] ¦ zero_month_datetime_hour[4,32,0] ¦ zero_day_datetime_hour[4,32,0] ¦ zero_date_datetime_hour[4,32,0] ¦ iso_t_datetime_hour[4,32,0] 𝄀 +12 ¦ 11 ¦ 10 ¦ 9 ¦ 0 diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index 510c8f0bf29df..a7d12e26eb7fc 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -98,3 +98,10 @@ SELECT HOUR('24-12-20 15:30:45') AS two_digit_year_hour, HOUR('99999990000') AS uint32_overflow_hour, HOUR('- 12:34:56') AS signed_space_time_hour, HOUR('12:34:56.') AS trailing_dot_time_hour; + +-- Zero date components retain the clock; ISO-T uses DATE-prefix TIME coercion. +SELECT HOUR('0000-01-01 12:34:56') AS zero_year_datetime_hour, + HOUR('2024-00-01 11:22:33') AS zero_month_datetime_hour, + HOUR('2024-01-00 10:20:30') AS zero_day_datetime_hour, + HOUR('0000-00-00 09:08:07') AS zero_date_datetime_hour, + HOUR('2024-12-20T15:30:45.123456') AS iso_t_datetime_hour; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index aaf88c363db52..0d4674285f926 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -92,7 +92,7 @@ MINUTE('2024-12-20T15:30:45.123456') AS t_datetime_minute, MINUTE(' 12:34:56 ') AS trimmed_time_minute, MINUTE('foo12:34:56') AS malformed_time_minute; ➤ fractional_time_minute[-6,8,0] ¦ day_fractional_time_minute[-6,8,0] ¦ negative_fractional_time_minute[-6,8,0] ¦ negative_day_fractional_time_minute[-6,8,0] ¦ overflow_time_minute[-6,8,0] ¦ negative_overflow_time_minute[-6,8,0] ¦ invalid_minute_minute[-6,8,0] ¦ invalid_second_minute[-6,8,0] ¦ compact_time_minute[-6,8,0] ¦ compact_datetime_fractional_minute[-6,8,0] ¦ invalid_compact_datetime_minute[-6,8,0] ¦ date_only_time_minute[-6,8,0] ¦ t_datetime_minute[-6,8,0] ¦ trimmed_time_minute[-6,8,0] ¦ malformed_time_minute[-6,8,0] 𝄀 -34 ¦ 4 ¦ 34 ¦ 4 ¦ 59 ¦ 59 ¦ null ¦ null ¦ 30 ¦ 30 ¦ null ¦ 20 ¦ 30 ¦ 34 ¦ null +34 ¦ 4 ¦ 34 ¦ 4 ¦ 59 ¦ 59 ¦ null ¦ null ¦ 30 ¦ 30 ¦ null ¦ 20 ¦ 20 ¦ 34 ¦ null SELECT MINUTE('-2024-12-20 15:30:45') AS negative_datetime_minute, MINUTE('-20241220153045') AS negative_compact_datetime_minute, MINUTE('20240230010203') AS invalid_calendar_compact_datetime_minute, @@ -110,3 +110,10 @@ MINUTE('- 12:34:56') AS signed_space_time_minute, MINUTE('12:34:56.') AS trailing_dot_time_minute; ➤ two_digit_year_minute[-6,8,0] ¦ colon_datetime_minute[-6,8,0] ¦ wide_second_datetime_minute[-6,8,0] ¦ invalid_wide_second_datetime_minute[-6,8,0] ¦ incomplete_datetime_minute[-6,8,0] ¦ uint32_overflow_minute[-6,8,0] ¦ signed_space_time_minute[-6,8,0] ¦ trailing_dot_time_minute[-6,8,0] 𝄀 30 ¦ 34 ¦ 0 ¦ null ¦ 2 ¦ null ¦ 34 ¦ 34 +SELECT MINUTE('0000-01-01 12:34:56') AS zero_year_datetime_minute, +MINUTE('2024-00-01 11:22:33') AS zero_month_datetime_minute, +MINUTE('2024-01-00 10:20:30') AS zero_day_datetime_minute, +MINUTE('0000-00-00 09:08:07') AS zero_date_datetime_minute, +MINUTE('2024-12-20T15:30:45.123456') AS iso_t_datetime_minute; +➤ zero_year_datetime_minute[-6,8,0] ¦ zero_month_datetime_minute[-6,8,0] ¦ zero_day_datetime_minute[-6,8,0] ¦ zero_date_datetime_minute[-6,8,0] ¦ iso_t_datetime_minute[-6,8,0] 𝄀 +34 ¦ 22 ¦ 20 ¦ 8 ¦ 20 diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index 9aacbf69a4f93..b1e4686c36b3b 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -94,3 +94,10 @@ SELECT MINUTE('24-12-20 15:30:45') AS two_digit_year_minute, MINUTE('99999990000') AS uint32_overflow_minute, MINUTE('- 12:34:56') AS signed_space_time_minute, MINUTE('12:34:56.') AS trailing_dot_time_minute; + +-- Zero date components retain the clock; ISO-T uses DATE-prefix TIME coercion. +SELECT MINUTE('0000-01-01 12:34:56') AS zero_year_datetime_minute, + MINUTE('2024-00-01 11:22:33') AS zero_month_datetime_minute, + MINUTE('2024-01-00 10:20:30') AS zero_day_datetime_minute, + MINUTE('0000-00-00 09:08:07') AS zero_date_datetime_minute, + MINUTE('2024-12-20T15:30:45.123456') AS iso_t_datetime_minute; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index febebe46973df..3105bfbfb4601 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -93,7 +93,7 @@ SECOND('2024-12-20T15:30:45.123456') AS t_datetime_second, SECOND(' 12:34:56 ') AS trimmed_time_second, SECOND('foo12:34:56') AS malformed_time_second; ➤ fractional_time_second[-6,8,0] ¦ day_fractional_time_second[-6,8,0] ¦ negative_fractional_time_second[-6,8,0] ¦ negative_day_fractional_time_second[-6,8,0] ¦ overflow_time_second[-6,8,0] ¦ negative_overflow_time_second[-6,8,0] ¦ invalid_minute_second[-6,8,0] ¦ invalid_second_second[-6,8,0] ¦ compact_time_second[-6,8,0] ¦ compact_datetime_fractional_second[-6,8,0] ¦ invalid_compact_datetime_second[-6,8,0] ¦ date_only_time_second[-6,8,0] ¦ t_datetime_second[-6,8,0] ¦ trimmed_time_second[-6,8,0] ¦ malformed_time_second[-6,8,0] 𝄀 -56 ¦ 5 ¦ 56 ¦ 5 ¦ 59 ¦ 59 ¦ null ¦ null ¦ 45 ¦ 45 ¦ null ¦ 24 ¦ 45 ¦ 56 ¦ null +56 ¦ 5 ¦ 56 ¦ 5 ¦ 59 ¦ 59 ¦ null ¦ null ¦ 45 ¦ 45 ¦ null ¦ 24 ¦ 24 ¦ 56 ¦ null SELECT SECOND('-2024-12-20 15:30:45') AS negative_datetime_second, SECOND('-20241220153045') AS negative_compact_datetime_second, SECOND('20240230010203') AS invalid_calendar_compact_datetime_second, @@ -111,3 +111,10 @@ SECOND('- 12:34:56') AS signed_space_time_second, SECOND('12:34:56.') AS trailing_dot_time_second; ➤ two_digit_year_second[-6,8,0] ¦ colon_datetime_second[-6,8,0] ¦ wide_second_datetime_second[-6,8,0] ¦ invalid_wide_second_datetime_second[-6,8,0] ¦ incomplete_datetime_second[-6,8,0] ¦ uint32_overflow_second[-6,8,0] ¦ signed_space_time_second[-6,8,0] ¦ trailing_dot_time_second[-6,8,0] 𝄀 45 ¦ 56 ¦ 9 ¦ null ¦ 3 ¦ null ¦ 56 ¦ 56 +SELECT SECOND('0000-01-01 12:34:56') AS zero_year_datetime_second, +SECOND('2024-00-01 11:22:33') AS zero_month_datetime_second, +SECOND('2024-01-00 10:20:30') AS zero_day_datetime_second, +SECOND('0000-00-00 09:08:07') AS zero_date_datetime_second, +SECOND('2024-12-20T15:30:45.123456') AS iso_t_datetime_second; +➤ zero_year_datetime_second[-6,8,0] ¦ zero_month_datetime_second[-6,8,0] ¦ zero_day_datetime_second[-6,8,0] ¦ zero_date_datetime_second[-6,8,0] ¦ iso_t_datetime_second[-6,8,0] 𝄀 +56 ¦ 33 ¦ 30 ¦ 7 ¦ 24 diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 8e75fc9b46718..2d0126fbd0a0f 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -94,3 +94,10 @@ SELECT SECOND('24-12-20 15:30:45') AS two_digit_year_second, SECOND('99999990000') AS uint32_overflow_second, SECOND('- 12:34:56') AS signed_space_time_second, SECOND('12:34:56.') AS trailing_dot_time_second; + +-- Zero date components retain the clock; ISO-T uses DATE-prefix TIME coercion. +SELECT SECOND('0000-01-01 12:34:56') AS zero_year_datetime_second, + SECOND('2024-00-01 11:22:33') AS zero_month_datetime_second, + SECOND('2024-01-00 10:20:30') AS zero_day_datetime_second, + SECOND('0000-00-00 09:08:07') AS zero_date_datetime_second, + SECOND('2024-12-20T15:30:45.123456') AS iso_t_datetime_second; From 6eed654586f92badf0d89d31e33ca6f112caefcb Mon Sep 17 00:00:00 2001 From: daviszhen Date: Wed, 29 Jul 2026 11:24:01 +0800 Subject: [PATCH 09/25] update --- test/distributed/cases/function/func_datetime_hour.result | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index a39d2df6648cf..50bcaeb0a4a72 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -6,7 +6,7 @@ SELECT HOUR('2024-12-20 15:30:45') AS result2; 15 SELECT HOUR(NOW()) AS result3; ➤ result3[-6,8,0] 𝄀 -11 +19 SELECT HOUR(CAST('15:30:45' AS TIME)) AS time_cast; ➤ time_cast[4,32,0] 𝄀 15 From dfd10d8dbdb3b0811173718010348df1641138a2 Mon Sep 17 00:00:00 2001 From: daviszhen Date: Wed, 29 Jul 2026 14:54:11 +0800 Subject: [PATCH 10/25] update --- pkg/sql/plan/function/func_unary.go | 43 ++++++++++++++----- pkg/sql/plan/function/func_unary_test.go | 29 +++++++++++++ .../cases/function/func_datetime_hour.result | 7 +++ .../cases/function/func_datetime_hour.test | 7 +++ .../function/func_datetime_minute.result | 7 +++ .../cases/function/func_datetime_minute.test | 7 +++ .../function/func_datetime_second.result | 7 +++ .../cases/function/func_datetime_second.test | 7 +++ 8 files changed, 104 insertions(+), 10 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index e8ac4fb7f2994..f845f39345f86 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4800,16 +4800,22 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { } pos++ minute, _, ok := mysqlVariableDigitsForExtract(str, &pos) - if !ok || pos >= len(str) || str[pos] != ':' { + if !ok { return result } - pos++ second := uint64(0) - if pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { - second, _, ok = mysqlVariableDigitsForExtract(str, &pos) - if !ok { - return result + // MySQL accepts a DATETIME whose clock ends after the minute and supplies + // the omitted seconds as zero. + if pos < len(str) && str[pos] == ':' { + pos++ + if pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { + second, _, ok = mysqlVariableDigitsForExtract(str, &pos) + if !ok { + return result + } } + } else if pos < len(str) { + return result } if !mysqlDatetimeDateForExtract(year, month, day) || hour > 23 || minute > 59 || second > 59 { return result @@ -4825,15 +4831,32 @@ func mysqlDatetimeDateForExtract(year, month, day uint64) bool { if year > 9999 || month > 12 || day > 31 { return false } - // String-to-TIME coercion retains a valid clock even when the date has a - // zero component. The calendar is incomplete, but HOUR/MINUTE/SECOND do - // not need to reconstruct it. Fully specified dates still need validation. - if year == 0 || month == 0 || day == 0 { + // A zero month or day is an incomplete calendar value; string-to-TIME + // coercion can still extract its clock. Once both are nonzero, retain + // calendar validation even when the year is zero. + if month == 0 || day == 0 { return true } + if year == 0 { + return day <= mysqlDaysInMonthForExtract(year, month) + } return types.ValidDate(int32(year), uint8(month), uint8(day)) } +func mysqlDaysInMonthForExtract(year, month uint64) uint64 { + switch month { + case 4, 6, 9, 11: + return 30 + case 2: + if year%4 == 0 && (year%100 != 0 || year%400 == 0) { + return 29 + } + return 28 + default: + return 31 + } +} + func mysqlDateSeparatorForExtract(c byte) bool { return c == '-' || c == '/' || c == ':' } diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 38fcd8aae9239..1acad7d574dc0 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4248,6 +4248,35 @@ func TestStringTimeExtractZeroDateAndISOSeparator(t *testing.T) { } } +func TestStringTimeExtractIncompleteDatetimeAndZeroYearCalendar(t *testing.T) { + proc := testutil.NewProcess(t) + inputs := []FunctionTestInput{ + NewFunctionTestInput(types.T_varchar.ToType(), []string{ + "2024-12-20 12:34", + "0000-02-31 12:34:56", + "0000-04-31 12:34:56", + "1900-02-29 12:34:56", + "2000-02-29 01:02:03", + }, nil), + } + + for _, tc := range []struct { + name string + expect FunctionTestResult + fn fEvalFn + }{ + {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{12, 0, 0, 0, 1}, []bool{false, true, true, true, false}), StringToHour}, + {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{34, 0, 0, 0, 2}, []bool{false, true, true, true, false}), StringToMinute}, + {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{0, 0, 0, 0, 3}, []bool{false, true, true, true, false}), StringToSecond}, + } { + t.Run(tc.name, func(t *testing.T) { + tcc := NewFunctionTestCase(proc, inputs, tc.expect, tc.fn) + succeed, info := tcc.Run() + require.True(t, succeed, info) + }) + } +} + func TestStringTimeExtractWhitespace(t *testing.T) { for _, typ := range []types.T{types.T_char, types.T_varchar, types.T_text} { t.Run(typ.String(), func(t *testing.T) { diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index 50bcaeb0a4a72..d1edc293c07bb 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -129,3 +129,10 @@ HOUR('0000-00-00 09:08:07') AS zero_date_datetime_hour, HOUR('2024-12-20T15:30:45.123456') AS iso_t_datetime_hour; ➤ zero_year_datetime_hour[4,32,0] ¦ zero_month_datetime_hour[4,32,0] ¦ zero_day_datetime_hour[4,32,0] ¦ zero_date_datetime_hour[4,32,0] ¦ iso_t_datetime_hour[4,32,0] 𝄀 12 ¦ 11 ¦ 10 ¦ 9 ¦ 0 +SELECT HOUR('2024-12-20 12:34') AS minute_datetime_hour, +HOUR('0000-02-31 12:34:56') AS invalid_zero_year_february_hour, +HOUR('0000-04-31 12:34:56') AS invalid_zero_year_april_hour, +HOUR('1900-02-29 12:34:56') AS invalid_nonleap_datetime_hour, +HOUR('2000-02-29 01:02:03') AS leap_datetime_hour; +➤ minute_datetime_hour[4,32,0] ¦ invalid_zero_year_february_hour[4,32,0] ¦ invalid_zero_year_april_hour[4,32,0] ¦ invalid_nonleap_datetime_hour[4,32,0] ¦ leap_datetime_hour[4,32,0] 𝄀 +12 ¦ null ¦ null ¦ null ¦ 1 diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index a7d12e26eb7fc..2aaf0e3fcabc9 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -105,3 +105,10 @@ SELECT HOUR('0000-01-01 12:34:56') AS zero_year_datetime_hour, HOUR('2024-01-00 10:20:30') AS zero_day_datetime_hour, HOUR('0000-00-00 09:08:07') AS zero_date_datetime_hour, HOUR('2024-12-20T15:30:45.123456') AS iso_t_datetime_hour; + +-- A DATETIME may omit seconds; zero year still requires a valid nonzero month/day. +SELECT HOUR('2024-12-20 12:34') AS minute_datetime_hour, + HOUR('0000-02-31 12:34:56') AS invalid_zero_year_february_hour, + HOUR('0000-04-31 12:34:56') AS invalid_zero_year_april_hour, + HOUR('1900-02-29 12:34:56') AS invalid_nonleap_datetime_hour, + HOUR('2000-02-29 01:02:03') AS leap_datetime_hour; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 0d4674285f926..17940fe7901ae 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -117,3 +117,10 @@ MINUTE('0000-00-00 09:08:07') AS zero_date_datetime_minute, MINUTE('2024-12-20T15:30:45.123456') AS iso_t_datetime_minute; ➤ zero_year_datetime_minute[-6,8,0] ¦ zero_month_datetime_minute[-6,8,0] ¦ zero_day_datetime_minute[-6,8,0] ¦ zero_date_datetime_minute[-6,8,0] ¦ iso_t_datetime_minute[-6,8,0] 𝄀 34 ¦ 22 ¦ 20 ¦ 8 ¦ 20 +SELECT MINUTE('2024-12-20 12:34') AS minute_datetime_minute, +MINUTE('0000-02-31 12:34:56') AS invalid_zero_year_february_minute, +MINUTE('0000-04-31 12:34:56') AS invalid_zero_year_april_minute, +MINUTE('1900-02-29 12:34:56') AS invalid_nonleap_datetime_minute, +MINUTE('2000-02-29 01:02:03') AS leap_datetime_minute; +➤ minute_datetime_minute[-6,8,0] ¦ invalid_zero_year_february_minute[-6,8,0] ¦ invalid_zero_year_april_minute[-6,8,0] ¦ invalid_nonleap_datetime_minute[-6,8,0] ¦ leap_datetime_minute[-6,8,0] 𝄀 +34 ¦ null ¦ null ¦ null ¦ 2 diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index b1e4686c36b3b..4048ef4b6c2f2 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -101,3 +101,10 @@ SELECT MINUTE('0000-01-01 12:34:56') AS zero_year_datetime_minute, MINUTE('2024-01-00 10:20:30') AS zero_day_datetime_minute, MINUTE('0000-00-00 09:08:07') AS zero_date_datetime_minute, MINUTE('2024-12-20T15:30:45.123456') AS iso_t_datetime_minute; + +-- A DATETIME may omit seconds; zero year still requires a valid nonzero month/day. +SELECT MINUTE('2024-12-20 12:34') AS minute_datetime_minute, + MINUTE('0000-02-31 12:34:56') AS invalid_zero_year_february_minute, + MINUTE('0000-04-31 12:34:56') AS invalid_zero_year_april_minute, + MINUTE('1900-02-29 12:34:56') AS invalid_nonleap_datetime_minute, + MINUTE('2000-02-29 01:02:03') AS leap_datetime_minute; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index 3105bfbfb4601..ca0d3539892c7 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -118,3 +118,10 @@ SECOND('0000-00-00 09:08:07') AS zero_date_datetime_second, SECOND('2024-12-20T15:30:45.123456') AS iso_t_datetime_second; ➤ zero_year_datetime_second[-6,8,0] ¦ zero_month_datetime_second[-6,8,0] ¦ zero_day_datetime_second[-6,8,0] ¦ zero_date_datetime_second[-6,8,0] ¦ iso_t_datetime_second[-6,8,0] 𝄀 56 ¦ 33 ¦ 30 ¦ 7 ¦ 24 +SELECT SECOND('2024-12-20 12:34') AS minute_datetime_second, +SECOND('0000-02-31 12:34:56') AS invalid_zero_year_february_second, +SECOND('0000-04-31 12:34:56') AS invalid_zero_year_april_second, +SECOND('1900-02-29 12:34:56') AS invalid_nonleap_datetime_second, +SECOND('2000-02-29 01:02:03') AS leap_datetime_second; +➤ minute_datetime_second[-6,8,0] ¦ invalid_zero_year_february_second[-6,8,0] ¦ invalid_zero_year_april_second[-6,8,0] ¦ invalid_nonleap_datetime_second[-6,8,0] ¦ leap_datetime_second[-6,8,0] 𝄀 +0 ¦ null ¦ null ¦ null ¦ 3 diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 2d0126fbd0a0f..5113117415f7d 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -101,3 +101,10 @@ SELECT SECOND('0000-01-01 12:34:56') AS zero_year_datetime_second, SECOND('2024-01-00 10:20:30') AS zero_day_datetime_second, SECOND('0000-00-00 09:08:07') AS zero_date_datetime_second, SECOND('2024-12-20T15:30:45.123456') AS iso_t_datetime_second; + +-- A DATETIME may omit seconds; zero year still requires a valid nonzero month/day. +SELECT SECOND('2024-12-20 12:34') AS minute_datetime_second, + SECOND('0000-02-31 12:34:56') AS invalid_zero_year_february_second, + SECOND('0000-04-31 12:34:56') AS invalid_zero_year_april_second, + SECOND('1900-02-29 12:34:56') AS invalid_nonleap_datetime_second, + SECOND('2000-02-29 01:02:03') AS leap_datetime_second; From 3e5edea7a5822994a8d3e980f90fdd277fc33373 Mon Sep 17 00:00:00 2001 From: daviszhen Date: Wed, 29 Jul 2026 16:32:08 +0800 Subject: [PATCH 11/25] update --- pkg/sql/plan/function/func_unary.go | 18 ++++++++++ pkg/sql/plan/function/func_unary_test.go | 35 +++++++++++++++++-- .../cases/function/func_datetime_hour.result | 14 ++++++-- .../cases/function/func_datetime_hour.test | 10 ++++++ .../function/func_datetime_minute.result | 14 ++++++-- .../cases/function/func_datetime_minute.test | 10 ++++++ .../function/func_datetime_second.result | 14 ++++++-- .../cases/function/func_datetime_second.test | 10 ++++++ 8 files changed, 116 insertions(+), 9 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index f845f39345f86..8f6b2702c9d4d 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4848,6 +4848,11 @@ func mysqlDaysInMonthForExtract(year, month uint64) uint64 { case 4, 6, 9, 11: return 30 case 2: + // MySQL's zero year accepts February 28 but not February 29. Do not + // apply the proleptic Gregorian leap-year rule to year zero. + if year == 0 { + return 28 + } if year%4 == 0 && (year%100 != 0 || year%400 == 0) { return 29 } @@ -5012,16 +5017,29 @@ func parseCompactDatetimeClockForExtract(str string) timeExtractParseResult { } if digitCount >= 14 { + if !mysqlCompactDatetimeSuffixForExtract(str[14:]) { + return timeExtractParseResult{matched: true} + } hour, minute, second, ok := compactDatetimeClockForExtract(str[:14], false) return timeExtractParseResult{hour: hour, minute: minute, second: second, matched: true, valid: ok} } if digitCount >= 12 { + if !mysqlCompactDatetimeSuffixForExtract(str[12:]) { + return timeExtractParseResult{matched: true} + } hour, minute, second, ok := compactDatetimeClockForExtract(str[:12], true) return timeExtractParseResult{hour: hour, minute: minute, second: second, matched: true, valid: ok} } return timeExtractParseResult{} } +func mysqlCompactDatetimeSuffixForExtract(suffix string) bool { + if len(suffix) == 0 { + return true + } + return suffix[0] == '.' && len(suffix) > 1 && asciiDigits(suffix[1:]) +} + func compactDatetimeClockForExtract(str string, twoDigitYear bool) (uint64, uint8, uint8, bool) { yearWidth := 4 if twoDigitYear { diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 1acad7d574dc0..5343e9252b020 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4253,6 +4253,8 @@ func TestStringTimeExtractIncompleteDatetimeAndZeroYearCalendar(t *testing.T) { inputs := []FunctionTestInput{ NewFunctionTestInput(types.T_varchar.ToType(), []string{ "2024-12-20 12:34", + "0000-02-28 12:34:56", + "0000-02-29 12:34:56", "0000-02-31 12:34:56", "0000-04-31 12:34:56", "1900-02-29 12:34:56", @@ -4265,9 +4267,36 @@ func TestStringTimeExtractIncompleteDatetimeAndZeroYearCalendar(t *testing.T) { expect FunctionTestResult fn fEvalFn }{ - {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{12, 0, 0, 0, 1}, []bool{false, true, true, true, false}), StringToHour}, - {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{34, 0, 0, 0, 2}, []bool{false, true, true, true, false}), StringToMinute}, - {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{0, 0, 0, 0, 3}, []bool{false, true, true, true, false}), StringToSecond}, + {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{12, 12, 0, 0, 0, 0, 1}, []bool{false, false, true, true, true, true, false}), StringToHour}, + {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{34, 34, 0, 0, 0, 0, 2}, []bool{false, false, true, true, true, true, false}), StringToMinute}, + {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{0, 56, 0, 0, 0, 0, 3}, []bool{false, false, true, true, true, true, false}), StringToSecond}, + } { + t.Run(tc.name, func(t *testing.T) { + tcc := NewFunctionTestCase(proc, inputs, tc.expect, tc.fn) + succeed, info := tcc.Run() + require.True(t, succeed, info) + }) + } +} + +func TestStringTimeExtractCompactDatetimeSuffix(t *testing.T) { + proc := testutil.NewProcess(t) + inputs := []FunctionTestInput{ + NewFunctionTestInput(types.T_varchar.ToType(), []string{ + "20241220153045", "241220153045", "20241220153045.999999", "241220153045.9", + "202412201530451", "2412201530451", + "20241220153045abc", "241220153045abc", + }, nil), + } + + for _, tc := range []struct { + name string + expect FunctionTestResult + fn fEvalFn + }{ + {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{15, 15, 15, 15, 0, 0, 0, 0}, []bool{false, false, false, false, true, true, true, true}), StringToHour}, + {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{30, 30, 30, 30, 0, 0, 0, 0}, []bool{false, false, false, false, true, true, true, true}), StringToMinute}, + {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{45, 45, 45, 45, 0, 0, 0, 0}, []bool{false, false, false, false, true, true, true, true}), StringToSecond}, } { t.Run(tc.name, func(t *testing.T) { tcc := NewFunctionTestCase(proc, inputs, tc.expect, tc.fn) diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index d1edc293c07bb..4e04c9248e633 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -130,9 +130,19 @@ HOUR('2024-12-20T15:30:45.123456') AS iso_t_datetime_hour; ➤ zero_year_datetime_hour[4,32,0] ¦ zero_month_datetime_hour[4,32,0] ¦ zero_day_datetime_hour[4,32,0] ¦ zero_date_datetime_hour[4,32,0] ¦ iso_t_datetime_hour[4,32,0] 𝄀 12 ¦ 11 ¦ 10 ¦ 9 ¦ 0 SELECT HOUR('2024-12-20 12:34') AS minute_datetime_hour, +HOUR('0000-02-28 12:34:56') AS valid_zero_year_february_hour, +HOUR('0000-02-29 12:34:56') AS invalid_zero_year_leap_day_hour, HOUR('0000-02-31 12:34:56') AS invalid_zero_year_february_hour, HOUR('0000-04-31 12:34:56') AS invalid_zero_year_april_hour, HOUR('1900-02-29 12:34:56') AS invalid_nonleap_datetime_hour, HOUR('2000-02-29 01:02:03') AS leap_datetime_hour; -➤ minute_datetime_hour[4,32,0] ¦ invalid_zero_year_february_hour[4,32,0] ¦ invalid_zero_year_april_hour[4,32,0] ¦ invalid_nonleap_datetime_hour[4,32,0] ¦ leap_datetime_hour[4,32,0] 𝄀 -12 ¦ null ¦ null ¦ null ¦ 1 +➤ minute_datetime_hour[4,32,0] ¦ valid_zero_year_february_hour[4,32,0] ¦ invalid_zero_year_leap_day_hour[4,32,0] ¦ invalid_zero_year_february_hour[4,32,0] ¦ invalid_zero_year_april_hour[4,32,0] ¦ invalid_nonleap_datetime_hour[4,32,0] ¦ leap_datetime_hour[4,32,0] 𝄀 +12 ¦ 12 ¦ null ¦ null ¦ null ¦ null ¦ 1 +SELECT HOUR('20241220153045') AS compact_14_hour, +HOUR('241220153045') AS compact_12_hour, +HOUR('202412201530451') AS compact_14_extra_digit_hour, +HOUR('2412201530451') AS compact_12_extra_digit_hour, +HOUR('20241220153045abc') AS compact_14_alpha_suffix_hour, +HOUR('241220153045abc') AS compact_12_alpha_suffix_hour; +➤ compact_14_hour[4,32,0] ¦ compact_12_hour[4,32,0] ¦ compact_14_extra_digit_hour[4,32,0] ¦ compact_12_extra_digit_hour[4,32,0] ¦ compact_14_alpha_suffix_hour[4,32,0] ¦ compact_12_alpha_suffix_hour[4,32,0] 𝄀 +15 ¦ 15 ¦ null ¦ null ¦ null ¦ null diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index 2aaf0e3fcabc9..e8d2aa816e88f 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -108,7 +108,17 @@ SELECT HOUR('0000-01-01 12:34:56') AS zero_year_datetime_hour, -- A DATETIME may omit seconds; zero year still requires a valid nonzero month/day. SELECT HOUR('2024-12-20 12:34') AS minute_datetime_hour, + HOUR('0000-02-28 12:34:56') AS valid_zero_year_february_hour, + HOUR('0000-02-29 12:34:56') AS invalid_zero_year_leap_day_hour, HOUR('0000-02-31 12:34:56') AS invalid_zero_year_february_hour, HOUR('0000-04-31 12:34:56') AS invalid_zero_year_april_hour, HOUR('1900-02-29 12:34:56') AS invalid_nonleap_datetime_hour, HOUR('2000-02-29 01:02:03') AS leap_datetime_hour; + +-- Compact DATETIME only accepts exact 14/12-digit forms or a fractional suffix. +SELECT HOUR('20241220153045') AS compact_14_hour, + HOUR('241220153045') AS compact_12_hour, + HOUR('202412201530451') AS compact_14_extra_digit_hour, + HOUR('2412201530451') AS compact_12_extra_digit_hour, + HOUR('20241220153045abc') AS compact_14_alpha_suffix_hour, + HOUR('241220153045abc') AS compact_12_alpha_suffix_hour; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 17940fe7901ae..781512f79756f 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -118,9 +118,19 @@ MINUTE('2024-12-20T15:30:45.123456') AS iso_t_datetime_minute; ➤ zero_year_datetime_minute[-6,8,0] ¦ zero_month_datetime_minute[-6,8,0] ¦ zero_day_datetime_minute[-6,8,0] ¦ zero_date_datetime_minute[-6,8,0] ¦ iso_t_datetime_minute[-6,8,0] 𝄀 34 ¦ 22 ¦ 20 ¦ 8 ¦ 20 SELECT MINUTE('2024-12-20 12:34') AS minute_datetime_minute, +MINUTE('0000-02-28 12:34:56') AS valid_zero_year_february_minute, +MINUTE('0000-02-29 12:34:56') AS invalid_zero_year_leap_day_minute, MINUTE('0000-02-31 12:34:56') AS invalid_zero_year_february_minute, MINUTE('0000-04-31 12:34:56') AS invalid_zero_year_april_minute, MINUTE('1900-02-29 12:34:56') AS invalid_nonleap_datetime_minute, MINUTE('2000-02-29 01:02:03') AS leap_datetime_minute; -➤ minute_datetime_minute[-6,8,0] ¦ invalid_zero_year_february_minute[-6,8,0] ¦ invalid_zero_year_april_minute[-6,8,0] ¦ invalid_nonleap_datetime_minute[-6,8,0] ¦ leap_datetime_minute[-6,8,0] 𝄀 -34 ¦ null ¦ null ¦ null ¦ 2 +➤ minute_datetime_minute[-6,8,0] ¦ valid_zero_year_february_minute[-6,8,0] ¦ invalid_zero_year_leap_day_minute[-6,8,0] ¦ invalid_zero_year_february_minute[-6,8,0] ¦ invalid_zero_year_april_minute[-6,8,0] ¦ invalid_nonleap_datetime_minute[-6,8,0] ¦ leap_datetime_minute[-6,8,0] 𝄀 +34 ¦ 34 ¦ null ¦ null ¦ null ¦ null ¦ 2 +SELECT MINUTE('20241220153045') AS compact_14_minute, +MINUTE('241220153045') AS compact_12_minute, +MINUTE('202412201530451') AS compact_14_extra_digit_minute, +MINUTE('2412201530451') AS compact_12_extra_digit_minute, +MINUTE('20241220153045abc') AS compact_14_alpha_suffix_minute, +MINUTE('241220153045abc') AS compact_12_alpha_suffix_minute; +➤ compact_14_minute[-6,8,0] ¦ compact_12_minute[-6,8,0] ¦ compact_14_extra_digit_minute[-6,8,0] ¦ compact_12_extra_digit_minute[-6,8,0] ¦ compact_14_alpha_suffix_minute[-6,8,0] ¦ compact_12_alpha_suffix_minute[-6,8,0] 𝄀 +30 ¦ 30 ¦ null ¦ null ¦ null ¦ null diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index 4048ef4b6c2f2..e4c7a3edd8565 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -104,7 +104,17 @@ SELECT MINUTE('0000-01-01 12:34:56') AS zero_year_datetime_minute, -- A DATETIME may omit seconds; zero year still requires a valid nonzero month/day. SELECT MINUTE('2024-12-20 12:34') AS minute_datetime_minute, + MINUTE('0000-02-28 12:34:56') AS valid_zero_year_february_minute, + MINUTE('0000-02-29 12:34:56') AS invalid_zero_year_leap_day_minute, MINUTE('0000-02-31 12:34:56') AS invalid_zero_year_february_minute, MINUTE('0000-04-31 12:34:56') AS invalid_zero_year_april_minute, MINUTE('1900-02-29 12:34:56') AS invalid_nonleap_datetime_minute, MINUTE('2000-02-29 01:02:03') AS leap_datetime_minute; + +-- Compact DATETIME only accepts exact 14/12-digit forms or a fractional suffix. +SELECT MINUTE('20241220153045') AS compact_14_minute, + MINUTE('241220153045') AS compact_12_minute, + MINUTE('202412201530451') AS compact_14_extra_digit_minute, + MINUTE('2412201530451') AS compact_12_extra_digit_minute, + MINUTE('20241220153045abc') AS compact_14_alpha_suffix_minute, + MINUTE('241220153045abc') AS compact_12_alpha_suffix_minute; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index ca0d3539892c7..fc681e23c7219 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -119,9 +119,19 @@ SECOND('2024-12-20T15:30:45.123456') AS iso_t_datetime_second; ➤ zero_year_datetime_second[-6,8,0] ¦ zero_month_datetime_second[-6,8,0] ¦ zero_day_datetime_second[-6,8,0] ¦ zero_date_datetime_second[-6,8,0] ¦ iso_t_datetime_second[-6,8,0] 𝄀 56 ¦ 33 ¦ 30 ¦ 7 ¦ 24 SELECT SECOND('2024-12-20 12:34') AS minute_datetime_second, +SECOND('0000-02-28 12:34:56') AS valid_zero_year_february_second, +SECOND('0000-02-29 12:34:56') AS invalid_zero_year_leap_day_second, SECOND('0000-02-31 12:34:56') AS invalid_zero_year_february_second, SECOND('0000-04-31 12:34:56') AS invalid_zero_year_april_second, SECOND('1900-02-29 12:34:56') AS invalid_nonleap_datetime_second, SECOND('2000-02-29 01:02:03') AS leap_datetime_second; -➤ minute_datetime_second[-6,8,0] ¦ invalid_zero_year_february_second[-6,8,0] ¦ invalid_zero_year_april_second[-6,8,0] ¦ invalid_nonleap_datetime_second[-6,8,0] ¦ leap_datetime_second[-6,8,0] 𝄀 -0 ¦ null ¦ null ¦ null ¦ 3 +➤ minute_datetime_second[-6,8,0] ¦ valid_zero_year_february_second[-6,8,0] ¦ invalid_zero_year_leap_day_second[-6,8,0] ¦ invalid_zero_year_february_second[-6,8,0] ¦ invalid_zero_year_april_second[-6,8,0] ¦ invalid_nonleap_datetime_second[-6,8,0] ¦ leap_datetime_second[-6,8,0] 𝄀 +0 ¦ 56 ¦ null ¦ null ¦ null ¦ null ¦ 3 +SELECT SECOND('20241220153045') AS compact_14_second, +SECOND('241220153045') AS compact_12_second, +SECOND('202412201530451') AS compact_14_extra_digit_second, +SECOND('2412201530451') AS compact_12_extra_digit_second, +SECOND('20241220153045abc') AS compact_14_alpha_suffix_second, +SECOND('241220153045abc') AS compact_12_alpha_suffix_second; +➤ compact_14_second[-6,8,0] ¦ compact_12_second[-6,8,0] ¦ compact_14_extra_digit_second[-6,8,0] ¦ compact_12_extra_digit_second[-6,8,0] ¦ compact_14_alpha_suffix_second[-6,8,0] ¦ compact_12_alpha_suffix_second[-6,8,0] 𝄀 +45 ¦ 45 ¦ null ¦ null ¦ null ¦ null diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 5113117415f7d..40b2fa6b8270b 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -104,7 +104,17 @@ SELECT SECOND('0000-01-01 12:34:56') AS zero_year_datetime_second, -- A DATETIME may omit seconds; zero year still requires a valid nonzero month/day. SELECT SECOND('2024-12-20 12:34') AS minute_datetime_second, + SECOND('0000-02-28 12:34:56') AS valid_zero_year_february_second, + SECOND('0000-02-29 12:34:56') AS invalid_zero_year_leap_day_second, SECOND('0000-02-31 12:34:56') AS invalid_zero_year_february_second, SECOND('0000-04-31 12:34:56') AS invalid_zero_year_april_second, SECOND('1900-02-29 12:34:56') AS invalid_nonleap_datetime_second, SECOND('2000-02-29 01:02:03') AS leap_datetime_second; + +-- Compact DATETIME only accepts exact 14/12-digit forms or a fractional suffix. +SELECT SECOND('20241220153045') AS compact_14_second, + SECOND('241220153045') AS compact_12_second, + SECOND('202412201530451') AS compact_14_extra_digit_second, + SECOND('2412201530451') AS compact_12_extra_digit_second, + SECOND('20241220153045abc') AS compact_14_alpha_suffix_second, + SECOND('241220153045abc') AS compact_12_alpha_suffix_second; From c192e90285ac3d9bba35112075104c50c255f775 Mon Sep 17 00:00:00 2001 From: daviszhen Date: Thu, 30 Jul 2026 12:54:43 +0800 Subject: [PATCH 12/25] update --- pkg/sql/plan/function/func_unary.go | 72 ++++++++++++++----- pkg/sql/plan/function/func_unary_test.go | 32 ++++++++- .../cases/function/func_datetime_hour.result | 12 ++++ .../cases/function/func_datetime_hour.test | 13 ++++ .../function/func_datetime_minute.result | 12 ++++ .../cases/function/func_datetime_minute.test | 13 ++++ .../function/func_datetime_second.result | 12 ++++ .../cases/function/func_datetime_second.test | 13 ++++ 8 files changed, 157 insertions(+), 22 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index 8f6b2702c9d4d..da0d9c4c6ed80 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4884,9 +4884,9 @@ func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { } if dot := strings.IndexByte(prefix, '.'); dot >= 0 { - if dot < len(prefix)-1 && !asciiDigits(prefix[dot+1:]) { - return 0, 0, 0, false - } + // MySQL consumes the complete TIME prefix before a fractional separator. + // The remainder is irrelevant to HOUR/MINUTE/SECOND extraction, including + // an empty fraction or trailing non-numeric text. prefix = prefix[:dot] } @@ -4955,31 +4955,62 @@ func mysqlClockFieldsForExtract(str string) (uint64, uint8, uint8, bool) { } } - secondColon := firstColon + 1 + strings.IndexByte(str[firstColon+1:], ':') - if secondColon <= firstColon { - hourText, minuteText := str[:firstColon], str[firstColon+1:] - if len(hourText) == 0 || len(minuteText) == 0 || !asciiDigits(hourText) || !asciiDigits(minuteText) { + hourText := str[:firstColon] + if len(hourText) > 0 && !asciiDigits(hourText) { + return 0, 0, 0, false + } + + pos := firstColon + 1 + minuteStart := pos + for pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { + pos++ + } + minuteText := str[minuteStart:pos] + if len(minuteText) == 0 { + // MySQL interprets a trailing colon after a numeric field as a + // seconds-only value, for example "12:" as 00:00:12. + if len(hourText) == 0 || pos != len(str) { return 0, 0, 0, false } - hour := mysqlClampedDigitsForExtract(hourText, 839) - minute := mysqlClampedDigitsForExtract(minuteText, 60) - if minute >= 60 { + second := mysqlClampedDigitsForExtract(hourText, 60) + if second >= 60 { return 0, 0, 0, false } - return hour, uint8(minute), 0, true + return 0, 0, uint8(second), true } - if strings.IndexByte(str[secondColon+1:], ':') >= 0 { + + minute := mysqlClampedDigitsForExtract(minuteText, 60) + if minute >= 60 { return 0, 0, 0, false } - hourText, minuteText, secondText := str[:firstColon], str[firstColon+1:secondColon], str[secondColon+1:] - if len(hourText) == 0 || len(minuteText) == 0 || len(secondText) == 0 || - !asciiDigits(hourText) || !asciiDigits(minuteText) || !asciiDigits(secondText) { - return 0, 0, 0, false + if len(hourText) == 0 { + // A leading colon leaves the hour unspecified: ":34" is 00:34:00. + return 0, uint8(minute), 0, true } + hour := mysqlClampedDigitsForExtract(hourText, 839) - minute := mysqlClampedDigitsForExtract(minuteText, 60) + if pos == len(str) || str[pos] != ':' { + // Stop at the valid HOUR:MINUTE prefix. mysqlTimePrefixForExtract has + // already retained intentionally tolerated trailing text. + return hour, uint8(minute), 0, true + } + + pos++ + secondStart := pos + for pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { + pos++ + } + secondText := str[secondStart:pos] + if len(secondText) == 0 { + return 0, 0, 0, false + } second := mysqlClampedDigitsForExtract(secondText, 60) - if minute >= 60 || second >= 60 { + if second >= 60 { + return 0, 0, 0, false + } + // Consume a complete HOUR:MINUTE:SECOND prefix and ignore following + // punctuation/text. A third colon remains structural input and is invalid. + if pos < len(str) && str[pos] == ':' { return 0, 0, 0, false } return hour, uint8(minute), uint8(second), true @@ -5037,7 +5068,10 @@ func mysqlCompactDatetimeSuffixForExtract(suffix string) bool { if len(suffix) == 0 { return true } - return suffix[0] == '.' && len(suffix) > 1 && asciiDigits(suffix[1:]) + // MySQL consumes a compact DATETIME through the fractional separator. The + // fraction may be empty or may stop before trailing non-numeric text, but a + // suffix without a decimal separator is not part of this coercion. + return suffix[0] == '.' } func compactDatetimeClockForExtract(str string, twoDigitYear bool) (uint64, uint8, uint8, bool) { diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 0f3bdd68c42e0..70b214beb5d0e 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4284,6 +4284,7 @@ func TestStringTimeExtractCompactDatetimeSuffix(t *testing.T) { inputs := []FunctionTestInput{ NewFunctionTestInput(types.T_varchar.ToType(), []string{ "20241220153045", "241220153045", "20241220153045.999999", "241220153045.9", + "20241220153045.123abc", "20241220153045.", "241220153045.123abc", "241220153045.", "202412201530451", "2412201530451", "20241220153045abc", "241220153045abc", }, nil), @@ -4294,9 +4295,34 @@ func TestStringTimeExtractCompactDatetimeSuffix(t *testing.T) { expect FunctionTestResult fn fEvalFn }{ - {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{15, 15, 15, 15, 0, 0, 0, 0}, []bool{false, false, false, false, true, true, true, true}), StringToHour}, - {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{30, 30, 30, 30, 0, 0, 0, 0}, []bool{false, false, false, false, true, true, true, true}), StringToMinute}, - {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{45, 45, 45, 45, 0, 0, 0, 0}, []bool{false, false, false, false, true, true, true, true}), StringToSecond}, + {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{15, 15, 15, 15, 15, 15, 15, 15, 0, 0, 0, 0}, []bool{false, false, false, false, false, false, false, false, true, true, true, true}), StringToHour}, + {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{30, 30, 30, 30, 30, 30, 30, 30, 0, 0, 0, 0}, []bool{false, false, false, false, false, false, false, false, true, true, true, true}), StringToMinute}, + {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{45, 45, 45, 45, 45, 45, 45, 45, 0, 0, 0, 0}, []bool{false, false, false, false, false, false, false, false, true, true, true, true}), StringToSecond}, + } { + t.Run(tc.name, func(t *testing.T) { + tcc := NewFunctionTestCase(proc, inputs, tc.expect, tc.fn) + succeed, info := tcc.Run() + require.True(t, succeed, info) + }) + } +} + +func TestStringTimeExtractPartialClockPrefix(t *testing.T) { + proc := testutil.NewProcess(t) + inputs := []FunctionTestInput{ + NewFunctionTestInput(types.T_varchar.ToType(), []string{ + "12:", ":34", "12:34:56-", "12:34:56..", + }, nil), + } + + for _, tc := range []struct { + name string + expect FunctionTestResult + fn fEvalFn + }{ + {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{0, 0, 12, 12}, nil), StringToHour}, + {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{0, 34, 34, 34}, nil), StringToMinute}, + {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{12, 0, 56, 56}, nil), StringToSecond}, } { t.Run(tc.name, func(t *testing.T) { tcc := NewFunctionTestCase(proc, inputs, tc.expect, tc.fn) diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index 4e04c9248e633..a1716a57ed690 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -146,3 +146,15 @@ HOUR('20241220153045abc') AS compact_14_alpha_suffix_hour, HOUR('241220153045abc') AS compact_12_alpha_suffix_hour; ➤ compact_14_hour[4,32,0] ¦ compact_12_hour[4,32,0] ¦ compact_14_extra_digit_hour[4,32,0] ¦ compact_12_extra_digit_hour[4,32,0] ¦ compact_14_alpha_suffix_hour[4,32,0] ¦ compact_12_alpha_suffix_hour[4,32,0] 𝄀 15 ¦ 15 ¦ null ¦ null ¦ null ¦ null +SELECT HOUR('20241220153045.123abc') AS compact_14_fraction_text_hour, +HOUR('20241220153045.') AS compact_14_empty_fraction_hour, +HOUR('241220153045.123abc') AS compact_12_fraction_text_hour, +HOUR('241220153045.') AS compact_12_empty_fraction_hour; +➤ compact_14_fraction_text_hour[4,32,0] ¦ compact_14_empty_fraction_hour[4,32,0] ¦ compact_12_fraction_text_hour[4,32,0] ¦ compact_12_empty_fraction_hour[4,32,0] 𝄀 +15 ¦ 15 ¦ 15 ¦ 15 +SELECT HOUR('12:') AS trailing_colon_hour, +HOUR(':34') AS leading_colon_hour, +HOUR('12:34:56-') AS trailing_dash_hour, +HOUR('12:34:56..') AS trailing_dots_hour; +➤ trailing_colon_hour[4,32,0] ¦ leading_colon_hour[4,32,0] ¦ trailing_dash_hour[4,32,0] ¦ trailing_dots_hour[4,32,0] 𝄀 +0 ¦ 0 ¦ 12 ¦ 12 diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index e8d2aa816e88f..8a5f80345f9e9 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -122,3 +122,16 @@ SELECT HOUR('20241220153045') AS compact_14_hour, HOUR('2412201530451') AS compact_12_extra_digit_hour, HOUR('20241220153045abc') AS compact_14_alpha_suffix_hour, HOUR('241220153045abc') AS compact_12_alpha_suffix_hour; + +-- Compact DATETIME consumes the fractional prefix, including an empty fraction +-- or non-numeric text after the fractional digits. +SELECT HOUR('20241220153045.123abc') AS compact_14_fraction_text_hour, + HOUR('20241220153045.') AS compact_14_empty_fraction_hour, + HOUR('241220153045.123abc') AS compact_12_fraction_text_hour, + HOUR('241220153045.') AS compact_12_empty_fraction_hour; + +-- TIME coercion stops after the valid clock prefix. +SELECT HOUR('12:') AS trailing_colon_hour, + HOUR(':34') AS leading_colon_hour, + HOUR('12:34:56-') AS trailing_dash_hour, + HOUR('12:34:56..') AS trailing_dots_hour; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 781512f79756f..f894bc6685973 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -134,3 +134,15 @@ MINUTE('20241220153045abc') AS compact_14_alpha_suffix_minute, MINUTE('241220153045abc') AS compact_12_alpha_suffix_minute; ➤ compact_14_minute[-6,8,0] ¦ compact_12_minute[-6,8,0] ¦ compact_14_extra_digit_minute[-6,8,0] ¦ compact_12_extra_digit_minute[-6,8,0] ¦ compact_14_alpha_suffix_minute[-6,8,0] ¦ compact_12_alpha_suffix_minute[-6,8,0] 𝄀 30 ¦ 30 ¦ null ¦ null ¦ null ¦ null +SELECT MINUTE('20241220153045.123abc') AS compact_14_fraction_text_minute, +MINUTE('20241220153045.') AS compact_14_empty_fraction_minute, +MINUTE('241220153045.123abc') AS compact_12_fraction_text_minute, +MINUTE('241220153045.') AS compact_12_empty_fraction_minute; +➤ compact_14_fraction_text_minute[-6,8,0] ¦ compact_14_empty_fraction_minute[-6,8,0] ¦ compact_12_fraction_text_minute[-6,8,0] ¦ compact_12_empty_fraction_minute[-6,8,0] 𝄀 +30 ¦ 30 ¦ 30 ¦ 30 +SELECT MINUTE('12:') AS trailing_colon_minute, +MINUTE(':34') AS leading_colon_minute, +MINUTE('12:34:56-') AS trailing_dash_minute, +MINUTE('12:34:56..') AS trailing_dots_minute; +➤ trailing_colon_minute[-6,8,0] ¦ leading_colon_minute[-6,8,0] ¦ trailing_dash_minute[-6,8,0] ¦ trailing_dots_minute[-6,8,0] 𝄀 +0 ¦ 34 ¦ 34 ¦ 34 diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index e4c7a3edd8565..95f34e72b0419 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -118,3 +118,16 @@ SELECT MINUTE('20241220153045') AS compact_14_minute, MINUTE('2412201530451') AS compact_12_extra_digit_minute, MINUTE('20241220153045abc') AS compact_14_alpha_suffix_minute, MINUTE('241220153045abc') AS compact_12_alpha_suffix_minute; + +-- Compact DATETIME consumes the fractional prefix, including an empty fraction +-- or non-numeric text after the fractional digits. +SELECT MINUTE('20241220153045.123abc') AS compact_14_fraction_text_minute, + MINUTE('20241220153045.') AS compact_14_empty_fraction_minute, + MINUTE('241220153045.123abc') AS compact_12_fraction_text_minute, + MINUTE('241220153045.') AS compact_12_empty_fraction_minute; + +-- TIME coercion stops after the valid clock prefix. +SELECT MINUTE('12:') AS trailing_colon_minute, + MINUTE(':34') AS leading_colon_minute, + MINUTE('12:34:56-') AS trailing_dash_minute, + MINUTE('12:34:56..') AS trailing_dots_minute; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index fc681e23c7219..a84e08bf576ab 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -135,3 +135,15 @@ SECOND('20241220153045abc') AS compact_14_alpha_suffix_second, SECOND('241220153045abc') AS compact_12_alpha_suffix_second; ➤ compact_14_second[-6,8,0] ¦ compact_12_second[-6,8,0] ¦ compact_14_extra_digit_second[-6,8,0] ¦ compact_12_extra_digit_second[-6,8,0] ¦ compact_14_alpha_suffix_second[-6,8,0] ¦ compact_12_alpha_suffix_second[-6,8,0] 𝄀 45 ¦ 45 ¦ null ¦ null ¦ null ¦ null +SELECT SECOND('20241220153045.123abc') AS compact_14_fraction_text_second, +SECOND('20241220153045.') AS compact_14_empty_fraction_second, +SECOND('241220153045.123abc') AS compact_12_fraction_text_second, +SECOND('241220153045.') AS compact_12_empty_fraction_second; +➤ compact_14_fraction_text_second[-6,8,0] ¦ compact_14_empty_fraction_second[-6,8,0] ¦ compact_12_fraction_text_second[-6,8,0] ¦ compact_12_empty_fraction_second[-6,8,0] 𝄀 +45 ¦ 45 ¦ 45 ¦ 45 +SELECT SECOND('12:') AS trailing_colon_second, +SECOND(':34') AS leading_colon_second, +SECOND('12:34:56-') AS trailing_dash_second, +SECOND('12:34:56..') AS trailing_dots_second; +➤ trailing_colon_second[-6,8,0] ¦ leading_colon_second[-6,8,0] ¦ trailing_dash_second[-6,8,0] ¦ trailing_dots_second[-6,8,0] 𝄀 +12 ¦ 0 ¦ 56 ¦ 56 diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 40b2fa6b8270b..e6b0949b82d0f 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -118,3 +118,16 @@ SELECT SECOND('20241220153045') AS compact_14_second, SECOND('2412201530451') AS compact_12_extra_digit_second, SECOND('20241220153045abc') AS compact_14_alpha_suffix_second, SECOND('241220153045abc') AS compact_12_alpha_suffix_second; + +-- Compact DATETIME consumes the fractional prefix, including an empty fraction +-- or non-numeric text after the fractional digits. +SELECT SECOND('20241220153045.123abc') AS compact_14_fraction_text_second, + SECOND('20241220153045.') AS compact_14_empty_fraction_second, + SECOND('241220153045.123abc') AS compact_12_fraction_text_second, + SECOND('241220153045.') AS compact_12_empty_fraction_second; + +-- TIME coercion stops after the valid clock prefix. +SELECT SECOND('12:') AS trailing_colon_second, + SECOND(':34') AS leading_colon_second, + SECOND('12:34:56-') AS trailing_dash_second, + SECOND('12:34:56..') AS trailing_dots_second; From ef433d99ab5e07c6371295ff40842a5820d54195 Mon Sep 17 00:00:00 2001 From: daviszhen Date: Thu, 30 Jul 2026 14:00:46 +0800 Subject: [PATCH 13/25] update --- pkg/sql/plan/function/func_unary.go | 11 ++++------- pkg/sql/plan/function/func_unary_test.go | 8 ++++---- .../cases/function/func_datetime_hour.result | 10 +++++++--- .../cases/function/func_datetime_hour.test | 6 +++++- .../cases/function/func_datetime_minute.result | 10 +++++++--- .../cases/function/func_datetime_minute.test | 6 +++++- .../cases/function/func_datetime_second.result | 10 +++++++--- .../cases/function/func_datetime_second.test | 6 +++++- 8 files changed, 44 insertions(+), 23 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index da0d9c4c6ed80..1b6f6cdd71e98 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4967,16 +4967,13 @@ func mysqlClockFieldsForExtract(str string) (uint64, uint8, uint8, bool) { } minuteText := str[minuteStart:pos] if len(minuteText) == 0 { - // MySQL interprets a trailing colon after a numeric field as a - // seconds-only value, for example "12:" as 00:00:12. + // MySQL ignores a trailing colon and applies its compact TIME coercion + // to the preceding digits: "12:" is 00:00:12, while "1234:" is + // 00:12:34. if len(hourText) == 0 || pos != len(str) { return 0, 0, 0, false } - second := mysqlClampedDigitsForExtract(hourText, 60) - if second >= 60 { - return 0, 0, 0, false - } - return 0, 0, uint8(second), true + return mysqlClockFieldsForExtract(hourText) } minute := mysqlClampedDigitsForExtract(minuteText, 60) diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 70b214beb5d0e..174f8e4db7861 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4311,7 +4311,7 @@ func TestStringTimeExtractPartialClockPrefix(t *testing.T) { proc := testutil.NewProcess(t) inputs := []FunctionTestInput{ NewFunctionTestInput(types.T_varchar.ToType(), []string{ - "12:", ":34", "12:34:56-", "12:34:56..", + "12:", ":34", "12:34:56-", "12:34:56..", "123:", "1234:", "12345:", "123456:", }, nil), } @@ -4320,9 +4320,9 @@ func TestStringTimeExtractPartialClockPrefix(t *testing.T) { expect FunctionTestResult fn fEvalFn }{ - {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{0, 0, 12, 12}, nil), StringToHour}, - {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{0, 34, 34, 34}, nil), StringToMinute}, - {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{12, 0, 56, 56}, nil), StringToSecond}, + {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{0, 0, 12, 12, 0, 0, 1, 12}, nil), StringToHour}, + {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{0, 34, 34, 34, 1, 12, 23, 34}, nil), StringToMinute}, + {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{12, 0, 56, 56, 23, 34, 45, 56}, nil), StringToSecond}, } { t.Run(tc.name, func(t *testing.T) { tcc := NewFunctionTestCase(proc, inputs, tc.expect, tc.fn) diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index a1716a57ed690..d2ba4a139622f 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -155,6 +155,10 @@ HOUR('241220153045.') AS compact_12_empty_fraction_hour; SELECT HOUR('12:') AS trailing_colon_hour, HOUR(':34') AS leading_colon_hour, HOUR('12:34:56-') AS trailing_dash_hour, -HOUR('12:34:56..') AS trailing_dots_hour; -➤ trailing_colon_hour[4,32,0] ¦ leading_colon_hour[4,32,0] ¦ trailing_dash_hour[4,32,0] ¦ trailing_dots_hour[4,32,0] 𝄀 -0 ¦ 0 ¦ 12 ¦ 12 +HOUR('12:34:56..') AS trailing_dots_hour, +HOUR('123:') AS compact_3_trailing_colon_hour, +HOUR('1234:') AS compact_4_trailing_colon_hour, +HOUR('12345:') AS compact_5_trailing_colon_hour, +HOUR('123456:') AS compact_6_trailing_colon_hour; +➤ trailing_colon_hour[4,32,0] ¦ leading_colon_hour[4,32,0] ¦ trailing_dash_hour[4,32,0] ¦ trailing_dots_hour[4,32,0] ¦ compact_3_trailing_colon_hour[4,32,0] ¦ compact_4_trailing_colon_hour[4,32,0] ¦ compact_5_trailing_colon_hour[4,32,0] ¦ compact_6_trailing_colon_hour[4,32,0] 𝄀 +0 ¦ 0 ¦ 12 ¦ 12 ¦ 0 ¦ 0 ¦ 1 ¦ 12 diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index 8a5f80345f9e9..cf7b013c1b245 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -134,4 +134,8 @@ SELECT HOUR('20241220153045.123abc') AS compact_14_fraction_text_hour, SELECT HOUR('12:') AS trailing_colon_hour, HOUR(':34') AS leading_colon_hour, HOUR('12:34:56-') AS trailing_dash_hour, - HOUR('12:34:56..') AS trailing_dots_hour; + HOUR('12:34:56..') AS trailing_dots_hour, + HOUR('123:') AS compact_3_trailing_colon_hour, + HOUR('1234:') AS compact_4_trailing_colon_hour, + HOUR('12345:') AS compact_5_trailing_colon_hour, + HOUR('123456:') AS compact_6_trailing_colon_hour; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index f894bc6685973..4a375a6b679ad 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -143,6 +143,10 @@ MINUTE('241220153045.') AS compact_12_empty_fraction_minute; SELECT MINUTE('12:') AS trailing_colon_minute, MINUTE(':34') AS leading_colon_minute, MINUTE('12:34:56-') AS trailing_dash_minute, -MINUTE('12:34:56..') AS trailing_dots_minute; -➤ trailing_colon_minute[-6,8,0] ¦ leading_colon_minute[-6,8,0] ¦ trailing_dash_minute[-6,8,0] ¦ trailing_dots_minute[-6,8,0] 𝄀 -0 ¦ 34 ¦ 34 ¦ 34 +MINUTE('12:34:56..') AS trailing_dots_minute, +MINUTE('123:') AS compact_3_trailing_colon_minute, +MINUTE('1234:') AS compact_4_trailing_colon_minute, +MINUTE('12345:') AS compact_5_trailing_colon_minute, +MINUTE('123456:') AS compact_6_trailing_colon_minute; +➤ trailing_colon_minute[-6,8,0] ¦ leading_colon_minute[-6,8,0] ¦ trailing_dash_minute[-6,8,0] ¦ trailing_dots_minute[-6,8,0] ¦ compact_3_trailing_colon_minute[-6,8,0] ¦ compact_4_trailing_colon_minute[-6,8,0] ¦ compact_5_trailing_colon_minute[-6,8,0] ¦ compact_6_trailing_colon_minute[-6,8,0] 𝄀 +0 ¦ 34 ¦ 34 ¦ 34 ¦ 1 ¦ 12 ¦ 23 ¦ 34 diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index 95f34e72b0419..625c7adcdc299 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -130,4 +130,8 @@ SELECT MINUTE('20241220153045.123abc') AS compact_14_fraction_text_minute, SELECT MINUTE('12:') AS trailing_colon_minute, MINUTE(':34') AS leading_colon_minute, MINUTE('12:34:56-') AS trailing_dash_minute, - MINUTE('12:34:56..') AS trailing_dots_minute; + MINUTE('12:34:56..') AS trailing_dots_minute, + MINUTE('123:') AS compact_3_trailing_colon_minute, + MINUTE('1234:') AS compact_4_trailing_colon_minute, + MINUTE('12345:') AS compact_5_trailing_colon_minute, + MINUTE('123456:') AS compact_6_trailing_colon_minute; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index a84e08bf576ab..eed6195383b6a 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -144,6 +144,10 @@ SECOND('241220153045.') AS compact_12_empty_fraction_second; SELECT SECOND('12:') AS trailing_colon_second, SECOND(':34') AS leading_colon_second, SECOND('12:34:56-') AS trailing_dash_second, -SECOND('12:34:56..') AS trailing_dots_second; -➤ trailing_colon_second[-6,8,0] ¦ leading_colon_second[-6,8,0] ¦ trailing_dash_second[-6,8,0] ¦ trailing_dots_second[-6,8,0] 𝄀 -12 ¦ 0 ¦ 56 ¦ 56 +SECOND('12:34:56..') AS trailing_dots_second, +SECOND('123:') AS compact_3_trailing_colon_second, +SECOND('1234:') AS compact_4_trailing_colon_second, +SECOND('12345:') AS compact_5_trailing_colon_second, +SECOND('123456:') AS compact_6_trailing_colon_second; +➤ trailing_colon_second[-6,8,0] ¦ leading_colon_second[-6,8,0] ¦ trailing_dash_second[-6,8,0] ¦ trailing_dots_second[-6,8,0] ¦ compact_3_trailing_colon_second[-6,8,0] ¦ compact_4_trailing_colon_second[-6,8,0] ¦ compact_5_trailing_colon_second[-6,8,0] ¦ compact_6_trailing_colon_second[-6,8,0] 𝄀 +12 ¦ 0 ¦ 56 ¦ 56 ¦ 23 ¦ 34 ¦ 45 ¦ 56 diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index e6b0949b82d0f..6e52ab7802ea9 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -130,4 +130,8 @@ SELECT SECOND('20241220153045.123abc') AS compact_14_fraction_text_second, SELECT SECOND('12:') AS trailing_colon_second, SECOND(':34') AS leading_colon_second, SECOND('12:34:56-') AS trailing_dash_second, - SECOND('12:34:56..') AS trailing_dots_second; + SECOND('12:34:56..') AS trailing_dots_second, + SECOND('123:') AS compact_3_trailing_colon_second, + SECOND('1234:') AS compact_4_trailing_colon_second, + SECOND('12345:') AS compact_5_trailing_colon_second, + SECOND('123456:') AS compact_6_trailing_colon_second; From fc6f5e81adb53604f1c752d4d35cef59afbc4bb9 Mon Sep 17 00:00:00 2001 From: daviszhen Date: Thu, 30 Jul 2026 14:45:44 +0800 Subject: [PATCH 14/25] update --- test/distributed/cases/function/func_datetime_hour.result | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index d2ba4a139622f..c3088f1a327d8 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -6,7 +6,7 @@ SELECT HOUR('2024-12-20 15:30:45') AS result2; 15 SELECT HOUR(NOW()) AS result3; ➤ result3[-6,8,0] 𝄀 -19 +14 SELECT HOUR(CAST('15:30:45' AS TIME)) AS time_cast; ➤ time_cast[4,32,0] 𝄀 15 From e08d583d54359e8f8a390c95dbaf4e6cbc8eccb7 Mon Sep 17 00:00:00 2001 From: daviszhen Date: Thu, 30 Jul 2026 16:13:30 +0800 Subject: [PATCH 15/25] update --- pkg/sql/plan/function/func_unary.go | 71 +++++++++++-------- pkg/sql/plan/function/func_unary_test.go | 42 +++++++++++ .../cases/function/func_datetime_hour.result | 9 ++- .../cases/function/func_datetime_hour.test | 7 ++ .../function/func_datetime_minute.result | 7 ++ .../cases/function/func_datetime_minute.test | 7 ++ .../function/func_datetime_second.result | 7 ++ .../cases/function/func_datetime_second.test | 7 ++ 8 files changed, 126 insertions(+), 31 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index 3fedc14736d1f..38eadc9e7ce4f 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -5041,29 +5041,37 @@ func mysqlClockFieldsForExtract(str string) (uint64, uint8, uint8, bool) { if len(str) == 0 { return 0, 0, 0, false } - firstColon := strings.IndexByte(str, ':') - if firstColon < 0 { - if !asciiDigits(str) { + + pos := 0 + for pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { + pos++ + } + hourText := str[:pos] + if pos == len(str) || str[pos] != ':' { + // A punctuation-only suffix terminates a valid compact TIME prefix. This + // keeps inputs such as "12-.abc" as 00:00:12 without turning a + // date-looking value such as "2024-12-20" into a compact TIME here. + if len(hourText) == 0 || !mysqlCompactTimePrefixBoundary(str[pos:]) { return 0, 0, 0, false } - switch len(str) { + switch len(hourText) { case 1, 2: - second := mysqlClampedDigitsForExtract(str, 60) + second := mysqlClampedDigitsForExtract(hourText, 60) if second >= 60 { return 0, 0, 0, false } return 0, 0, uint8(second), true case 3, 4: - minute := mysqlClampedDigitsForExtract(str[:len(str)-2], 60) - second := mysqlClampedDigitsForExtract(str[len(str)-2:], 60) + minute := mysqlClampedDigitsForExtract(hourText[:len(hourText)-2], 60) + second := mysqlClampedDigitsForExtract(hourText[len(hourText)-2:], 60) if minute >= 60 || second >= 60 { return 0, 0, 0, false } return 0, uint8(minute), uint8(second), true default: - hour := mysqlClampedDigitsForExtract(str[:len(str)-4], 839) - minute := mysqlClampedDigitsForExtract(str[len(str)-4:len(str)-2], 60) - second := mysqlClampedDigitsForExtract(str[len(str)-2:], 60) + hour := mysqlClampedDigitsForExtract(hourText[:len(hourText)-4], 839) + minute := mysqlClampedDigitsForExtract(hourText[len(hourText)-4:len(hourText)-2], 60) + second := mysqlClampedDigitsForExtract(hourText[len(hourText)-2:], 60) if minute >= 60 || second >= 60 { return 0, 0, 0, false } @@ -5071,12 +5079,7 @@ func mysqlClockFieldsForExtract(str string) (uint64, uint8, uint8, bool) { } } - hourText := str[:firstColon] - if len(hourText) > 0 && !asciiDigits(hourText) { - return 0, 0, 0, false - } - - pos := firstColon + 1 + pos++ minuteStart := pos for pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { pos++ @@ -5084,9 +5087,9 @@ func mysqlClockFieldsForExtract(str string) (uint64, uint8, uint8, bool) { minuteText := str[minuteStart:pos] if len(minuteText) == 0 { // MySQL ignores a trailing colon and applies its compact TIME coercion - // to the preceding digits: "12:" is 00:00:12, while "1234:" is - // 00:12:34. - if len(hourText) == 0 || pos != len(str) { + // to the preceding digits. This also stops at the first colon in + // "12::56", preserving 00:00:12 rather than rejecting the input. + if len(hourText) == 0 { return 0, 0, 0, false } return mysqlClockFieldsForExtract(hourText) @@ -5096,12 +5099,10 @@ func mysqlClockFieldsForExtract(str string) (uint64, uint8, uint8, bool) { if minute >= 60 { return 0, 0, 0, false } - if len(hourText) == 0 { - // A leading colon leaves the hour unspecified: ":34" is 00:34:00. - return 0, uint8(minute), 0, true + hour := uint64(0) + if len(hourText) > 0 { + hour = mysqlClampedDigitsForExtract(hourText, 839) } - - hour := mysqlClampedDigitsForExtract(hourText, 839) if pos == len(str) || str[pos] != ':' { // Stop at the valid HOUR:MINUTE prefix. mysqlTimePrefixForExtract has // already retained intentionally tolerated trailing text. @@ -5115,20 +5116,30 @@ func mysqlClockFieldsForExtract(str string) (uint64, uint8, uint8, bool) { } secondText := str[secondStart:pos] if len(secondText) == 0 { - return 0, 0, 0, false + // A trailing second separator is a valid prefix with omitted seconds. + return hour, uint8(minute), 0, true } second := mysqlClampedDigitsForExtract(secondText, 60) if second >= 60 { return 0, 0, 0, false } - // Consume a complete HOUR:MINUTE:SECOND prefix and ignore following - // punctuation/text. A third colon remains structural input and is invalid. - if pos < len(str) && str[pos] == ':' { - return 0, 0, 0, false - } + // Stop after a complete HOUR:MINUTE:SECOND prefix. Following punctuation, + // including a third colon, does not discard the parsed clock. return hour, uint8(minute), uint8(second), true } +func mysqlCompactTimePrefixBoundary(str string) bool { + if len(str) == 0 { + return true + } + for i := 0; i < len(str); i++ { + if str[i] >= '0' && str[i] <= '9' { + return false + } + } + return true +} + func mysqlClampedDigitsForExtract(str string, limit uint64) uint64 { if len(str) == 0 { return limit diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 4a9b617a0830e..c39bf9bcc6d63 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4332,6 +4332,48 @@ func TestStringTimeExtractPartialClockPrefix(t *testing.T) { } } +func TestStringTimeExtractMySQLPartialPrefixBoundaries(t *testing.T) { + for _, typ := range []types.T{types.T_varchar, types.T_char, types.T_text} { + t.Run(typ.String(), func(t *testing.T) { + proc := testutil.NewProcess(t) + input := NewFunctionTestInput(typ.ToType(), []string{ + ":34:56", "12:34:", "12::56", "12:34:56:", "12-.abc", + }, nil) + + for _, tc := range []struct { + name string + fn fEvalFn + expect FunctionTestResult + }{ + { + name: "hour", + fn: StringToHour, + expect: NewFunctionTestResult(types.T_uint32.ToType(), false, + []uint32{0, 12, 0, 12, 0}, nil), + }, + { + name: "minute", + fn: StringToMinute, + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, + []uint8{34, 34, 0, 34, 0}, nil), + }, + { + name: "second", + fn: StringToSecond, + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, + []uint8{56, 0, 12, 56, 12}, nil), + }, + } { + t.Run(tc.name, func(t *testing.T) { + ftc := NewFunctionTestCase(proc, []FunctionTestInput{input}, tc.expect, tc.fn) + success, info := ftc.Run() + require.True(t, success, info) + }) + } + }) + } +} + func TestStringTimeExtractWhitespace(t *testing.T) { for _, typ := range []types.T{types.T_char, types.T_varchar, types.T_text} { t.Run(typ.String(), func(t *testing.T) { diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index c3088f1a327d8..7443bdb00301c 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -6,7 +6,7 @@ SELECT HOUR('2024-12-20 15:30:45') AS result2; 15 SELECT HOUR(NOW()) AS result3; ➤ result3[-6,8,0] 𝄀 -14 +16 SELECT HOUR(CAST('15:30:45' AS TIME)) AS time_cast; ➤ time_cast[4,32,0] 𝄀 15 @@ -162,3 +162,10 @@ HOUR('12345:') AS compact_5_trailing_colon_hour, HOUR('123456:') AS compact_6_trailing_colon_hour; ➤ trailing_colon_hour[4,32,0] ¦ leading_colon_hour[4,32,0] ¦ trailing_dash_hour[4,32,0] ¦ trailing_dots_hour[4,32,0] ¦ compact_3_trailing_colon_hour[4,32,0] ¦ compact_4_trailing_colon_hour[4,32,0] ¦ compact_5_trailing_colon_hour[4,32,0] ¦ compact_6_trailing_colon_hour[4,32,0] 𝄀 0 ¦ 0 ¦ 12 ¦ 12 ¦ 0 ¦ 0 ¦ 1 ¦ 12 +SELECT HOUR(':34:56') AS leading_colon_with_seconds_hour, +HOUR('12:34:') AS trailing_second_colon_hour, +HOUR('12::56') AS empty_minute_hour, +HOUR('12:34:56:') AS fourth_field_colon_hour, +HOUR('12-.abc') AS compact_prefix_before_fraction_hour; +➤ leading_colon_with_seconds_hour[4,32,0] ¦ trailing_second_colon_hour[4,32,0] ¦ empty_minute_hour[4,32,0] ¦ fourth_field_colon_hour[4,32,0] ¦ compact_prefix_before_fraction_hour[4,32,0] 𝄀 +0 ¦ 12 ¦ 0 ¦ 12 ¦ 0 diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index cf7b013c1b245..51420c1589ee3 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -139,3 +139,10 @@ SELECT HOUR('12:') AS trailing_colon_hour, HOUR('1234:') AS compact_4_trailing_colon_hour, HOUR('12345:') AS compact_5_trailing_colon_hour, HOUR('123456:') AS compact_6_trailing_colon_hour; + +-- MySQL retains every valid field consumed before a partial TIME suffix. +SELECT HOUR(':34:56') AS leading_colon_with_seconds_hour, + HOUR('12:34:') AS trailing_second_colon_hour, + HOUR('12::56') AS empty_minute_hour, + HOUR('12:34:56:') AS fourth_field_colon_hour, + HOUR('12-.abc') AS compact_prefix_before_fraction_hour; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 4a375a6b679ad..80c5eda07ad36 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -150,3 +150,10 @@ MINUTE('12345:') AS compact_5_trailing_colon_minute, MINUTE('123456:') AS compact_6_trailing_colon_minute; ➤ trailing_colon_minute[-6,8,0] ¦ leading_colon_minute[-6,8,0] ¦ trailing_dash_minute[-6,8,0] ¦ trailing_dots_minute[-6,8,0] ¦ compact_3_trailing_colon_minute[-6,8,0] ¦ compact_4_trailing_colon_minute[-6,8,0] ¦ compact_5_trailing_colon_minute[-6,8,0] ¦ compact_6_trailing_colon_minute[-6,8,0] 𝄀 0 ¦ 34 ¦ 34 ¦ 34 ¦ 1 ¦ 12 ¦ 23 ¦ 34 +SELECT MINUTE(':34:56') AS leading_colon_with_seconds_minute, +MINUTE('12:34:') AS trailing_second_colon_minute, +MINUTE('12::56') AS empty_minute_minute, +MINUTE('12:34:56:') AS fourth_field_colon_minute, +MINUTE('12-.abc') AS compact_prefix_before_fraction_minute; +➤ leading_colon_with_seconds_minute[-6,8,0] ¦ trailing_second_colon_minute[-6,8,0] ¦ empty_minute_minute[-6,8,0] ¦ fourth_field_colon_minute[-6,8,0] ¦ compact_prefix_before_fraction_minute[-6,8,0] 𝄀 +34 ¦ 34 ¦ 0 ¦ 34 ¦ 0 diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index 625c7adcdc299..7c6706fc3aca8 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -135,3 +135,10 @@ SELECT MINUTE('12:') AS trailing_colon_minute, MINUTE('1234:') AS compact_4_trailing_colon_minute, MINUTE('12345:') AS compact_5_trailing_colon_minute, MINUTE('123456:') AS compact_6_trailing_colon_minute; + +-- MySQL retains every valid field consumed before a partial TIME suffix. +SELECT MINUTE(':34:56') AS leading_colon_with_seconds_minute, + MINUTE('12:34:') AS trailing_second_colon_minute, + MINUTE('12::56') AS empty_minute_minute, + MINUTE('12:34:56:') AS fourth_field_colon_minute, + MINUTE('12-.abc') AS compact_prefix_before_fraction_minute; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index eed6195383b6a..511bd0308de42 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -151,3 +151,10 @@ SECOND('12345:') AS compact_5_trailing_colon_second, SECOND('123456:') AS compact_6_trailing_colon_second; ➤ trailing_colon_second[-6,8,0] ¦ leading_colon_second[-6,8,0] ¦ trailing_dash_second[-6,8,0] ¦ trailing_dots_second[-6,8,0] ¦ compact_3_trailing_colon_second[-6,8,0] ¦ compact_4_trailing_colon_second[-6,8,0] ¦ compact_5_trailing_colon_second[-6,8,0] ¦ compact_6_trailing_colon_second[-6,8,0] 𝄀 12 ¦ 0 ¦ 56 ¦ 56 ¦ 23 ¦ 34 ¦ 45 ¦ 56 +SELECT SECOND(':34:56') AS leading_colon_with_seconds_second, +SECOND('12:34:') AS trailing_second_colon_second, +SECOND('12::56') AS empty_minute_second, +SECOND('12:34:56:') AS fourth_field_colon_second, +SECOND('12-.abc') AS compact_prefix_before_fraction_second; +➤ leading_colon_with_seconds_second[-6,8,0] ¦ trailing_second_colon_second[-6,8,0] ¦ empty_minute_second[-6,8,0] ¦ fourth_field_colon_second[-6,8,0] ¦ compact_prefix_before_fraction_second[-6,8,0] 𝄀 +56 ¦ 0 ¦ 12 ¦ 56 ¦ 12 diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 6e52ab7802ea9..312bdefbeefab 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -135,3 +135,10 @@ SELECT SECOND('12:') AS trailing_colon_second, SECOND('1234:') AS compact_4_trailing_colon_second, SECOND('12345:') AS compact_5_trailing_colon_second, SECOND('123456:') AS compact_6_trailing_colon_second; + +-- MySQL retains every valid field consumed before a partial TIME suffix. +SELECT SECOND(':34:56') AS leading_colon_with_seconds_second, + SECOND('12:34:') AS trailing_second_colon_second, + SECOND('12::56') AS empty_minute_second, + SECOND('12:34:56:') AS fourth_field_colon_second, + SECOND('12-.abc') AS compact_prefix_before_fraction_second; From 18b79060586e32d71eb707ce1be6d528e0dede0d Mon Sep 17 00:00:00 2001 From: daviszhen Date: Fri, 31 Jul 2026 15:30:06 +0800 Subject: [PATCH 16/25] update --- pkg/sql/plan/function/func_unary.go | 106 ++++++++++++++---- pkg/sql/plan/function/func_unary_test.go | 44 ++++++++ .../cases/function/func_datetime_hour.result | 21 ++++ .../cases/function/func_datetime_hour.test | 14 +++ .../function/func_datetime_minute.result | 21 ++++ .../cases/function/func_datetime_minute.test | 14 +++ .../function/func_datetime_second.result | 21 ++++ .../cases/function/func_datetime_second.test | 14 +++ 8 files changed, 235 insertions(+), 20 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index 38eadc9e7ce4f..8af63cfe5a50a 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4911,28 +4911,45 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { pos++ } hour, _, ok := mysqlVariableDigitsForExtract(str, &pos) - if !ok || pos >= len(str) || str[pos] != ':' { - return result - } - pos++ - minute, _, ok := mysqlVariableDigitsForExtract(str, &pos) if !ok { return result } + minute := uint64(0) second := uint64(0) - // MySQL accepts a DATETIME whose clock ends after the minute and supplies - // the omitted seconds as zero. if pos < len(str) && str[pos] == ':' { pos++ - if pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { - second, _, ok = mysqlVariableDigitsForExtract(str, &pos) + if pos < len(str) && str[pos] == ':' { + // In a separated DATETIME, an empty minute field leaves the + // following numeric field as the minute. The already consumed hour + // remains intact (for example, "... 12::56" is 12:56:00). + pos++ + if pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { + minute, _, ok = mysqlVariableDigitsForExtract(str, &pos) + if !ok { + return result + } + } + } else if pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { + minute, _, ok = mysqlVariableDigitsForExtract(str, &pos) if !ok { return result } + // MySQL accepts a DATETIME whose clock ends after the minute and + // supplies the omitted seconds as zero. + if pos < len(str) && str[pos] == ':' { + pos++ + if pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { + second, _, ok = mysqlVariableDigitsForExtract(str, &pos) + if !ok { + return result + } + } + } } - } else if pos < len(str) { - return result } + // A complete date followed by an hour or a trailing field separator is a + // valid DATETIME prefix. Do not fall back to compact TIME coercion after + // this branch has consumed the date and hour. if !mysqlDatetimeDateForExtract(year, month, day) || hour > 23 || minute > 59 || second > 59 { return result } @@ -5020,7 +5037,11 @@ func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { } } - hour, minute, second, ok := mysqlClockFieldsForExtract(prefix) + parseClock := mysqlClockFieldsForExtract + if hasDay { + parseClock = mysqlDayClockFieldsForExtract + } + hour, minute, second, ok := parseClock(prefix) if !ok { return 0, 0, 0, false } @@ -5051,7 +5072,7 @@ func mysqlClockFieldsForExtract(str string) (uint64, uint8, uint8, bool) { // A punctuation-only suffix terminates a valid compact TIME prefix. This // keeps inputs such as "12-.abc" as 00:00:12 without turning a // date-looking value such as "2024-12-20" into a compact TIME here. - if len(hourText) == 0 || !mysqlCompactTimePrefixBoundary(str[pos:]) { + if len(hourText) == 0 || !mysqlCompactTimePrefixBoundary(hourText, str[pos:]) { return 0, 0, 0, false } switch len(hourText) { @@ -5128,16 +5149,61 @@ func mysqlClockFieldsForExtract(str string) (uint64, uint8, uint8, bool) { return hour, uint8(minute), uint8(second), true } -func mysqlCompactTimePrefixBoundary(str string) bool { - if len(str) == 0 { +func mysqlCompactTimePrefixBoundary(prefix, suffix string) bool { + // Once compact TIME has consumed its leading digits, later punctuation and + // digits cannot reinterpret that field. Preserve the sole exception for a + // separated date shape, which is handled by the date branch (and + // therefore still validates its calendar fields). + if len(prefix) != 4 || len(suffix) < 3 || !mysqlDateSeparatorForExtract(suffix[0]) { return true } - for i := 0; i < len(str); i++ { - if str[i] >= '0' && str[i] <= '9' { - return false - } + pos := 1 + _, _, ok := mysqlVariableDigitsForExtract(suffix, &pos) + return !ok || pos == len(suffix) || suffix[pos] != suffix[0] +} + +func mysqlDayClockFieldsForExtract(str string) (uint64, uint8, uint8, bool) { + pos := 0 + for pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { + pos++ } - return true + if pos == 0 { + return 0, 0, 0, false + } + hour := mysqlClampedDigitsForExtract(str[:pos], 839) + if pos == len(str) || str[pos] != ':' { + return hour, 0, 0, true + } + + pos++ + minuteStart := pos + for pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { + pos++ + } + if minuteStart == pos { + return hour, 0, 0, true + } + minute := mysqlClampedDigitsForExtract(str[minuteStart:pos], 60) + if minute >= 60 { + return 0, 0, 0, false + } + if pos == len(str) || str[pos] != ':' { + return hour, uint8(minute), 0, true + } + + pos++ + secondStart := pos + for pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { + pos++ + } + if secondStart == pos { + return hour, uint8(minute), 0, true + } + second := mysqlClampedDigitsForExtract(str[secondStart:pos], 60) + if second >= 60 { + return 0, 0, 0, false + } + return hour, uint8(minute), uint8(second), true } func mysqlClampedDigitsForExtract(str string, limit uint64) uint64 { diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index c39bf9bcc6d63..b21f3d6d91227 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4374,6 +4374,50 @@ func TestStringTimeExtractMySQLPartialPrefixBoundaries(t *testing.T) { } } +func TestStringTimeExtractContextAwarePrefixBoundaries(t *testing.T) { + for _, typ := range []types.T{types.T_varchar, types.T_char, types.T_text} { + t.Run(typ.String(), func(t *testing.T) { + proc := testutil.NewProcess(t) + input := NewFunctionTestInput(typ.ToType(), []string{ + "12-34", "1234-56", + "1 02", "1 02:", "1 02-34", + "2024-12-20 12", "2024-12-20 12:", "2024-12-20 12::56", + }, nil) + + for _, tc := range []struct { + name string + fn fEvalFn + expect FunctionTestResult + }{ + { + name: "hour", + fn: StringToHour, + expect: NewFunctionTestResult(types.T_uint32.ToType(), false, + []uint32{0, 0, 26, 26, 26, 12, 12, 12}, nil), + }, + { + name: "minute", + fn: StringToMinute, + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, + []uint8{0, 12, 0, 0, 0, 0, 0, 56}, nil), + }, + { + name: "second", + fn: StringToSecond, + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, + []uint8{12, 34, 0, 0, 0, 0, 0, 0}, nil), + }, + } { + t.Run(tc.name, func(t *testing.T) { + ftc := NewFunctionTestCase(proc, []FunctionTestInput{input}, tc.expect, tc.fn) + success, info := ftc.Run() + require.True(t, success, info) + }) + } + }) + } +} + func TestStringTimeExtractWhitespace(t *testing.T) { for _, typ := range []types.T{types.T_char, types.T_varchar, types.T_text} { t.Run(typ.String(), func(t *testing.T) { diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index 7443bdb00301c..f1c192329e2dc 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -169,3 +169,24 @@ HOUR('12:34:56:') AS fourth_field_colon_hour, HOUR('12-.abc') AS compact_prefix_before_fraction_hour; ➤ leading_colon_with_seconds_hour[4,32,0] ¦ trailing_second_colon_hour[4,32,0] ¦ empty_minute_hour[4,32,0] ¦ fourth_field_colon_hour[4,32,0] ¦ compact_prefix_before_fraction_hour[4,32,0] 𝄀 0 ¦ 12 ¦ 0 ¦ 12 ¦ 0 +CREATE TABLE time_extract_prefix_boundaries(v VARCHAR(32), c CHAR(32), t TEXT); +INSERT INTO time_extract_prefix_boundaries VALUES +('12-34', '12-34', '12-34'), +('1234-56', '1234-56', '1234-56'), +('1 02', '1 02', '1 02'), +('1 02:', '1 02:', '1 02:'), +('1 02-34', '1 02-34', '1 02-34'), +('2024-12-20 12', '2024-12-20 12', '2024-12-20 12'), +('2024-12-20 12:', '2024-12-20 12:', '2024-12-20 12:'), +('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'); +SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_prefix_boundaries; +➤ HOUR(v)[4,32,0] ¦ HOUR(c)[4,32,0] ¦ HOUR(t)[4,32,0] 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 𝄀 +26 ¦ 26 ¦ 26 𝄀 +26 ¦ 26 ¦ 26 𝄀 +26 ¦ 26 ¦ 26 𝄀 +12 ¦ 12 ¦ 12 𝄀 +12 ¦ 12 ¦ 12 𝄀 +12 ¦ 12 ¦ 12 +DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index 51420c1589ee3..0acccdebb4d1f 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -146,3 +146,17 @@ SELECT HOUR(':34:56') AS leading_colon_with_seconds_hour, HOUR('12::56') AS empty_minute_hour, HOUR('12:34:56:') AS fourth_field_colon_hour, HOUR('12-.abc') AS compact_prefix_before_fraction_hour; + +-- Context-aware prefix consumption must agree across all string types. +CREATE TABLE time_extract_prefix_boundaries(v VARCHAR(32), c CHAR(32), t TEXT); +INSERT INTO time_extract_prefix_boundaries VALUES + ('12-34', '12-34', '12-34'), + ('1234-56', '1234-56', '1234-56'), + ('1 02', '1 02', '1 02'), + ('1 02:', '1 02:', '1 02:'), + ('1 02-34', '1 02-34', '1 02-34'), + ('2024-12-20 12', '2024-12-20 12', '2024-12-20 12'), + ('2024-12-20 12:', '2024-12-20 12:', '2024-12-20 12:'), + ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'); +SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_prefix_boundaries; +DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 80c5eda07ad36..419ad6aa4e837 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -157,3 +157,24 @@ MINUTE('12:34:56:') AS fourth_field_colon_minute, MINUTE('12-.abc') AS compact_prefix_before_fraction_minute; ➤ leading_colon_with_seconds_minute[-6,8,0] ¦ trailing_second_colon_minute[-6,8,0] ¦ empty_minute_minute[-6,8,0] ¦ fourth_field_colon_minute[-6,8,0] ¦ compact_prefix_before_fraction_minute[-6,8,0] 𝄀 34 ¦ 34 ¦ 0 ¦ 34 ¦ 0 +CREATE TABLE time_extract_prefix_boundaries(v VARCHAR(32), c CHAR(32), t TEXT); +INSERT INTO time_extract_prefix_boundaries VALUES +('12-34', '12-34', '12-34'), +('1234-56', '1234-56', '1234-56'), +('1 02', '1 02', '1 02'), +('1 02:', '1 02:', '1 02:'), +('1 02-34', '1 02-34', '1 02-34'), +('2024-12-20 12', '2024-12-20 12', '2024-12-20 12'), +('2024-12-20 12:', '2024-12-20 12:', '2024-12-20 12:'), +('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'); +SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_prefix_boundaries; +➤ MINUTE(v)[-6,8,0] ¦ MINUTE(c)[-6,8,0] ¦ MINUTE(t)[-6,8,0] 𝄀 +0 ¦ 0 ¦ 0 𝄀 +12 ¦ 12 ¦ 12 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 𝄀 +56 ¦ 56 ¦ 56 +DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index 7c6706fc3aca8..50ab3ea075cd1 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -142,3 +142,17 @@ SELECT MINUTE(':34:56') AS leading_colon_with_seconds_minute, MINUTE('12::56') AS empty_minute_minute, MINUTE('12:34:56:') AS fourth_field_colon_minute, MINUTE('12-.abc') AS compact_prefix_before_fraction_minute; + +-- Context-aware prefix consumption must agree across all string types. +CREATE TABLE time_extract_prefix_boundaries(v VARCHAR(32), c CHAR(32), t TEXT); +INSERT INTO time_extract_prefix_boundaries VALUES + ('12-34', '12-34', '12-34'), + ('1234-56', '1234-56', '1234-56'), + ('1 02', '1 02', '1 02'), + ('1 02:', '1 02:', '1 02:'), + ('1 02-34', '1 02-34', '1 02-34'), + ('2024-12-20 12', '2024-12-20 12', '2024-12-20 12'), + ('2024-12-20 12:', '2024-12-20 12:', '2024-12-20 12:'), + ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'); +SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_prefix_boundaries; +DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index 511bd0308de42..366a6ae8f2209 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -158,3 +158,24 @@ SECOND('12:34:56:') AS fourth_field_colon_second, SECOND('12-.abc') AS compact_prefix_before_fraction_second; ➤ leading_colon_with_seconds_second[-6,8,0] ¦ trailing_second_colon_second[-6,8,0] ¦ empty_minute_second[-6,8,0] ¦ fourth_field_colon_second[-6,8,0] ¦ compact_prefix_before_fraction_second[-6,8,0] 𝄀 56 ¦ 0 ¦ 12 ¦ 56 ¦ 12 +CREATE TABLE time_extract_prefix_boundaries(v VARCHAR(32), c CHAR(32), t TEXT); +INSERT INTO time_extract_prefix_boundaries VALUES +('12-34', '12-34', '12-34'), +('1234-56', '1234-56', '1234-56'), +('1 02', '1 02', '1 02'), +('1 02:', '1 02:', '1 02:'), +('1 02-34', '1 02-34', '1 02-34'), +('2024-12-20 12', '2024-12-20 12', '2024-12-20 12'), +('2024-12-20 12:', '2024-12-20 12:', '2024-12-20 12:'), +('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'); +SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_prefix_boundaries; +➤ SECOND(v)[-6,8,0] ¦ SECOND(c)[-6,8,0] ¦ SECOND(t)[-6,8,0] 𝄀 +12 ¦ 12 ¦ 12 𝄀 +34 ¦ 34 ¦ 34 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 +DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 312bdefbeefab..6378e6c5829f8 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -142,3 +142,17 @@ SELECT SECOND(':34:56') AS leading_colon_with_seconds_second, SECOND('12::56') AS empty_minute_second, SECOND('12:34:56:') AS fourth_field_colon_second, SECOND('12-.abc') AS compact_prefix_before_fraction_second; + +-- Context-aware prefix consumption must agree across all string types. +CREATE TABLE time_extract_prefix_boundaries(v VARCHAR(32), c CHAR(32), t TEXT); +INSERT INTO time_extract_prefix_boundaries VALUES + ('12-34', '12-34', '12-34'), + ('1234-56', '1234-56', '1234-56'), + ('1 02', '1 02', '1 02'), + ('1 02:', '1 02:', '1 02:'), + ('1 02-34', '1 02-34', '1 02-34'), + ('2024-12-20 12', '2024-12-20 12', '2024-12-20 12'), + ('2024-12-20 12:', '2024-12-20 12:', '2024-12-20 12:'), + ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'); +SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_prefix_boundaries; +DROP TABLE time_extract_prefix_boundaries; From befd09448e8c798740daaab0a682a218c3339c44 Mon Sep 17 00:00:00 2001 From: daviszhen Date: Fri, 31 Jul 2026 15:31:07 +0800 Subject: [PATCH 17/25] update --- test/distributed/cases/function/func_datetime_hour.result | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index f1c192329e2dc..395d5fa2e5a85 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -6,7 +6,7 @@ SELECT HOUR('2024-12-20 15:30:45') AS result2; 15 SELECT HOUR(NOW()) AS result3; ➤ result3[-6,8,0] 𝄀 -16 +15 SELECT HOUR(CAST('15:30:45' AS TIME)) AS time_cast; ➤ time_cast[4,32,0] 𝄀 15 From 4899333d6be64d26d417e92c3f2e432e774b6a0b Mon Sep 17 00:00:00 2001 From: daviszhen Date: Fri, 31 Jul 2026 18:14:57 +0800 Subject: [PATCH 18/25] fix: consume MySQL datetime prefixes for time extracts --- pkg/sql/plan/function/func_unary.go | 42 +++++++++---------- pkg/sql/plan/function/func_unary_test.go | 17 ++++---- .../cases/function/func_datetime_hour.result | 10 ++++- .../cases/function/func_datetime_hour.test | 5 ++- .../function/func_datetime_minute.result | 10 ++++- .../cases/function/func_datetime_minute.test | 5 ++- .../function/func_datetime_second.result | 10 ++++- .../cases/function/func_datetime_second.test | 5 ++- 8 files changed, 66 insertions(+), 38 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index 1a361af68710d..7497959980746 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4835,11 +4835,10 @@ func mysqlTimeStringToClockForExtract(str string) (uint64, uint8, uint8, bool) { return hour, minute, second, true } - // MySQL coerces a complete date-only string through its leading year field: - // "2024-12-20" becomes the compact TIME 00:20:24. An ISO-T suffix does - // not make it a DATETIME for this TIME coercion, so it follows the same - // date-prefix rule. Do not apply this to other malformed date-looking input, - // or invalid clocks would acquire a value. + // MySQL consumes a complete date prefix through its leading year field: + // "2024-12-20" and "2024-12-20foo" both become the compact TIME + // 00:20:24. An ISO-T suffix does not make it a DATETIME for this TIME + // coercion, so it follows the same date-prefix rule. if mysqlDatePrefixTimeStringForExtract(str) { if hour, minute, second, ok := mysqlTimePrefixClockForExtract(str[:4]); ok { return hour, minute, second, true @@ -4854,8 +4853,7 @@ func mysqlDateOnlyStringForExtract(str string) bool { } func mysqlDatePrefixTimeStringForExtract(str string) bool { - return mysqlDateOnlyStringForExtract(str) || - (len(str) > 10 && str[10] == 'T' && mysqlDateOnlyStringForExtract(str[:10])) + return len(str) >= 10 && mysqlDateOnlyStringForExtract(str[:10]) } func mysqlLeadingDigitsExceedUint32(str string) bool { @@ -4916,28 +4914,26 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { } minute := uint64(0) second := uint64(0) - if pos < len(str) && str[pos] == ':' { + if pos < len(str) && mysqlClockFieldSeparatorForExtract(str[pos]) { pos++ - if pos < len(str) && str[pos] == ':' { - // In a separated DATETIME, an empty minute field leaves the - // following numeric field as the minute. The already consumed hour - // remains intact (for example, "... 12::56" is 12:56:00). + for pos < len(str) && mysqlClockFieldSeparatorForExtract(str[pos]) { pos++ - if pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { - minute, _, ok = mysqlVariableDigitsForExtract(str, &pos) - if !ok { - return result - } - } - } else if pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { + } + if pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { minute, _, ok = mysqlVariableDigitsForExtract(str, &pos) if !ok { return result } // MySQL accepts a DATETIME whose clock ends after the minute and - // supplies the omitted seconds as zero. - if pos < len(str) && str[pos] == ':' { + // supplies the omitted seconds as zero. Once a second-field + // separator is consumed, repeated separators still retain the + // previously parsed hour and minute (for example, + // "... 12:34::56"). + if pos < len(str) && mysqlClockFieldSeparatorForExtract(str[pos]) { pos++ + for pos < len(str) && mysqlClockFieldSeparatorForExtract(str[pos]) { + pos++ + } if pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { second, _, ok = mysqlVariableDigitsForExtract(str, &pos) if !ok { @@ -4999,6 +4995,10 @@ func mysqlDateSeparatorForExtract(c byte) bool { return c == '-' || c == '/' || c == ':' } +func mysqlClockFieldSeparatorForExtract(c byte) bool { + return c == ':' || c == '-' +} + func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { prefix := mysqlTimePrefixForExtract(str) if len(prefix) == 0 { diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 483703b4b566e..4c8391f42483c 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4185,25 +4185,25 @@ func TestStringTimeExtract(t *testing.T) { expect: NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{12, 272, 272, 51, 12, 15, 15, 15, 0, 15, 15, 0, 0, 0, 0, 0, 51, 12, 272, 272, 51, 838, 838, 838, 0, 0, 0, 12}, - []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true, + []bool{false, false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, false, false, false, false, false, false, false, false, true, true, false, false}), fn: StringToHour, }, { name: "minute", expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{30, 59, 59, 4, 30, 30, 30, 30, 20, 30, 30, 0, 0, 0, 0, 0, + []uint8{30, 59, 59, 4, 30, 30, 30, 30, 20, 30, 30, 20, 0, 0, 0, 0, 4, 30, 59, 59, 4, 59, 59, 59, 0, 0, 20, 34}, - []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true, + []bool{false, false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, false, false, false, false, false, false, false, false, true, true, false, false}), fn: StringToMinute, }, { name: "second", expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{45, 59, 59, 5, 45, 45, 45, 45, 24, 45, 45, 0, 0, 0, 0, 0, + []uint8{45, 59, 59, 5, 45, 45, 45, 45, 24, 45, 45, 24, 0, 0, 0, 0, 5, 45, 59, 59, 5, 59, 59, 59, 0, 0, 24, 56}, - []bool{false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, true, + []bool{false, false, false, false, false, false, false, false, false, false, false, false, true, true, true, true, false, false, false, false, false, false, false, false, true, true, false, false}), fn: StringToSecond, }, @@ -4382,6 +4382,7 @@ func TestStringTimeExtractContextAwarePrefixBoundaries(t *testing.T) { "12-34", "1234-56", "1 02", "1 02:", "1 02-34", "2024-12-20 12", "2024-12-20 12:", "2024-12-20 12::56", + "2024-12-20foo", "2024-12-20 12:34::56", "2024-12-20 12-34", }, nil) for _, tc := range []struct { @@ -4393,19 +4394,19 @@ func TestStringTimeExtractContextAwarePrefixBoundaries(t *testing.T) { name: "hour", fn: StringToHour, expect: NewFunctionTestResult(types.T_uint32.ToType(), false, - []uint32{0, 0, 26, 26, 26, 12, 12, 12}, nil), + []uint32{0, 0, 26, 26, 26, 12, 12, 12, 0, 12, 12}, nil), }, { name: "minute", fn: StringToMinute, expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{0, 12, 0, 0, 0, 0, 0, 56}, nil), + []uint8{0, 12, 0, 0, 0, 0, 0, 56, 20, 34, 34}, nil), }, { name: "second", fn: StringToSecond, expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{12, 34, 0, 0, 0, 0, 0, 0}, nil), + []uint8{12, 34, 0, 0, 0, 0, 0, 0, 24, 56, 0}, nil), }, } { t.Run(tc.name, func(t *testing.T) { diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index 395d5fa2e5a85..398101a563d99 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -87,7 +87,7 @@ HOUR('15:30:45abc') AS trailing_time_hour, HOUR('2024-12-20 15:30:45abc') AS trailing_datetime_hour, HOUR('2024-12-20foo') AS malformed_datetime_hour; ➤ compact_datetime_hour[4,32,0] ¦ trailing_time_hour[4,32,0] ¦ trailing_datetime_hour[4,32,0] ¦ malformed_datetime_hour[4,32,0] 𝄀 -15 ¦ 15 ¦ 15 ¦ null +15 ¦ 15 ¦ 15 ¦ 0 SELECT HOUR('12:34:56.789012') AS fractional_time_hour, HOUR('2 03:04:05.9') AS day_fractional_time_hour, HOUR('-12:34:56.789012') AS negative_fractional_time_hour, @@ -178,7 +178,10 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('1 02-34', '1 02-34', '1 02-34'), ('2024-12-20 12', '2024-12-20 12', '2024-12-20 12'), ('2024-12-20 12:', '2024-12-20 12:', '2024-12-20 12:'), -('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'); +('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'), +('2024-12-20foo', '2024-12-20foo', '2024-12-20foo'), +('2024-12-20 12:34::56', '2024-12-20 12:34::56', '2024-12-20 12:34::56'), +('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'); SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_prefix_boundaries; ➤ HOUR(v)[4,32,0] ¦ HOUR(c)[4,32,0] ¦ HOUR(t)[4,32,0] 𝄀 0 ¦ 0 ¦ 0 𝄀 @@ -189,4 +192,7 @@ SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_prefix_boundaries; 12 ¦ 12 ¦ 12 𝄀 12 ¦ 12 ¦ 12 𝄀 12 ¦ 12 ¦ 12 +0 ¦ 0 ¦ 0 𝄀 +12 ¦ 12 ¦ 12 𝄀 +12 ¦ 12 ¦ 12 DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index 0acccdebb4d1f..41f8580daa1bf 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -157,6 +157,9 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('1 02-34', '1 02-34', '1 02-34'), ('2024-12-20 12', '2024-12-20 12', '2024-12-20 12'), ('2024-12-20 12:', '2024-12-20 12:', '2024-12-20 12:'), - ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'); + ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'), + ('2024-12-20foo', '2024-12-20foo', '2024-12-20foo'), + ('2024-12-20 12:34::56', '2024-12-20 12:34::56', '2024-12-20 12:34::56'), + ('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'); SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_prefix_boundaries; DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 419ad6aa4e837..9d856ed29cbb3 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -75,7 +75,7 @@ MINUTE('15:30:45abc') AS trailing_time_minute, MINUTE('2024-12-20 15:30:45abc') AS trailing_datetime_minute, MINUTE('2024-12-20foo') AS malformed_datetime_minute; ➤ compact_datetime_minute[-6,8,0] ¦ trailing_time_minute[-6,8,0] ¦ trailing_datetime_minute[-6,8,0] ¦ malformed_datetime_minute[-6,8,0] 𝄀 -30 ¦ 30 ¦ 30 ¦ null +30 ¦ 30 ¦ 30 ¦ 20 SELECT MINUTE('12:34:56.789012') AS fractional_time_minute, MINUTE('2 03:04:05.9') AS day_fractional_time_minute, MINUTE('-12:34:56.789012') AS negative_fractional_time_minute, @@ -166,7 +166,10 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('1 02-34', '1 02-34', '1 02-34'), ('2024-12-20 12', '2024-12-20 12', '2024-12-20 12'), ('2024-12-20 12:', '2024-12-20 12:', '2024-12-20 12:'), -('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'); +('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'), +('2024-12-20foo', '2024-12-20foo', '2024-12-20foo'), +('2024-12-20 12:34::56', '2024-12-20 12:34::56', '2024-12-20 12:34::56'), +('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'); SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_prefix_boundaries; ➤ MINUTE(v)[-6,8,0] ¦ MINUTE(c)[-6,8,0] ¦ MINUTE(t)[-6,8,0] 𝄀 0 ¦ 0 ¦ 0 𝄀 @@ -177,4 +180,7 @@ SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_prefix_boundaries; 0 ¦ 0 ¦ 0 𝄀 0 ¦ 0 ¦ 0 𝄀 56 ¦ 56 ¦ 56 +20 ¦ 20 ¦ 20 𝄀 +34 ¦ 34 ¦ 34 𝄀 +34 ¦ 34 ¦ 34 DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index 50ab3ea075cd1..48cfe3e352e7e 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -153,6 +153,9 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('1 02-34', '1 02-34', '1 02-34'), ('2024-12-20 12', '2024-12-20 12', '2024-12-20 12'), ('2024-12-20 12:', '2024-12-20 12:', '2024-12-20 12:'), - ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'); + ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'), + ('2024-12-20foo', '2024-12-20foo', '2024-12-20foo'), + ('2024-12-20 12:34::56', '2024-12-20 12:34::56', '2024-12-20 12:34::56'), + ('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'); SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_prefix_boundaries; DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index 366a6ae8f2209..febd94d2c7885 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -76,7 +76,7 @@ SECOND('15:30:45abc') AS trailing_time_second, SECOND('2024-12-20 15:30:45abc') AS trailing_datetime_second, SECOND('2024-12-20foo') AS malformed_datetime_second; ➤ compact_datetime_second[-6,8,0] ¦ trailing_time_second[-6,8,0] ¦ trailing_datetime_second[-6,8,0] ¦ malformed_datetime_second[-6,8,0] 𝄀 -45 ¦ 45 ¦ 45 ¦ null +45 ¦ 45 ¦ 45 ¦ 24 SELECT SECOND('12:34:56.789012') AS fractional_time_second, SECOND('2 03:04:05.9') AS day_fractional_time_second, SECOND('-12:34:56.789012') AS negative_fractional_time_second, @@ -167,7 +167,10 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('1 02-34', '1 02-34', '1 02-34'), ('2024-12-20 12', '2024-12-20 12', '2024-12-20 12'), ('2024-12-20 12:', '2024-12-20 12:', '2024-12-20 12:'), -('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'); +('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'), +('2024-12-20foo', '2024-12-20foo', '2024-12-20foo'), +('2024-12-20 12:34::56', '2024-12-20 12:34::56', '2024-12-20 12:34::56'), +('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'); SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_prefix_boundaries; ➤ SECOND(v)[-6,8,0] ¦ SECOND(c)[-6,8,0] ¦ SECOND(t)[-6,8,0] 𝄀 12 ¦ 12 ¦ 12 𝄀 @@ -178,4 +181,7 @@ SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_prefix_boundaries; 0 ¦ 0 ¦ 0 𝄀 0 ¦ 0 ¦ 0 𝄀 0 ¦ 0 ¦ 0 +24 ¦ 24 ¦ 24 𝄀 +56 ¦ 56 ¦ 56 𝄀 +0 ¦ 0 ¦ 0 DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 6378e6c5829f8..772ebe0cdec40 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -153,6 +153,9 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('1 02-34', '1 02-34', '1 02-34'), ('2024-12-20 12', '2024-12-20 12', '2024-12-20 12'), ('2024-12-20 12:', '2024-12-20 12:', '2024-12-20 12:'), - ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'); + ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'), + ('2024-12-20foo', '2024-12-20foo', '2024-12-20foo'), + ('2024-12-20 12:34::56', '2024-12-20 12:34::56', '2024-12-20 12:34::56'), + ('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'); SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_prefix_boundaries; DROP TABLE time_extract_prefix_boundaries; From f1e28d3e3c118d2e2a53d040db0d7cf4d2cf3a77 Mon Sep 17 00:00:00 2001 From: daviszhen Date: Fri, 31 Jul 2026 18:26:42 +0800 Subject: [PATCH 19/25] update --- test/distributed/cases/function/func_datetime_hour.result | 4 ++-- test/distributed/cases/function/func_datetime_minute.result | 2 +- test/distributed/cases/function/func_datetime_second.result | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index 398101a563d99..84efb49870dbb 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -6,7 +6,7 @@ SELECT HOUR('2024-12-20 15:30:45') AS result2; 15 SELECT HOUR(NOW()) AS result3; ➤ result3[-6,8,0] 𝄀 -15 +18 SELECT HOUR(CAST('15:30:45' AS TIME)) AS time_cast; ➤ time_cast[4,32,0] 𝄀 15 @@ -191,7 +191,7 @@ SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_prefix_boundaries; 26 ¦ 26 ¦ 26 𝄀 12 ¦ 12 ¦ 12 𝄀 12 ¦ 12 ¦ 12 𝄀 -12 ¦ 12 ¦ 12 +12 ¦ 12 ¦ 12 𝄀 0 ¦ 0 ¦ 0 𝄀 12 ¦ 12 ¦ 12 𝄀 12 ¦ 12 ¦ 12 diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 9d856ed29cbb3..d75e33531abd2 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -179,7 +179,7 @@ SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_prefix_boundaries; 0 ¦ 0 ¦ 0 𝄀 0 ¦ 0 ¦ 0 𝄀 0 ¦ 0 ¦ 0 𝄀 -56 ¦ 56 ¦ 56 +56 ¦ 56 ¦ 56 𝄀 20 ¦ 20 ¦ 20 𝄀 34 ¦ 34 ¦ 34 𝄀 34 ¦ 34 ¦ 34 diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index febd94d2c7885..eda158b550eac 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -180,7 +180,7 @@ SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_prefix_boundaries; 0 ¦ 0 ¦ 0 𝄀 0 ¦ 0 ¦ 0 𝄀 0 ¦ 0 ¦ 0 𝄀 -0 ¦ 0 ¦ 0 +0 ¦ 0 ¦ 0 𝄀 24 ¦ 24 ¦ 24 𝄀 56 ¦ 56 ¦ 56 𝄀 0 ¦ 0 ¦ 0 From 60388fcb20024d7a04b7e7c8b10f882ab5b76254 Mon Sep 17 00:00:00 2001 From: daviszhen Date: Fri, 31 Jul 2026 19:34:59 +0800 Subject: [PATCH 20/25] update --- pkg/sql/plan/function/func_unary.go | 60 +++++++++++++------ pkg/sql/plan/function/func_unary_test.go | 56 +++++++++-------- .../cases/function/func_datetime_hour.result | 22 +++++-- .../cases/function/func_datetime_hour.test | 12 +++- .../function/func_datetime_minute.result | 20 +++++-- .../cases/function/func_datetime_minute.test | 12 +++- .../function/func_datetime_second.result | 22 +++++-- .../cases/function/func_datetime_second.test | 12 +++- 8 files changed, 154 insertions(+), 62 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index 7497959980746..d2f73c25d8c5e 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4886,26 +4886,25 @@ type timeExtractParseResult struct { func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { pos := 0 year, yearDigits, ok := mysqlVariableDigitsForExtract(str, &pos) - if !ok || yearDigits > 4 || pos >= len(str) || !mysqlDateSeparatorForExtract(str[pos]) { + if !ok || yearDigits > 4 || pos >= len(str) || !mysqlDatetimePunctuationForExtract(str[pos]) { return timeExtractParseResult{} } if yearDigits == 2 { year = uint64(adjustYear(int(year))) } - separator := str[pos] pos++ month, _, ok := mysqlVariableDigitsForExtract(str, &pos) - if !ok || pos >= len(str) || str[pos] != separator { + if !ok || pos >= len(str) || !mysqlDatetimePunctuationForExtract(str[pos]) { return timeExtractParseResult{} } pos++ day, _, ok := mysqlVariableDigitsForExtract(str, &pos) - if !ok || pos >= len(str) || str[pos] != ' ' { + if !ok || pos >= len(str) || !mysqlWhitespaceForExtract(str[pos]) { // A complete date without a clock is still handled by the TIME path. return timeExtractParseResult{} } result := timeExtractParseResult{matched: true} - for pos < len(str) && str[pos] == ' ' { + for pos < len(str) && mysqlWhitespaceForExtract(str[pos]) { pos++ } hour, _, ok := mysqlVariableDigitsForExtract(str, &pos) @@ -4914,9 +4913,9 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { } minute := uint64(0) second := uint64(0) - if pos < len(str) && mysqlClockFieldSeparatorForExtract(str[pos]) { + if pos < len(str) && mysqlDatetimePunctuationForExtract(str[pos]) { pos++ - for pos < len(str) && mysqlClockFieldSeparatorForExtract(str[pos]) { + for pos < len(str) && mysqlDatetimePunctuationForExtract(str[pos]) { pos++ } if pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { @@ -4929,9 +4928,9 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { // separator is consumed, repeated separators still retain the // previously parsed hour and minute (for example, // "... 12:34::56"). - if pos < len(str) && mysqlClockFieldSeparatorForExtract(str[pos]) { + if pos < len(str) && mysqlDatetimePunctuationForExtract(str[pos]) { pos++ - for pos < len(str) && mysqlClockFieldSeparatorForExtract(str[pos]) { + for pos < len(str) && mysqlDatetimePunctuationForExtract(str[pos]) { pos++ } if pos < len(str) && str[pos] >= '0' && str[pos] <= '9' { @@ -4995,8 +4994,35 @@ func mysqlDateSeparatorForExtract(c byte) bool { return c == '-' || c == '/' || c == ':' } -func mysqlClockFieldSeparatorForExtract(c byte) bool { - return c == ':' || c == '-' +func mysqlDatetimePunctuationForExtract(c byte) bool { + return (c >= '!' && c <= '/') || (c >= ':' && c <= '@') || + (c >= '[' && c <= '`') || (c >= '{' && c <= '~') +} + +func mysqlWhitespaceForExtract(c byte) bool { + switch c { + case ' ', '\t', '\n', '\r', '\f', '\v': + return true + default: + return false + } +} + +func mysqlTrimLeftWhitespaceForExtract(str string) string { + pos := 0 + for pos < len(str) && mysqlWhitespaceForExtract(str[pos]) { + pos++ + } + return str[pos:] +} + +func mysqlFirstWhitespaceForExtract(str string) int { + for i := 0; i < len(str); i++ { + if mysqlWhitespaceForExtract(str[i]) { + return i + } + } + return -1 } func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { @@ -5011,7 +5037,7 @@ func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { if len(prefix) == 0 { return 0, 0, 0, false } - prefix = strings.TrimLeft(prefix, " ") + prefix = mysqlTrimLeftWhitespaceForExtract(prefix) if len(prefix) == 0 { return 0, 0, 0, false } @@ -5025,14 +5051,14 @@ func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { day := uint64(0) hasDay := false - if space := strings.IndexByte(prefix, ' '); space >= 0 { + if space := mysqlFirstWhitespaceForExtract(prefix); space >= 0 { if space == 0 || !asciiDigits(prefix[:space]) { return 0, 0, 0, false } day = mysqlClampedDigitsForExtract(prefix[:space], 35) hasDay = true - prefix = strings.TrimLeft(prefix[space:], " ") - if len(prefix) == 0 || strings.IndexByte(prefix, ' ') >= 0 { + prefix = mysqlTrimLeftWhitespaceForExtract(prefix[space:]) + if len(prefix) == 0 || mysqlFirstWhitespaceForExtract(prefix) >= 0 { return 0, 0, 0, false } } @@ -5261,7 +5287,7 @@ func mysqlCompactDatetimeSuffixForExtract(suffix string) bool { // MySQL consumes a compact DATETIME through the fractional separator. The // fraction may be empty or may stop before trailing non-numeric text, but a // suffix without a decimal separator is not part of this coercion. - return suffix[0] == '.' + return suffix[0] == '.' && (len(suffix) == 1 || (suffix[1] != '+' && suffix[1] != '-')) } func compactDatetimeClockForExtract(str string, twoDigitYear bool) (uint64, uint8, uint8, bool) { @@ -5293,7 +5319,7 @@ func mysqlTimePrefixForExtract(str string) string { end := 0 for end < len(str) { c := str[end] - if (c < '0' || c > '9') && c != ':' && c != '.' && c != '-' && c != ' ' { + if (c < '0' || c > '9') && c != ':' && c != '.' && c != '-' && !mysqlWhitespaceForExtract(c) { break } end++ diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 4c8391f42483c..0a9a4314a6ac2 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4280,29 +4280,34 @@ func TestStringTimeExtractIncompleteDatetimeAndZeroYearCalendar(t *testing.T) { } func TestStringTimeExtractCompactDatetimeSuffix(t *testing.T) { - proc := testutil.NewProcess(t) - inputs := []FunctionTestInput{ - NewFunctionTestInput(types.T_varchar.ToType(), []string{ - "20241220153045", "241220153045", "20241220153045.999999", "241220153045.9", - "20241220153045.123abc", "20241220153045.", "241220153045.123abc", "241220153045.", - "202412201530451", "2412201530451", - "20241220153045abc", "241220153045abc", - }, nil), - } + for _, typ := range []types.T{types.T_varchar, types.T_char, types.T_text} { + t.Run(typ.String(), func(t *testing.T) { + proc := testutil.NewProcess(t) + inputs := []FunctionTestInput{ + NewFunctionTestInput(typ.ToType(), []string{ + "20241220153045", "241220153045", "20241220153045.999999", "241220153045.9", + "20241220153045.123abc", "20241220153045.", "241220153045.123abc", "241220153045.", + "202412201530451", "2412201530451", + "20241220153045abc", "241220153045abc", + "20241220153045.-", "20241220153045.+", "241220153045.-", "241220153045.+", + }, nil), + } - for _, tc := range []struct { - name string - expect FunctionTestResult - fn fEvalFn - }{ - {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{15, 15, 15, 15, 15, 15, 15, 15, 0, 0, 0, 0}, []bool{false, false, false, false, false, false, false, false, true, true, true, true}), StringToHour}, - {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{30, 30, 30, 30, 30, 30, 30, 30, 0, 0, 0, 0}, []bool{false, false, false, false, false, false, false, false, true, true, true, true}), StringToMinute}, - {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{45, 45, 45, 45, 45, 45, 45, 45, 0, 0, 0, 0}, []bool{false, false, false, false, false, false, false, false, true, true, true, true}), StringToSecond}, - } { - t.Run(tc.name, func(t *testing.T) { - tcc := NewFunctionTestCase(proc, inputs, tc.expect, tc.fn) - succeed, info := tcc.Run() - require.True(t, succeed, info) + for _, tc := range []struct { + name string + expect FunctionTestResult + fn fEvalFn + }{ + {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{15, 15, 15, 15, 15, 15, 15, 15, 0, 0, 0, 0, 0, 0, 0, 0}, []bool{false, false, false, false, false, false, false, false, true, true, true, true, true, true, true, true}), StringToHour}, + {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{30, 30, 30, 30, 30, 30, 30, 30, 0, 0, 0, 0, 0, 0, 0, 0}, []bool{false, false, false, false, false, false, false, false, true, true, true, true, true, true, true, true}), StringToMinute}, + {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{45, 45, 45, 45, 45, 45, 45, 45, 0, 0, 0, 0, 0, 0, 0, 0}, []bool{false, false, false, false, false, false, false, false, true, true, true, true, true, true, true, true}), StringToSecond}, + } { + t.Run(tc.name, func(t *testing.T) { + tcc := NewFunctionTestCase(proc, inputs, tc.expect, tc.fn) + succeed, info := tcc.Run() + require.True(t, succeed, info) + }) + } }) } } @@ -4383,6 +4388,7 @@ func TestStringTimeExtractContextAwarePrefixBoundaries(t *testing.T) { "1 02", "1 02:", "1 02-34", "2024-12-20 12", "2024-12-20 12:", "2024-12-20 12::56", "2024-12-20foo", "2024-12-20 12:34::56", "2024-12-20 12-34", + "2024-12-20 12/34/56", "2024@12@20 12@34@56", "2024-12-20\t12:34:56", "1\t02:34:56", }, nil) for _, tc := range []struct { @@ -4394,19 +4400,19 @@ func TestStringTimeExtractContextAwarePrefixBoundaries(t *testing.T) { name: "hour", fn: StringToHour, expect: NewFunctionTestResult(types.T_uint32.ToType(), false, - []uint32{0, 0, 26, 26, 26, 12, 12, 12, 0, 12, 12}, nil), + []uint32{0, 0, 26, 26, 26, 12, 12, 12, 0, 12, 12, 12, 12, 12, 26}, nil), }, { name: "minute", fn: StringToMinute, expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{0, 12, 0, 0, 0, 0, 0, 56, 20, 34, 34}, nil), + []uint8{0, 12, 0, 0, 0, 0, 0, 56, 20, 34, 34, 34, 34, 34, 34}, nil), }, { name: "second", fn: StringToSecond, expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{12, 34, 0, 0, 0, 0, 0, 0, 24, 56, 0}, nil), + []uint8{12, 34, 0, 0, 0, 0, 0, 0, 24, 56, 0, 56, 56, 56, 56}, nil), }, } { t.Run(tc.name, func(t *testing.T) { diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index 84efb49870dbb..86f8ca5cdaa5f 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -149,9 +149,13 @@ HOUR('241220153045abc') AS compact_12_alpha_suffix_hour; SELECT HOUR('20241220153045.123abc') AS compact_14_fraction_text_hour, HOUR('20241220153045.') AS compact_14_empty_fraction_hour, HOUR('241220153045.123abc') AS compact_12_fraction_text_hour, -HOUR('241220153045.') AS compact_12_empty_fraction_hour; -➤ compact_14_fraction_text_hour[4,32,0] ¦ compact_14_empty_fraction_hour[4,32,0] ¦ compact_12_fraction_text_hour[4,32,0] ¦ compact_12_empty_fraction_hour[4,32,0] 𝄀 -15 ¦ 15 ¦ 15 ¦ 15 +HOUR('241220153045.') AS compact_12_empty_fraction_hour, +HOUR('20241220153045.-') AS compact_14_negative_fraction_hour, +HOUR('20241220153045.+') AS compact_14_positive_fraction_hour, +HOUR('241220153045.-') AS compact_12_negative_fraction_hour, +HOUR('241220153045.+') AS compact_12_positive_fraction_hour; +➤ compact_14_fraction_text_hour[4,32,0] ¦ compact_14_empty_fraction_hour[4,32,0] ¦ compact_12_fraction_text_hour[4,32,0] ¦ compact_12_empty_fraction_hour[4,32,0] ¦ compact_14_negative_fraction_hour[4,32,0] ¦ compact_14_positive_fraction_hour[4,32,0] ¦ compact_12_negative_fraction_hour[4,32,0] ¦ compact_12_positive_fraction_hour[4,32,0] 𝄀 +15 ¦ 15 ¦ 15 ¦ 15 ¦ null ¦ null ¦ null ¦ null SELECT HOUR('12:') AS trailing_colon_hour, HOUR(':34') AS leading_colon_hour, HOUR('12:34:56-') AS trailing_dash_hour, @@ -181,7 +185,11 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'), ('2024-12-20foo', '2024-12-20foo', '2024-12-20foo'), ('2024-12-20 12:34::56', '2024-12-20 12:34::56', '2024-12-20 12:34::56'), -('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'); +('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'), +('2024-12-20 12/34/56', '2024-12-20 12/34/56', '2024-12-20 12/34/56'), +('2024@12@20 12@34@56', '2024@12@20 12@34@56', '2024@12@20 12@34@56'), +('2024-12-20\t12:34:56', '2024-12-20\t12:34:56', '2024-12-20\t12:34:56'), +('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'); SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_prefix_boundaries; ➤ HOUR(v)[4,32,0] ¦ HOUR(c)[4,32,0] ¦ HOUR(t)[4,32,0] 𝄀 0 ¦ 0 ¦ 0 𝄀 @@ -194,5 +202,9 @@ SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_prefix_boundaries; 12 ¦ 12 ¦ 12 𝄀 0 ¦ 0 ¦ 0 𝄀 12 ¦ 12 ¦ 12 𝄀 -12 ¦ 12 ¦ 12 +12 ¦ 12 ¦ 12 𝄀 +12 ¦ 12 ¦ 12 𝄀 +12 ¦ 12 ¦ 12 𝄀 +12 ¦ 12 ¦ 12 𝄀 +26 ¦ 26 ¦ 26 DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index 41f8580daa1bf..cd4b4d5a3a878 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -128,7 +128,11 @@ SELECT HOUR('20241220153045') AS compact_14_hour, SELECT HOUR('20241220153045.123abc') AS compact_14_fraction_text_hour, HOUR('20241220153045.') AS compact_14_empty_fraction_hour, HOUR('241220153045.123abc') AS compact_12_fraction_text_hour, - HOUR('241220153045.') AS compact_12_empty_fraction_hour; + HOUR('241220153045.') AS compact_12_empty_fraction_hour, + HOUR('20241220153045.-') AS compact_14_negative_fraction_hour, + HOUR('20241220153045.+') AS compact_14_positive_fraction_hour, + HOUR('241220153045.-') AS compact_12_negative_fraction_hour, + HOUR('241220153045.+') AS compact_12_positive_fraction_hour; -- TIME coercion stops after the valid clock prefix. SELECT HOUR('12:') AS trailing_colon_hour, @@ -160,6 +164,10 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'), ('2024-12-20foo', '2024-12-20foo', '2024-12-20foo'), ('2024-12-20 12:34::56', '2024-12-20 12:34::56', '2024-12-20 12:34::56'), - ('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'); + ('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'), + ('2024-12-20 12/34/56', '2024-12-20 12/34/56', '2024-12-20 12/34/56'), + ('2024@12@20 12@34@56', '2024@12@20 12@34@56', '2024@12@20 12@34@56'), + ('2024-12-20\t12:34:56', '2024-12-20\t12:34:56', '2024-12-20\t12:34:56'), + ('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'); SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_prefix_boundaries; DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index d75e33531abd2..222575324fcd2 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -137,9 +137,13 @@ MINUTE('241220153045abc') AS compact_12_alpha_suffix_minute; SELECT MINUTE('20241220153045.123abc') AS compact_14_fraction_text_minute, MINUTE('20241220153045.') AS compact_14_empty_fraction_minute, MINUTE('241220153045.123abc') AS compact_12_fraction_text_minute, -MINUTE('241220153045.') AS compact_12_empty_fraction_minute; -➤ compact_14_fraction_text_minute[-6,8,0] ¦ compact_14_empty_fraction_minute[-6,8,0] ¦ compact_12_fraction_text_minute[-6,8,0] ¦ compact_12_empty_fraction_minute[-6,8,0] 𝄀 -30 ¦ 30 ¦ 30 ¦ 30 +MINUTE('241220153045.') AS compact_12_empty_fraction_minute, +MINUTE('20241220153045.-') AS compact_14_negative_fraction_minute, +MINUTE('20241220153045.+') AS compact_14_positive_fraction_minute, +MINUTE('241220153045.-') AS compact_12_negative_fraction_minute, +MINUTE('241220153045.+') AS compact_12_positive_fraction_minute; +➤ compact_14_fraction_text_minute[-6,8,0] ¦ compact_14_empty_fraction_minute[-6,8,0] ¦ compact_12_fraction_text_minute[-6,8,0] ¦ compact_12_empty_fraction_minute[-6,8,0] ¦ compact_14_negative_fraction_minute[-6,8,0] ¦ compact_14_positive_fraction_minute[-6,8,0] ¦ compact_12_negative_fraction_minute[-6,8,0] ¦ compact_12_positive_fraction_minute[-6,8,0] 𝄀 +30 ¦ 30 ¦ 30 ¦ 30 ¦ null ¦ null ¦ null ¦ null SELECT MINUTE('12:') AS trailing_colon_minute, MINUTE(':34') AS leading_colon_minute, MINUTE('12:34:56-') AS trailing_dash_minute, @@ -169,7 +173,11 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'), ('2024-12-20foo', '2024-12-20foo', '2024-12-20foo'), ('2024-12-20 12:34::56', '2024-12-20 12:34::56', '2024-12-20 12:34::56'), -('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'); +('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'), +('2024-12-20 12/34/56', '2024-12-20 12/34/56', '2024-12-20 12/34/56'), +('2024@12@20 12@34@56', '2024@12@20 12@34@56', '2024@12@20 12@34@56'), +('2024-12-20\t12:34:56', '2024-12-20\t12:34:56', '2024-12-20\t12:34:56'), +('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'); SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_prefix_boundaries; ➤ MINUTE(v)[-6,8,0] ¦ MINUTE(c)[-6,8,0] ¦ MINUTE(t)[-6,8,0] 𝄀 0 ¦ 0 ¦ 0 𝄀 @@ -182,5 +190,9 @@ SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_prefix_boundaries; 56 ¦ 56 ¦ 56 𝄀 20 ¦ 20 ¦ 20 𝄀 34 ¦ 34 ¦ 34 𝄀 +34 ¦ 34 ¦ 34 𝄀 +34 ¦ 34 ¦ 34 𝄀 +34 ¦ 34 ¦ 34 𝄀 +34 ¦ 34 ¦ 34 𝄀 34 ¦ 34 ¦ 34 DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index 48cfe3e352e7e..c524d9934be6d 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -124,7 +124,11 @@ SELECT MINUTE('20241220153045') AS compact_14_minute, SELECT MINUTE('20241220153045.123abc') AS compact_14_fraction_text_minute, MINUTE('20241220153045.') AS compact_14_empty_fraction_minute, MINUTE('241220153045.123abc') AS compact_12_fraction_text_minute, - MINUTE('241220153045.') AS compact_12_empty_fraction_minute; + MINUTE('241220153045.') AS compact_12_empty_fraction_minute, + MINUTE('20241220153045.-') AS compact_14_negative_fraction_minute, + MINUTE('20241220153045.+') AS compact_14_positive_fraction_minute, + MINUTE('241220153045.-') AS compact_12_negative_fraction_minute, + MINUTE('241220153045.+') AS compact_12_positive_fraction_minute; -- TIME coercion stops after the valid clock prefix. SELECT MINUTE('12:') AS trailing_colon_minute, @@ -156,6 +160,10 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'), ('2024-12-20foo', '2024-12-20foo', '2024-12-20foo'), ('2024-12-20 12:34::56', '2024-12-20 12:34::56', '2024-12-20 12:34::56'), - ('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'); + ('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'), + ('2024-12-20 12/34/56', '2024-12-20 12/34/56', '2024-12-20 12/34/56'), + ('2024@12@20 12@34@56', '2024@12@20 12@34@56', '2024@12@20 12@34@56'), + ('2024-12-20\t12:34:56', '2024-12-20\t12:34:56', '2024-12-20\t12:34:56'), + ('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'); SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_prefix_boundaries; DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index eda158b550eac..c83be261592b7 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -138,9 +138,13 @@ SECOND('241220153045abc') AS compact_12_alpha_suffix_second; SELECT SECOND('20241220153045.123abc') AS compact_14_fraction_text_second, SECOND('20241220153045.') AS compact_14_empty_fraction_second, SECOND('241220153045.123abc') AS compact_12_fraction_text_second, -SECOND('241220153045.') AS compact_12_empty_fraction_second; -➤ compact_14_fraction_text_second[-6,8,0] ¦ compact_14_empty_fraction_second[-6,8,0] ¦ compact_12_fraction_text_second[-6,8,0] ¦ compact_12_empty_fraction_second[-6,8,0] 𝄀 -45 ¦ 45 ¦ 45 ¦ 45 +SECOND('241220153045.') AS compact_12_empty_fraction_second, +SECOND('20241220153045.-') AS compact_14_negative_fraction_second, +SECOND('20241220153045.+') AS compact_14_positive_fraction_second, +SECOND('241220153045.-') AS compact_12_negative_fraction_second, +SECOND('241220153045.+') AS compact_12_positive_fraction_second; +➤ compact_14_fraction_text_second[-6,8,0] ¦ compact_14_empty_fraction_second[-6,8,0] ¦ compact_12_fraction_text_second[-6,8,0] ¦ compact_12_empty_fraction_second[-6,8,0] ¦ compact_14_negative_fraction_second[-6,8,0] ¦ compact_14_positive_fraction_second[-6,8,0] ¦ compact_12_negative_fraction_second[-6,8,0] ¦ compact_12_positive_fraction_second[-6,8,0] 𝄀 +45 ¦ 45 ¦ 45 ¦ 45 ¦ null ¦ null ¦ null ¦ null SELECT SECOND('12:') AS trailing_colon_second, SECOND(':34') AS leading_colon_second, SECOND('12:34:56-') AS trailing_dash_second, @@ -170,7 +174,11 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'), ('2024-12-20foo', '2024-12-20foo', '2024-12-20foo'), ('2024-12-20 12:34::56', '2024-12-20 12:34::56', '2024-12-20 12:34::56'), -('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'); +('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'), +('2024-12-20 12/34/56', '2024-12-20 12/34/56', '2024-12-20 12/34/56'), +('2024@12@20 12@34@56', '2024@12@20 12@34@56', '2024@12@20 12@34@56'), +('2024-12-20\t12:34:56', '2024-12-20\t12:34:56', '2024-12-20\t12:34:56'), +('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'); SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_prefix_boundaries; ➤ SECOND(v)[-6,8,0] ¦ SECOND(c)[-6,8,0] ¦ SECOND(t)[-6,8,0] 𝄀 12 ¦ 12 ¦ 12 𝄀 @@ -183,5 +191,9 @@ SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_prefix_boundaries; 0 ¦ 0 ¦ 0 𝄀 24 ¦ 24 ¦ 24 𝄀 56 ¦ 56 ¦ 56 𝄀 -0 ¦ 0 ¦ 0 +0 ¦ 0 ¦ 0 𝄀 +56 ¦ 56 ¦ 56 𝄀 +56 ¦ 56 ¦ 56 𝄀 +56 ¦ 56 ¦ 56 𝄀 +56 ¦ 56 ¦ 56 DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 772ebe0cdec40..7c22e7fa7f3e0 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -124,7 +124,11 @@ SELECT SECOND('20241220153045') AS compact_14_second, SELECT SECOND('20241220153045.123abc') AS compact_14_fraction_text_second, SECOND('20241220153045.') AS compact_14_empty_fraction_second, SECOND('241220153045.123abc') AS compact_12_fraction_text_second, - SECOND('241220153045.') AS compact_12_empty_fraction_second; + SECOND('241220153045.') AS compact_12_empty_fraction_second, + SECOND('20241220153045.-') AS compact_14_negative_fraction_second, + SECOND('20241220153045.+') AS compact_14_positive_fraction_second, + SECOND('241220153045.-') AS compact_12_negative_fraction_second, + SECOND('241220153045.+') AS compact_12_positive_fraction_second; -- TIME coercion stops after the valid clock prefix. SELECT SECOND('12:') AS trailing_colon_second, @@ -156,6 +160,10 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12::56', '2024-12-20 12::56', '2024-12-20 12::56'), ('2024-12-20foo', '2024-12-20foo', '2024-12-20foo'), ('2024-12-20 12:34::56', '2024-12-20 12:34::56', '2024-12-20 12:34::56'), - ('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'); + ('2024-12-20 12-34', '2024-12-20 12-34', '2024-12-20 12-34'), + ('2024-12-20 12/34/56', '2024-12-20 12/34/56', '2024-12-20 12/34/56'), + ('2024@12@20 12@34@56', '2024@12@20 12@34@56', '2024@12@20 12@34@56'), + ('2024-12-20\t12:34:56', '2024-12-20\t12:34:56', '2024-12-20\t12:34:56'), + ('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'); SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_prefix_boundaries; DROP TABLE time_extract_prefix_boundaries; From fb6747f1e53182d62af58f2881e59aa69409e15b Mon Sep 17 00:00:00 2001 From: daviszhen Date: Mon, 3 Aug 2026 12:01:35 +0800 Subject: [PATCH 21/25] update --- pkg/sql/plan/function/func_unary.go | 50 +++++++++++++++---- pkg/sql/plan/function/func_unary_test.go | 18 ++++--- .../cases/function/func_datetime_hour.result | 34 +++++++++++-- .../cases/function/func_datetime_hour.test | 19 ++++++- .../function/func_datetime_minute.result | 34 +++++++++++-- .../cases/function/func_datetime_minute.test | 19 ++++++- .../function/func_datetime_second.result | 34 +++++++++++-- .../cases/function/func_datetime_second.test | 19 ++++++- 8 files changed, 189 insertions(+), 38 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index d2f73c25d8c5e..9d628a7fe228d 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4892,12 +4892,16 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { if yearDigits == 2 { year = uint64(adjustYear(int(year))) } - pos++ + for pos < len(str) && mysqlDatetimePunctuationForExtract(str[pos]) { + pos++ + } month, _, ok := mysqlVariableDigitsForExtract(str, &pos) if !ok || pos >= len(str) || !mysqlDatetimePunctuationForExtract(str[pos]) { return timeExtractParseResult{} } - pos++ + for pos < len(str) && mysqlDatetimePunctuationForExtract(str[pos]) { + pos++ + } day, _, ok := mysqlVariableDigitsForExtract(str, &pos) if !ok || pos >= len(str) || !mysqlWhitespaceForExtract(str[pos]) { // A complete date without a clock is still handled by the TIME path. @@ -4907,6 +4911,9 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { for pos < len(str) && mysqlWhitespaceForExtract(str[pos]) { pos++ } + if pos < len(str) && (str[pos] == '+' || str[pos] == '-') { + pos++ + } hour, _, ok := mysqlVariableDigitsForExtract(str, &pos) if !ok { return result @@ -4942,6 +4949,13 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { } } } + // Whitespace after the clock has started terminates the separated + // DATETIME interpretation. MySQL then applies its date-prefix TIME + // coercion instead (for example, "2024-12-20 12:34 56" becomes + // 00:20:24). Outer whitespace has already been trimmed by the caller. + if pos < len(str) && mysqlWhitespaceForExtract(str[pos]) { + return timeExtractParseResult{} + } // A complete date followed by an hour or a trailing field separator is a // valid DATETIME prefix. Do not fall back to compact TIME coercion after // this branch has consumed the date and hour. @@ -5052,14 +5066,21 @@ func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { day := uint64(0) hasDay := false if space := mysqlFirstWhitespaceForExtract(prefix); space >= 0 { - if space == 0 || !asciiDigits(prefix[:space]) { - return 0, 0, 0, false - } - day = mysqlClampedDigitsForExtract(prefix[:space], 35) - hasDay = true - prefix = mysqlTrimLeftWhitespaceForExtract(prefix[space:]) - if len(prefix) == 0 || mysqlFirstWhitespaceForExtract(prefix) >= 0 { - return 0, 0, 0, false + if space > 0 && asciiDigits(prefix[:space]) { + day = mysqlClampedDigitsForExtract(prefix[:space], 35) + hasDay = true + prefix = mysqlTrimLeftWhitespaceForExtract(prefix[space:]) + if len(prefix) == 0 { + return 0, 0, 0, false + } + if clockEnd := mysqlFirstWhitespaceForExtract(prefix); clockEnd >= 0 { + prefix = prefix[:clockEnd] + } + } else { + // A clock prefix followed by whitespace keeps the fields consumed + // before that whitespace. It is not a day separator unless every + // byte before it is a digit. + prefix = prefix[:space] } } @@ -5287,7 +5308,14 @@ func mysqlCompactDatetimeSuffixForExtract(suffix string) bool { // MySQL consumes a compact DATETIME through the fractional separator. The // fraction may be empty or may stop before trailing non-numeric text, but a // suffix without a decimal separator is not part of this coercion. - return suffix[0] == '.' && (len(suffix) == 1 || (suffix[1] != '+' && suffix[1] != '-')) + if suffix[0] != '.' { + return false + } + pos := 1 + for pos < len(suffix) && suffix[pos] >= '0' && suffix[pos] <= '9' { + pos++ + } + return pos == len(suffix) || (suffix[pos] != '+' && suffix[pos] != '-') } func compactDatetimeClockForExtract(str string, twoDigitYear bool) (uint64, uint8, uint8, bool) { diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 0a9a4314a6ac2..4e08f505686d6 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4290,6 +4290,8 @@ func TestStringTimeExtractCompactDatetimeSuffix(t *testing.T) { "202412201530451", "2412201530451", "20241220153045abc", "241220153045abc", "20241220153045.-", "20241220153045.+", "241220153045.-", "241220153045.+", + "20241220153045.123-", "20241220153045.123+", "241220153045.123-", "241220153045.123+", + "20241220153045.123abc-", "241220153045.123abc+", }, nil), } @@ -4298,9 +4300,9 @@ func TestStringTimeExtractCompactDatetimeSuffix(t *testing.T) { expect FunctionTestResult fn fEvalFn }{ - {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{15, 15, 15, 15, 15, 15, 15, 15, 0, 0, 0, 0, 0, 0, 0, 0}, []bool{false, false, false, false, false, false, false, false, true, true, true, true, true, true, true, true}), StringToHour}, - {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{30, 30, 30, 30, 30, 30, 30, 30, 0, 0, 0, 0, 0, 0, 0, 0}, []bool{false, false, false, false, false, false, false, false, true, true, true, true, true, true, true, true}), StringToMinute}, - {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{45, 45, 45, 45, 45, 45, 45, 45, 0, 0, 0, 0, 0, 0, 0, 0}, []bool{false, false, false, false, false, false, false, false, true, true, true, true, true, true, true, true}), StringToSecond}, + {"hour", NewFunctionTestResult(types.T_uint32.ToType(), false, []uint32{15, 15, 15, 15, 15, 15, 15, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 15}, []bool{false, false, false, false, false, false, false, false, true, true, true, true, true, true, true, true, true, true, true, true, false, false}), StringToHour}, + {"minute", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{30, 30, 30, 30, 30, 30, 30, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 30, 30}, []bool{false, false, false, false, false, false, false, false, true, true, true, true, true, true, true, true, true, true, true, true, false, false}), StringToMinute}, + {"second", NewFunctionTestResult(types.T_uint8.ToType(), false, []uint8{45, 45, 45, 45, 45, 45, 45, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 45, 45}, []bool{false, false, false, false, false, false, false, false, true, true, true, true, true, true, true, true, true, true, true, true, false, false}), StringToSecond}, } { t.Run(tc.name, func(t *testing.T) { tcc := NewFunctionTestCase(proc, inputs, tc.expect, tc.fn) @@ -4389,6 +4391,10 @@ func TestStringTimeExtractContextAwarePrefixBoundaries(t *testing.T) { "2024-12-20 12", "2024-12-20 12:", "2024-12-20 12::56", "2024-12-20foo", "2024-12-20 12:34::56", "2024-12-20 12-34", "2024-12-20 12/34/56", "2024@12@20 12@34@56", "2024-12-20\t12:34:56", "1\t02:34:56", + "12:34 56", "1 02:34 56", "1 02 34", + "2024--12--20 12:34:56", "2024/-12/-20 12:34:56", + "2024-12-20 -12:34:56", "2024-12-20 +12:34:56", + "2024-12-20 12:34 56", "2024-12-20 12 34", }, nil) for _, tc := range []struct { @@ -4400,19 +4406,19 @@ func TestStringTimeExtractContextAwarePrefixBoundaries(t *testing.T) { name: "hour", fn: StringToHour, expect: NewFunctionTestResult(types.T_uint32.ToType(), false, - []uint32{0, 0, 26, 26, 26, 12, 12, 12, 0, 12, 12, 12, 12, 12, 26}, nil), + []uint32{0, 0, 26, 26, 26, 12, 12, 12, 0, 12, 12, 12, 12, 12, 26, 12, 26, 26, 12, 12, 12, 12, 0, 0}, nil), }, { name: "minute", fn: StringToMinute, expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{0, 12, 0, 0, 0, 0, 0, 56, 20, 34, 34, 34, 34, 34, 34}, nil), + []uint8{0, 12, 0, 0, 0, 0, 0, 56, 20, 34, 34, 34, 34, 34, 34, 34, 34, 0, 34, 34, 34, 34, 20, 20}, nil), }, { name: "second", fn: StringToSecond, expect: NewFunctionTestResult(types.T_uint8.ToType(), false, - []uint8{12, 34, 0, 0, 0, 0, 0, 0, 24, 56, 0, 56, 56, 56, 56}, nil), + []uint8{12, 34, 0, 0, 0, 0, 0, 0, 24, 56, 0, 56, 56, 56, 56, 0, 0, 0, 56, 56, 56, 56, 24, 24}, nil), }, } { t.Run(tc.name, func(t *testing.T) { diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index 86f8ca5cdaa5f..f02c3799ee7db 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -153,9 +153,15 @@ HOUR('241220153045.') AS compact_12_empty_fraction_hour, HOUR('20241220153045.-') AS compact_14_negative_fraction_hour, HOUR('20241220153045.+') AS compact_14_positive_fraction_hour, HOUR('241220153045.-') AS compact_12_negative_fraction_hour, -HOUR('241220153045.+') AS compact_12_positive_fraction_hour; -➤ compact_14_fraction_text_hour[4,32,0] ¦ compact_14_empty_fraction_hour[4,32,0] ¦ compact_12_fraction_text_hour[4,32,0] ¦ compact_12_empty_fraction_hour[4,32,0] ¦ compact_14_negative_fraction_hour[4,32,0] ¦ compact_14_positive_fraction_hour[4,32,0] ¦ compact_12_negative_fraction_hour[4,32,0] ¦ compact_12_positive_fraction_hour[4,32,0] 𝄀 -15 ¦ 15 ¦ 15 ¦ 15 ¦ null ¦ null ¦ null ¦ null +HOUR('241220153045.+') AS compact_12_positive_fraction_hour, +HOUR('20241220153045.123-') AS compact_14_fraction_negative_suffix_hour, +HOUR('20241220153045.123+') AS compact_14_fraction_positive_suffix_hour, +HOUR('241220153045.123-') AS compact_12_fraction_negative_suffix_hour, +HOUR('241220153045.123+') AS compact_12_fraction_positive_suffix_hour, +HOUR('20241220153045.123abc-') AS compact_14_fraction_text_negative_suffix_hour, +HOUR('241220153045.123abc+') AS compact_12_fraction_text_positive_suffix_hour; +➤ compact_14_fraction_text_hour[4,32,0] ¦ compact_14_empty_fraction_hour[4,32,0] ¦ compact_12_fraction_text_hour[4,32,0] ¦ compact_12_empty_fraction_hour[4,32,0] ¦ compact_14_negative_fraction_hour[4,32,0] ¦ compact_14_positive_fraction_hour[4,32,0] ¦ compact_12_negative_fraction_hour[4,32,0] ¦ compact_12_positive_fraction_hour[4,32,0] ¦ compact_14_fraction_negative_suffix_hour[4,32,0] ¦ compact_14_fraction_positive_suffix_hour[4,32,0] ¦ compact_12_fraction_negative_suffix_hour[4,32,0] ¦ compact_12_fraction_positive_suffix_hour[4,32,0] ¦ compact_14_fraction_text_negative_suffix_hour[4,32,0] ¦ compact_12_fraction_text_positive_suffix_hour[4,32,0] 𝄀 +15 ¦ 15 ¦ 15 ¦ 15 ¦ null ¦ null ¦ null ¦ null ¦ null ¦ null ¦ null ¦ null ¦ 15 ¦ 15 SELECT HOUR('12:') AS trailing_colon_hour, HOUR(':34') AS leading_colon_hour, HOUR('12:34:56-') AS trailing_dash_hour, @@ -189,7 +195,16 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12/34/56', '2024-12-20 12/34/56', '2024-12-20 12/34/56'), ('2024@12@20 12@34@56', '2024@12@20 12@34@56', '2024@12@20 12@34@56'), ('2024-12-20\t12:34:56', '2024-12-20\t12:34:56', '2024-12-20\t12:34:56'), -('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'); +('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'), +('12:34 56', '12:34 56', '12:34 56'), +('1 02:34 56', '1 02:34 56', '1 02:34 56'), +('1 02 34', '1 02 34', '1 02 34'), +('2024--12--20 12:34:56', '2024--12--20 12:34:56', '2024--12--20 12:34:56'), +('2024/-12/-20 12:34:56', '2024/-12/-20 12:34:56', '2024/-12/-20 12:34:56'), +('2024-12-20 -12:34:56', '2024-12-20 -12:34:56', '2024-12-20 -12:34:56'), +('2024-12-20 +12:34:56', '2024-12-20 +12:34:56', '2024-12-20 +12:34:56'), +('2024-12-20 12:34 56', '2024-12-20 12:34 56', '2024-12-20 12:34 56'), +('2024-12-20 12 34', '2024-12-20 12 34', '2024-12-20 12 34'); SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_prefix_boundaries; ➤ HOUR(v)[4,32,0] ¦ HOUR(c)[4,32,0] ¦ HOUR(t)[4,32,0] 𝄀 0 ¦ 0 ¦ 0 𝄀 @@ -206,5 +221,14 @@ SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_prefix_boundaries; 12 ¦ 12 ¦ 12 𝄀 12 ¦ 12 ¦ 12 𝄀 12 ¦ 12 ¦ 12 𝄀 -26 ¦ 26 ¦ 26 +26 ¦ 26 ¦ 26 𝄀 +12 ¦ 12 ¦ 12 𝄀 +26 ¦ 26 ¦ 26 𝄀 +26 ¦ 26 ¦ 26 𝄀 +12 ¦ 12 ¦ 12 𝄀 +12 ¦ 12 ¦ 12 𝄀 +12 ¦ 12 ¦ 12 𝄀 +12 ¦ 12 ¦ 12 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index cd4b4d5a3a878..4c4a9613454dd 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -132,7 +132,13 @@ SELECT HOUR('20241220153045.123abc') AS compact_14_fraction_text_hour, HOUR('20241220153045.-') AS compact_14_negative_fraction_hour, HOUR('20241220153045.+') AS compact_14_positive_fraction_hour, HOUR('241220153045.-') AS compact_12_negative_fraction_hour, - HOUR('241220153045.+') AS compact_12_positive_fraction_hour; + HOUR('241220153045.+') AS compact_12_positive_fraction_hour, + HOUR('20241220153045.123-') AS compact_14_fraction_negative_suffix_hour, + HOUR('20241220153045.123+') AS compact_14_fraction_positive_suffix_hour, + HOUR('241220153045.123-') AS compact_12_fraction_negative_suffix_hour, + HOUR('241220153045.123+') AS compact_12_fraction_positive_suffix_hour, + HOUR('20241220153045.123abc-') AS compact_14_fraction_text_negative_suffix_hour, + HOUR('241220153045.123abc+') AS compact_12_fraction_text_positive_suffix_hour; -- TIME coercion stops after the valid clock prefix. SELECT HOUR('12:') AS trailing_colon_hour, @@ -168,6 +174,15 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12/34/56', '2024-12-20 12/34/56', '2024-12-20 12/34/56'), ('2024@12@20 12@34@56', '2024@12@20 12@34@56', '2024@12@20 12@34@56'), ('2024-12-20\t12:34:56', '2024-12-20\t12:34:56', '2024-12-20\t12:34:56'), - ('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'); + ('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'), + ('12:34 56', '12:34 56', '12:34 56'), + ('1 02:34 56', '1 02:34 56', '1 02:34 56'), + ('1 02 34', '1 02 34', '1 02 34'), + ('2024--12--20 12:34:56', '2024--12--20 12:34:56', '2024--12--20 12:34:56'), + ('2024/-12/-20 12:34:56', '2024/-12/-20 12:34:56', '2024/-12/-20 12:34:56'), + ('2024-12-20 -12:34:56', '2024-12-20 -12:34:56', '2024-12-20 -12:34:56'), + ('2024-12-20 +12:34:56', '2024-12-20 +12:34:56', '2024-12-20 +12:34:56'), + ('2024-12-20 12:34 56', '2024-12-20 12:34 56', '2024-12-20 12:34 56'), + ('2024-12-20 12 34', '2024-12-20 12 34', '2024-12-20 12 34'); SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_prefix_boundaries; DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 222575324fcd2..cc247232c7468 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -141,9 +141,15 @@ MINUTE('241220153045.') AS compact_12_empty_fraction_minute, MINUTE('20241220153045.-') AS compact_14_negative_fraction_minute, MINUTE('20241220153045.+') AS compact_14_positive_fraction_minute, MINUTE('241220153045.-') AS compact_12_negative_fraction_minute, -MINUTE('241220153045.+') AS compact_12_positive_fraction_minute; -➤ compact_14_fraction_text_minute[-6,8,0] ¦ compact_14_empty_fraction_minute[-6,8,0] ¦ compact_12_fraction_text_minute[-6,8,0] ¦ compact_12_empty_fraction_minute[-6,8,0] ¦ compact_14_negative_fraction_minute[-6,8,0] ¦ compact_14_positive_fraction_minute[-6,8,0] ¦ compact_12_negative_fraction_minute[-6,8,0] ¦ compact_12_positive_fraction_minute[-6,8,0] 𝄀 -30 ¦ 30 ¦ 30 ¦ 30 ¦ null ¦ null ¦ null ¦ null +MINUTE('241220153045.+') AS compact_12_positive_fraction_minute, +MINUTE('20241220153045.123-') AS compact_14_fraction_negative_suffix_minute, +MINUTE('20241220153045.123+') AS compact_14_fraction_positive_suffix_minute, +MINUTE('241220153045.123-') AS compact_12_fraction_negative_suffix_minute, +MINUTE('241220153045.123+') AS compact_12_fraction_positive_suffix_minute, +MINUTE('20241220153045.123abc-') AS compact_14_fraction_text_negative_suffix_minute, +MINUTE('241220153045.123abc+') AS compact_12_fraction_text_positive_suffix_minute; +➤ compact_14_fraction_text_minute[-6,8,0] ¦ compact_14_empty_fraction_minute[-6,8,0] ¦ compact_12_fraction_text_minute[-6,8,0] ¦ compact_12_empty_fraction_minute[-6,8,0] ¦ compact_14_negative_fraction_minute[-6,8,0] ¦ compact_14_positive_fraction_minute[-6,8,0] ¦ compact_12_negative_fraction_minute[-6,8,0] ¦ compact_12_positive_fraction_minute[-6,8,0] ¦ compact_14_fraction_negative_suffix_minute[-6,8,0] ¦ compact_14_fraction_positive_suffix_minute[-6,8,0] ¦ compact_12_fraction_negative_suffix_minute[-6,8,0] ¦ compact_12_fraction_positive_suffix_minute[-6,8,0] ¦ compact_14_fraction_text_negative_suffix_minute[-6,8,0] ¦ compact_12_fraction_text_positive_suffix_minute[-6,8,0] 𝄀 +30 ¦ 30 ¦ 30 ¦ 30 ¦ null ¦ null ¦ null ¦ null ¦ null ¦ null ¦ null ¦ null ¦ 30 ¦ 30 SELECT MINUTE('12:') AS trailing_colon_minute, MINUTE(':34') AS leading_colon_minute, MINUTE('12:34:56-') AS trailing_dash_minute, @@ -177,7 +183,16 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12/34/56', '2024-12-20 12/34/56', '2024-12-20 12/34/56'), ('2024@12@20 12@34@56', '2024@12@20 12@34@56', '2024@12@20 12@34@56'), ('2024-12-20\t12:34:56', '2024-12-20\t12:34:56', '2024-12-20\t12:34:56'), -('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'); +('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'), +('12:34 56', '12:34 56', '12:34 56'), +('1 02:34 56', '1 02:34 56', '1 02:34 56'), +('1 02 34', '1 02 34', '1 02 34'), +('2024--12--20 12:34:56', '2024--12--20 12:34:56', '2024--12--20 12:34:56'), +('2024/-12/-20 12:34:56', '2024/-12/-20 12:34:56', '2024/-12/-20 12:34:56'), +('2024-12-20 -12:34:56', '2024-12-20 -12:34:56', '2024-12-20 -12:34:56'), +('2024-12-20 +12:34:56', '2024-12-20 +12:34:56', '2024-12-20 +12:34:56'), +('2024-12-20 12:34 56', '2024-12-20 12:34 56', '2024-12-20 12:34 56'), +('2024-12-20 12 34', '2024-12-20 12 34', '2024-12-20 12 34'); SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_prefix_boundaries; ➤ MINUTE(v)[-6,8,0] ¦ MINUTE(c)[-6,8,0] ¦ MINUTE(t)[-6,8,0] 𝄀 0 ¦ 0 ¦ 0 𝄀 @@ -194,5 +209,14 @@ SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_prefix_boundaries; 34 ¦ 34 ¦ 34 𝄀 34 ¦ 34 ¦ 34 𝄀 34 ¦ 34 ¦ 34 𝄀 -34 ¦ 34 ¦ 34 +34 ¦ 34 ¦ 34 𝄀 +34 ¦ 34 ¦ 34 𝄀 +34 ¦ 34 ¦ 34 𝄀 +0 ¦ 0 ¦ 0 𝄀 +34 ¦ 34 ¦ 34 𝄀 +34 ¦ 34 ¦ 34 𝄀 +34 ¦ 34 ¦ 34 𝄀 +34 ¦ 34 ¦ 34 𝄀 +20 ¦ 20 ¦ 20 𝄀 +20 ¦ 20 ¦ 20 DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index c524d9934be6d..ddf2e06aa8aee 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -128,7 +128,13 @@ SELECT MINUTE('20241220153045.123abc') AS compact_14_fraction_text_minute, MINUTE('20241220153045.-') AS compact_14_negative_fraction_minute, MINUTE('20241220153045.+') AS compact_14_positive_fraction_minute, MINUTE('241220153045.-') AS compact_12_negative_fraction_minute, - MINUTE('241220153045.+') AS compact_12_positive_fraction_minute; + MINUTE('241220153045.+') AS compact_12_positive_fraction_minute, + MINUTE('20241220153045.123-') AS compact_14_fraction_negative_suffix_minute, + MINUTE('20241220153045.123+') AS compact_14_fraction_positive_suffix_minute, + MINUTE('241220153045.123-') AS compact_12_fraction_negative_suffix_minute, + MINUTE('241220153045.123+') AS compact_12_fraction_positive_suffix_minute, + MINUTE('20241220153045.123abc-') AS compact_14_fraction_text_negative_suffix_minute, + MINUTE('241220153045.123abc+') AS compact_12_fraction_text_positive_suffix_minute; -- TIME coercion stops after the valid clock prefix. SELECT MINUTE('12:') AS trailing_colon_minute, @@ -164,6 +170,15 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12/34/56', '2024-12-20 12/34/56', '2024-12-20 12/34/56'), ('2024@12@20 12@34@56', '2024@12@20 12@34@56', '2024@12@20 12@34@56'), ('2024-12-20\t12:34:56', '2024-12-20\t12:34:56', '2024-12-20\t12:34:56'), - ('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'); + ('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'), + ('12:34 56', '12:34 56', '12:34 56'), + ('1 02:34 56', '1 02:34 56', '1 02:34 56'), + ('1 02 34', '1 02 34', '1 02 34'), + ('2024--12--20 12:34:56', '2024--12--20 12:34:56', '2024--12--20 12:34:56'), + ('2024/-12/-20 12:34:56', '2024/-12/-20 12:34:56', '2024/-12/-20 12:34:56'), + ('2024-12-20 -12:34:56', '2024-12-20 -12:34:56', '2024-12-20 -12:34:56'), + ('2024-12-20 +12:34:56', '2024-12-20 +12:34:56', '2024-12-20 +12:34:56'), + ('2024-12-20 12:34 56', '2024-12-20 12:34 56', '2024-12-20 12:34 56'), + ('2024-12-20 12 34', '2024-12-20 12 34', '2024-12-20 12 34'); SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_prefix_boundaries; DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index c83be261592b7..b59d7819b64d9 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -142,9 +142,15 @@ SECOND('241220153045.') AS compact_12_empty_fraction_second, SECOND('20241220153045.-') AS compact_14_negative_fraction_second, SECOND('20241220153045.+') AS compact_14_positive_fraction_second, SECOND('241220153045.-') AS compact_12_negative_fraction_second, -SECOND('241220153045.+') AS compact_12_positive_fraction_second; -➤ compact_14_fraction_text_second[-6,8,0] ¦ compact_14_empty_fraction_second[-6,8,0] ¦ compact_12_fraction_text_second[-6,8,0] ¦ compact_12_empty_fraction_second[-6,8,0] ¦ compact_14_negative_fraction_second[-6,8,0] ¦ compact_14_positive_fraction_second[-6,8,0] ¦ compact_12_negative_fraction_second[-6,8,0] ¦ compact_12_positive_fraction_second[-6,8,0] 𝄀 -45 ¦ 45 ¦ 45 ¦ 45 ¦ null ¦ null ¦ null ¦ null +SECOND('241220153045.+') AS compact_12_positive_fraction_second, +SECOND('20241220153045.123-') AS compact_14_fraction_negative_suffix_second, +SECOND('20241220153045.123+') AS compact_14_fraction_positive_suffix_second, +SECOND('241220153045.123-') AS compact_12_fraction_negative_suffix_second, +SECOND('241220153045.123+') AS compact_12_fraction_positive_suffix_second, +SECOND('20241220153045.123abc-') AS compact_14_fraction_text_negative_suffix_second, +SECOND('241220153045.123abc+') AS compact_12_fraction_text_positive_suffix_second; +➤ compact_14_fraction_text_second[-6,8,0] ¦ compact_14_empty_fraction_second[-6,8,0] ¦ compact_12_fraction_text_second[-6,8,0] ¦ compact_12_empty_fraction_second[-6,8,0] ¦ compact_14_negative_fraction_second[-6,8,0] ¦ compact_14_positive_fraction_second[-6,8,0] ¦ compact_12_negative_fraction_second[-6,8,0] ¦ compact_12_positive_fraction_second[-6,8,0] ¦ compact_14_fraction_negative_suffix_second[-6,8,0] ¦ compact_14_fraction_positive_suffix_second[-6,8,0] ¦ compact_12_fraction_negative_suffix_second[-6,8,0] ¦ compact_12_fraction_positive_suffix_second[-6,8,0] ¦ compact_14_fraction_text_negative_suffix_second[-6,8,0] ¦ compact_12_fraction_text_positive_suffix_second[-6,8,0] 𝄀 +45 ¦ 45 ¦ 45 ¦ 45 ¦ null ¦ null ¦ null ¦ null ¦ null ¦ null ¦ null ¦ null ¦ 45 ¦ 45 SELECT SECOND('12:') AS trailing_colon_second, SECOND(':34') AS leading_colon_second, SECOND('12:34:56-') AS trailing_dash_second, @@ -178,7 +184,16 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12/34/56', '2024-12-20 12/34/56', '2024-12-20 12/34/56'), ('2024@12@20 12@34@56', '2024@12@20 12@34@56', '2024@12@20 12@34@56'), ('2024-12-20\t12:34:56', '2024-12-20\t12:34:56', '2024-12-20\t12:34:56'), -('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'); +('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'), +('12:34 56', '12:34 56', '12:34 56'), +('1 02:34 56', '1 02:34 56', '1 02:34 56'), +('1 02 34', '1 02 34', '1 02 34'), +('2024--12--20 12:34:56', '2024--12--20 12:34:56', '2024--12--20 12:34:56'), +('2024/-12/-20 12:34:56', '2024/-12/-20 12:34:56', '2024/-12/-20 12:34:56'), +('2024-12-20 -12:34:56', '2024-12-20 -12:34:56', '2024-12-20 -12:34:56'), +('2024-12-20 +12:34:56', '2024-12-20 +12:34:56', '2024-12-20 +12:34:56'), +('2024-12-20 12:34 56', '2024-12-20 12:34 56', '2024-12-20 12:34 56'), +('2024-12-20 12 34', '2024-12-20 12 34', '2024-12-20 12 34'); SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_prefix_boundaries; ➤ SECOND(v)[-6,8,0] ¦ SECOND(c)[-6,8,0] ¦ SECOND(t)[-6,8,0] 𝄀 12 ¦ 12 ¦ 12 𝄀 @@ -195,5 +210,14 @@ SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_prefix_boundaries; 56 ¦ 56 ¦ 56 𝄀 56 ¦ 56 ¦ 56 𝄀 56 ¦ 56 ¦ 56 𝄀 -56 ¦ 56 ¦ 56 +56 ¦ 56 ¦ 56 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 𝄀 +56 ¦ 56 ¦ 56 𝄀 +56 ¦ 56 ¦ 56 𝄀 +56 ¦ 56 ¦ 56 𝄀 +56 ¦ 56 ¦ 56 𝄀 +24 ¦ 24 ¦ 24 𝄀 +24 ¦ 24 ¦ 24 DROP TABLE time_extract_prefix_boundaries; diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 7c22e7fa7f3e0..bf0c3247c807a 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -128,7 +128,13 @@ SELECT SECOND('20241220153045.123abc') AS compact_14_fraction_text_second, SECOND('20241220153045.-') AS compact_14_negative_fraction_second, SECOND('20241220153045.+') AS compact_14_positive_fraction_second, SECOND('241220153045.-') AS compact_12_negative_fraction_second, - SECOND('241220153045.+') AS compact_12_positive_fraction_second; + SECOND('241220153045.+') AS compact_12_positive_fraction_second, + SECOND('20241220153045.123-') AS compact_14_fraction_negative_suffix_second, + SECOND('20241220153045.123+') AS compact_14_fraction_positive_suffix_second, + SECOND('241220153045.123-') AS compact_12_fraction_negative_suffix_second, + SECOND('241220153045.123+') AS compact_12_fraction_positive_suffix_second, + SECOND('20241220153045.123abc-') AS compact_14_fraction_text_negative_suffix_second, + SECOND('241220153045.123abc+') AS compact_12_fraction_text_positive_suffix_second; -- TIME coercion stops after the valid clock prefix. SELECT SECOND('12:') AS trailing_colon_second, @@ -164,6 +170,15 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12/34/56', '2024-12-20 12/34/56', '2024-12-20 12/34/56'), ('2024@12@20 12@34@56', '2024@12@20 12@34@56', '2024@12@20 12@34@56'), ('2024-12-20\t12:34:56', '2024-12-20\t12:34:56', '2024-12-20\t12:34:56'), - ('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'); + ('1\t02:34:56', '1\t02:34:56', '1\t02:34:56'), + ('12:34 56', '12:34 56', '12:34 56'), + ('1 02:34 56', '1 02:34 56', '1 02:34 56'), + ('1 02 34', '1 02 34', '1 02 34'), + ('2024--12--20 12:34:56', '2024--12--20 12:34:56', '2024--12--20 12:34:56'), + ('2024/-12/-20 12:34:56', '2024/-12/-20 12:34:56', '2024/-12/-20 12:34:56'), + ('2024-12-20 -12:34:56', '2024-12-20 -12:34:56', '2024-12-20 -12:34:56'), + ('2024-12-20 +12:34:56', '2024-12-20 +12:34:56', '2024-12-20 +12:34:56'), + ('2024-12-20 12:34 56', '2024-12-20 12:34 56', '2024-12-20 12:34 56'), + ('2024-12-20 12 34', '2024-12-20 12 34', '2024-12-20 12 34'); SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_prefix_boundaries; DROP TABLE time_extract_prefix_boundaries; From 5cc42b79a8e923970b2c32120a0ee57916ce2dce Mon Sep 17 00:00:00 2001 From: daviszhen Date: Mon, 3 Aug 2026 12:03:26 +0800 Subject: [PATCH 22/25] update --- test/distributed/cases/function/func_datetime_hour.result | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index f02c3799ee7db..dd25799e7b6b7 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -6,7 +6,7 @@ SELECT HOUR('2024-12-20 15:30:45') AS result2; 15 SELECT HOUR(NOW()) AS result3; ➤ result3[-6,8,0] 𝄀 -18 +12 SELECT HOUR(CAST('15:30:45' AS TIME)) AS time_cast; ➤ time_cast[4,32,0] 𝄀 15 From eafe2e7009eb7aa7d4c91a9e813a9390f0b0716d Mon Sep 17 00:00:00 2001 From: daviszhen Date: Mon, 3 Aug 2026 15:08:19 +0800 Subject: [PATCH 23/25] update --- pkg/sql/plan/function/func_unary.go | 75 +++++++++++++------ pkg/sql/plan/function/func_unary_test.go | 60 +++++++++++++++ .../cases/function/func_datetime_hour.result | 33 ++++++++ .../cases/function/func_datetime_hour.test | 20 +++++ .../function/func_datetime_minute.result | 33 ++++++++ .../cases/function/func_datetime_minute.test | 20 +++++ .../function/func_datetime_second.result | 33 ++++++++ .../cases/function/func_datetime_second.test | 20 +++++ 8 files changed, 271 insertions(+), 23 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index 9b5fb16ac2b0e..357d78301a0cd 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4798,12 +4798,13 @@ func timeStringToFixedWithNullOnError[T types.FixedSizeTExceptStrType]( } // timeStringToClockForExtract follows MySQL's string-to-TIME coercion for -// HOUR, MINUTE, and SECOND. Its input contract is deliberately narrow: +// HOUR, MINUTE, and SECOND. Its grammar ownership is ordered deliberately: // -// - TIME and space-separated DATETIME strings return their clock fields. -// - Date-only and ISO-T date prefixes coerce as compact TIME (00:MM:YY). -// - Zero date components do not discard an otherwise valid clock. -// - Other malformed date-looking strings return NULL. +// - A single outer '-' belongs to the TIME sign. +// - An unambiguous separated DATETIME owns its date and clock fields. +// - Compact DATETIME owns only its exact numeric/fractional form. +// - Remaining input is scanned once as TIME/day-TIME/compact-TIME. +// - A complete DATE prefix is the final fallback (00:MM:YY). // // The general temporal parsers accept different grammars and must not receive // arbitrary TIME-shaped user input here. @@ -4886,7 +4887,13 @@ type timeExtractParseResult struct { func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { pos := 0 year, yearDigits, ok := mysqlVariableDigitsForExtract(str, &pos) - if !ok || yearDigits > 4 || pos >= len(str) || !mysqlDatetimePunctuationForExtract(str[pos]) { + if !ok || (yearDigits != 2 && yearDigits != 4) || pos >= len(str) || !mysqlDatetimePunctuationForExtract(str[pos]) { + return timeExtractParseResult{} + } + dateSeparator := str[pos] + // A two-digit colon prefix is a TIME first (for example, "12:34:56"), + // not a date with an invalid month. Four-digit colon dates remain valid. + if yearDigits == 2 && dateSeparator == ':' { return timeExtractParseResult{} } if yearDigits == 2 { @@ -4908,12 +4915,13 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { return timeExtractParseResult{} } result := timeExtractParseResult{matched: true} - for pos < len(str) && mysqlWhitespaceForExtract(str[pos]) { - pos++ + if !mysqlDatetimeDateForExtract(year, month, day) { + return result } - if pos < len(str) && (str[pos] == '+' || str[pos] == '-') { + for pos < len(str) && mysqlWhitespaceForExtract(str[pos]) { pos++ } + mysqlConsumeDatetimeClockSignsForExtract(str, &pos) hour, _, ok := mysqlVariableDigitsForExtract(str, &pos) if !ok { return result @@ -4949,11 +4957,10 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { } } } - // Whitespace after the clock has started terminates the separated - // DATETIME interpretation. MySQL then applies its date-prefix TIME - // coercion instead (for example, "2024-12-20 12:34 56" becomes - // 00:20:24). Outer whitespace has already been trimmed by the caller. - if pos < len(str) && mysqlWhitespaceForExtract(str[pos]) { + // Whitespace after a clock prefix or a sign followed by text terminates + // DATETIME ownership. The DATE-prefix fallback then applies its own compact + // TIME coercion. A bare suffix sign remains a valid consumed clock prefix. + if !mysqlDatetimeClockSuffixForExtract(str, pos) { return timeExtractParseResult{} } // A complete date followed by an hour or a trailing field separator is a @@ -4969,6 +4976,25 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { return result } +func mysqlConsumeDatetimeClockSignsForExtract(str string, pos *int) { + for *pos < len(str) && (str[*pos] == '+' || str[*pos] == '-') { + *pos = *pos + 1 + } +} + +func mysqlDatetimeClockSuffixForExtract(str string, pos int) bool { + if pos == len(str) { + return true + } + if mysqlWhitespaceForExtract(str[pos]) { + return false + } + if (str[pos] == '+' || str[pos] == '-') && pos+1 < len(str) { + return false + } + return true +} + func mysqlDatetimeDateForExtract(year, month, day uint64) bool { if year > 9999 || month > 12 || day > 31 { return false @@ -5045,12 +5071,6 @@ func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { return 0, 0, 0, false } - if prefix[0] == '-' { - prefix = prefix[1:] - } - if len(prefix) == 0 { - return 0, 0, 0, false - } prefix = mysqlTrimLeftWhitespaceForExtract(prefix) if len(prefix) == 0 { return 0, 0, 0, false @@ -5066,10 +5086,11 @@ func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { day := uint64(0) hasDay := false if space := mysqlFirstWhitespaceForExtract(prefix); space >= 0 { - if space > 0 && asciiDigits(prefix[:space]) { + postDay := mysqlTrimLeftWhitespaceForExtract(prefix[space:]) + if space > 0 && asciiDigits(prefix[:space]) && mysqlDayTimeClockCandidateForExtract(postDay) { day = mysqlClampedDigitsForExtract(prefix[:space], 35) hasDay = true - prefix = mysqlTrimLeftWhitespaceForExtract(prefix[space:]) + prefix = postDay if len(prefix) == 0 { return 0, 0, 0, false } @@ -5105,6 +5126,14 @@ func mysqlTimePrefixClockForExtract(str string) (uint64, uint8, uint8, bool) { return hour, minute, second, true } +func mysqlDayTimeClockCandidateForExtract(str string) bool { + digits := 0 + for digits < len(str) && str[digits] >= '0' && str[digits] <= '9' { + digits++ + } + return digits >= 2 +} + func mysqlClockFieldsForExtract(str string) (uint64, uint8, uint8, bool) { if len(str) == 0 { return 0, 0, 0, false @@ -5201,7 +5230,7 @@ func mysqlCompactTimePrefixBoundary(prefix, suffix string) bool { // digits cannot reinterpret that field. Preserve the sole exception for a // separated date shape, which is handled by the date branch (and // therefore still validates its calendar fields). - if len(prefix) != 4 || len(suffix) < 3 || !mysqlDateSeparatorForExtract(suffix[0]) { + if len(prefix) < 4 || len(suffix) < 3 || !mysqlDateSeparatorForExtract(suffix[0]) { return true } pos := 1 diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 05b0d81f44a0f..e5ace59e4339e 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4431,6 +4431,66 @@ func TestStringTimeExtractContextAwarePrefixBoundaries(t *testing.T) { } } +func TestStringTimeExtractAmbiguousPrefixOwnership(t *testing.T) { + for _, typ := range []types.T{types.T_varchar, types.T_char, types.T_text} { + t.Run(typ.String(), func(t *testing.T) { + proc := testutil.NewProcess(t) + input := NewFunctionTestInput(typ.ToType(), []string{ + // A one-digit field before whitespace remains a compact TIME prefix; + // a zero-padded post-day field owns the day-TIME grammar instead. + "1 2", "1 02", + // A colon-delimited TIME prefix must not be claimed as a two-digit + // year DATE when its apparent date components are out of range. + "12:34:56", "12:34:56 78", + // One-digit date-like fields are compact TIME, while a long compact + // prefix followed by a repeated date separator is invalid. + "1-2-3 4:5:6", "12345:", "12345-1-1 1:2:3", + // The outer sign is consumed exactly once. + "-12:34", "--12:34", + // A DATETIME clock owns a run of leading signs, but a sign followed + // by text terminates that clock and leaves the DATE-prefix coercion. + "2024-12-20 +12:34", "2024-12-20 ++12:34", + "2024-12-20 12:34:56abc", + "2024-12-20 12:34:56+abc", "2024-12-20 12:34:56-abc", + }, nil) + + for _, tc := range []struct { + name string + fn fEvalFn + expect FunctionTestResult + }{ + { + name: "hour", + fn: StringToHour, + expect: NewFunctionTestResult(types.T_uint32.ToType(), false, + []uint32{0, 26, 12, 12, 0, 1, 0, 12, 0, 12, 12, 12, 0, 0}, + []bool{false, false, false, false, false, false, true, false, true, false, false, false, false, false}), + }, + { + name: "minute", + fn: StringToMinute, + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, + []uint8{0, 0, 34, 34, 0, 23, 0, 34, 0, 34, 34, 34, 20, 20}, + []bool{false, false, false, false, false, false, true, false, true, false, false, false, false, false}), + }, + { + name: "second", + fn: StringToSecond, + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, + []uint8{1, 0, 56, 56, 1, 45, 0, 0, 0, 0, 0, 56, 24, 24}, + []bool{false, false, false, false, false, false, true, false, true, false, false, false, false, false}), + }, + } { + t.Run(tc.name, func(t *testing.T) { + ftc := NewFunctionTestCase(proc, []FunctionTestInput{input}, tc.expect, tc.fn) + success, info := ftc.Run() + require.True(t, success, info) + }) + } + }) + } +} + func TestStringTimeExtractWhitespace(t *testing.T) { for _, typ := range []types.T{types.T_char, types.T_varchar, types.T_text} { t.Run(typ.String(), func(t *testing.T) { diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index dd25799e7b6b7..163be3eab0612 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -232,3 +232,36 @@ SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_prefix_boundaries; 0 ¦ 0 ¦ 0 𝄀 0 ¦ 0 ¦ 0 DROP TABLE time_extract_prefix_boundaries; +CREATE TABLE time_extract_ambiguous_prefix_ownership(v VARCHAR(40), c CHAR(40), t TEXT); +INSERT INTO time_extract_ambiguous_prefix_ownership VALUES +('1 2', '1 2', '1 2'), +('1 02', '1 02', '1 02'), +('12:34:56', '12:34:56', '12:34:56'), +('12:34:56 78', '12:34:56 78', '12:34:56 78'), +('1-2-3 4:5:6', '1-2-3 4:5:6', '1-2-3 4:5:6'), +('12345:', '12345:', '12345:'), +('12345-1-1 1:2:3', '12345-1-1 1:2:3', '12345-1-1 1:2:3'), +('-12:34', '-12:34', '-12:34'), +('--12:34', '--12:34', '--12:34'), +('2024-12-20 +12:34', '2024-12-20 +12:34', '2024-12-20 +12:34'), +('2024-12-20 ++12:34', '2024-12-20 ++12:34', '2024-12-20 ++12:34'), +('2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc'), +('2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc'), +('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'); +SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_ambiguous_prefix_ownership; +➤ HOUR(v)[4,32,0] ¦ HOUR(c)[4,32,0] ¦ HOUR(t)[4,32,0] 𝄀 +0 ¦ 0 ¦ 0 𝄀 +26 ¦ 26 ¦ 26 𝄀 +12 ¦ 12 ¦ 12 𝄀 +12 ¦ 12 ¦ 12 𝄀 +0 ¦ 0 ¦ 0 𝄀 +1 ¦ 1 ¦ 1 𝄀 +null ¦ null ¦ null 𝄀 +12 ¦ 12 ¦ 12 𝄀 +null ¦ null ¦ null 𝄀 +12 ¦ 12 ¦ 12 𝄀 +12 ¦ 12 ¦ 12 𝄀 +12 ¦ 12 ¦ 12 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 +DROP TABLE time_extract_ambiguous_prefix_ownership; diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index 4c4a9613454dd..fda890a87e6ba 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -186,3 +186,23 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12 34', '2024-12-20 12 34', '2024-12-20 12 34'); SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_prefix_boundaries; DROP TABLE time_extract_prefix_boundaries; + +-- DATE/DATETIME/day-TIME/compact-TIME ownership uses one ordered prefix grammar. +CREATE TABLE time_extract_ambiguous_prefix_ownership(v VARCHAR(40), c CHAR(40), t TEXT); +INSERT INTO time_extract_ambiguous_prefix_ownership VALUES + ('1 2', '1 2', '1 2'), + ('1 02', '1 02', '1 02'), + ('12:34:56', '12:34:56', '12:34:56'), + ('12:34:56 78', '12:34:56 78', '12:34:56 78'), + ('1-2-3 4:5:6', '1-2-3 4:5:6', '1-2-3 4:5:6'), + ('12345:', '12345:', '12345:'), + ('12345-1-1 1:2:3', '12345-1-1 1:2:3', '12345-1-1 1:2:3'), + ('-12:34', '-12:34', '-12:34'), + ('--12:34', '--12:34', '--12:34'), + ('2024-12-20 +12:34', '2024-12-20 +12:34', '2024-12-20 +12:34'), + ('2024-12-20 ++12:34', '2024-12-20 ++12:34', '2024-12-20 ++12:34'), + ('2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc'), + ('2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc'), + ('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'); +SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_ambiguous_prefix_ownership; +DROP TABLE time_extract_ambiguous_prefix_ownership; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index cc247232c7468..321952290dd07 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -220,3 +220,36 @@ SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_prefix_boundaries; 20 ¦ 20 ¦ 20 𝄀 20 ¦ 20 ¦ 20 DROP TABLE time_extract_prefix_boundaries; +CREATE TABLE time_extract_ambiguous_prefix_ownership(v VARCHAR(40), c CHAR(40), t TEXT); +INSERT INTO time_extract_ambiguous_prefix_ownership VALUES +('1 2', '1 2', '1 2'), +('1 02', '1 02', '1 02'), +('12:34:56', '12:34:56', '12:34:56'), +('12:34:56 78', '12:34:56 78', '12:34:56 78'), +('1-2-3 4:5:6', '1-2-3 4:5:6', '1-2-3 4:5:6'), +('12345:', '12345:', '12345:'), +('12345-1-1 1:2:3', '12345-1-1 1:2:3', '12345-1-1 1:2:3'), +('-12:34', '-12:34', '-12:34'), +('--12:34', '--12:34', '--12:34'), +('2024-12-20 +12:34', '2024-12-20 +12:34', '2024-12-20 +12:34'), +('2024-12-20 ++12:34', '2024-12-20 ++12:34', '2024-12-20 ++12:34'), +('2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc'), +('2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc'), +('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'); +SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_ambiguous_prefix_ownership; +➤ MINUTE(v)[-6,8,0] ¦ MINUTE(c)[-6,8,0] ¦ MINUTE(t)[-6,8,0] 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 𝄀 +34 ¦ 34 ¦ 34 𝄀 +34 ¦ 34 ¦ 34 𝄀 +0 ¦ 0 ¦ 0 𝄀 +23 ¦ 23 ¦ 23 𝄀 +null ¦ null ¦ null 𝄀 +34 ¦ 34 ¦ 34 𝄀 +null ¦ null ¦ null 𝄀 +34 ¦ 34 ¦ 34 𝄀 +34 ¦ 34 ¦ 34 𝄀 +34 ¦ 34 ¦ 34 𝄀 +20 ¦ 20 ¦ 20 𝄀 +20 ¦ 20 ¦ 20 +DROP TABLE time_extract_ambiguous_prefix_ownership; diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index ddf2e06aa8aee..603eb2a9b6358 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -182,3 +182,23 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12 34', '2024-12-20 12 34', '2024-12-20 12 34'); SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_prefix_boundaries; DROP TABLE time_extract_prefix_boundaries; + +-- DATE/DATETIME/day-TIME/compact-TIME ownership uses one ordered prefix grammar. +CREATE TABLE time_extract_ambiguous_prefix_ownership(v VARCHAR(40), c CHAR(40), t TEXT); +INSERT INTO time_extract_ambiguous_prefix_ownership VALUES + ('1 2', '1 2', '1 2'), + ('1 02', '1 02', '1 02'), + ('12:34:56', '12:34:56', '12:34:56'), + ('12:34:56 78', '12:34:56 78', '12:34:56 78'), + ('1-2-3 4:5:6', '1-2-3 4:5:6', '1-2-3 4:5:6'), + ('12345:', '12345:', '12345:'), + ('12345-1-1 1:2:3', '12345-1-1 1:2:3', '12345-1-1 1:2:3'), + ('-12:34', '-12:34', '-12:34'), + ('--12:34', '--12:34', '--12:34'), + ('2024-12-20 +12:34', '2024-12-20 +12:34', '2024-12-20 +12:34'), + ('2024-12-20 ++12:34', '2024-12-20 ++12:34', '2024-12-20 ++12:34'), + ('2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc'), + ('2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc'), + ('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'); +SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_ambiguous_prefix_ownership; +DROP TABLE time_extract_ambiguous_prefix_ownership; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index b59d7819b64d9..d310e5e03f7dd 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -221,3 +221,36 @@ SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_prefix_boundaries; 24 ¦ 24 ¦ 24 𝄀 24 ¦ 24 ¦ 24 DROP TABLE time_extract_prefix_boundaries; +CREATE TABLE time_extract_ambiguous_prefix_ownership(v VARCHAR(40), c CHAR(40), t TEXT); +INSERT INTO time_extract_ambiguous_prefix_ownership VALUES +('1 2', '1 2', '1 2'), +('1 02', '1 02', '1 02'), +('12:34:56', '12:34:56', '12:34:56'), +('12:34:56 78', '12:34:56 78', '12:34:56 78'), +('1-2-3 4:5:6', '1-2-3 4:5:6', '1-2-3 4:5:6'), +('12345:', '12345:', '12345:'), +('12345-1-1 1:2:3', '12345-1-1 1:2:3', '12345-1-1 1:2:3'), +('-12:34', '-12:34', '-12:34'), +('--12:34', '--12:34', '--12:34'), +('2024-12-20 +12:34', '2024-12-20 +12:34', '2024-12-20 +12:34'), +('2024-12-20 ++12:34', '2024-12-20 ++12:34', '2024-12-20 ++12:34'), +('2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc'), +('2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc'), +('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'); +SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_ambiguous_prefix_ownership; +➤ SECOND(v)[-6,8,0] ¦ SECOND(c)[-6,8,0] ¦ SECOND(t)[-6,8,0] 𝄀 +1 ¦ 1 ¦ 1 𝄀 +0 ¦ 0 ¦ 0 𝄀 +56 ¦ 56 ¦ 56 𝄀 +56 ¦ 56 ¦ 56 𝄀 +1 ¦ 1 ¦ 1 𝄀 +45 ¦ 45 ¦ 45 𝄀 +null ¦ null ¦ null 𝄀 +0 ¦ 0 ¦ 0 𝄀 +null ¦ null ¦ null 𝄀 +0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 𝄀 +56 ¦ 56 ¦ 56 𝄀 +24 ¦ 24 ¦ 24 𝄀 +24 ¦ 24 ¦ 24 +DROP TABLE time_extract_ambiguous_prefix_ownership; diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index bf0c3247c807a..70e8b6a296f92 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -182,3 +182,23 @@ INSERT INTO time_extract_prefix_boundaries VALUES ('2024-12-20 12 34', '2024-12-20 12 34', '2024-12-20 12 34'); SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_prefix_boundaries; DROP TABLE time_extract_prefix_boundaries; + +-- DATE/DATETIME/day-TIME/compact-TIME ownership uses one ordered prefix grammar. +CREATE TABLE time_extract_ambiguous_prefix_ownership(v VARCHAR(40), c CHAR(40), t TEXT); +INSERT INTO time_extract_ambiguous_prefix_ownership VALUES + ('1 2', '1 2', '1 2'), + ('1 02', '1 02', '1 02'), + ('12:34:56', '12:34:56', '12:34:56'), + ('12:34:56 78', '12:34:56 78', '12:34:56 78'), + ('1-2-3 4:5:6', '1-2-3 4:5:6', '1-2-3 4:5:6'), + ('12345:', '12345:', '12345:'), + ('12345-1-1 1:2:3', '12345-1-1 1:2:3', '12345-1-1 1:2:3'), + ('-12:34', '-12:34', '-12:34'), + ('--12:34', '--12:34', '--12:34'), + ('2024-12-20 +12:34', '2024-12-20 +12:34', '2024-12-20 +12:34'), + ('2024-12-20 ++12:34', '2024-12-20 ++12:34', '2024-12-20 ++12:34'), + ('2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc'), + ('2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc'), + ('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'); +SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_ambiguous_prefix_ownership; +DROP TABLE time_extract_ambiguous_prefix_ownership; From 21c67bd0c85c8569a4788cf9de35022b04e2e83a Mon Sep 17 00:00:00 2001 From: daviszhen Date: Mon, 3 Aug 2026 18:25:43 +0800 Subject: [PATCH 24/25] update --- pkg/sql/plan/function/func_unary.go | 15 +++-- pkg/sql/plan/function/func_unary_test.go | 55 +++++++++++++++++++ .../cases/function/func_datetime_hour.result | 22 +++++++- .../cases/function/func_datetime_hour.test | 12 +++- .../function/func_datetime_minute.result | 22 +++++++- .../cases/function/func_datetime_minute.test | 12 +++- .../function/func_datetime_second.result | 22 +++++++- .../cases/function/func_datetime_second.test | 12 +++- 8 files changed, 160 insertions(+), 12 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index 357d78301a0cd..dd51e6263a6c1 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4887,7 +4887,7 @@ type timeExtractParseResult struct { func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { pos := 0 year, yearDigits, ok := mysqlVariableDigitsForExtract(str, &pos) - if !ok || (yearDigits != 2 && yearDigits != 4) || pos >= len(str) || !mysqlDatetimePunctuationForExtract(str[pos]) { + if !ok || yearDigits < 2 || yearDigits > 4 || pos >= len(str) || !mysqlDatetimePunctuationForExtract(str[pos]) { return timeExtractParseResult{} } dateSeparator := str[pos] @@ -4957,9 +4957,9 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { } } } - // Whitespace after a clock prefix or a sign followed by text terminates - // DATETIME ownership. The DATE-prefix fallback then applies its own compact - // TIME coercion. A bare suffix sign remains a valid consumed clock prefix. + // Whitespace or an unconsumed sign after a clock prefix terminates DATETIME + // ownership. The DATE-prefix fallback then applies its own compact TIME + // coercion. if !mysqlDatetimeClockSuffixForExtract(str, pos) { return timeExtractParseResult{} } @@ -4989,7 +4989,7 @@ func mysqlDatetimeClockSuffixForExtract(str string, pos int) bool { if mysqlWhitespaceForExtract(str[pos]) { return false } - if (str[pos] == '+' || str[pos] == '-') && pos+1 < len(str) { + if str[pos] == '+' || str[pos] == '-' { return false } return true @@ -5131,7 +5131,10 @@ func mysqlDayTimeClockCandidateForExtract(str string) bool { for digits < len(str) && str[digits] >= '0' && str[digits] <= '9' { digits++ } - return digits >= 2 + // A zero-padded/multi-digit field owns day-TIME even without a separator. + // A one-digit field owns it only when ':' proves that the field is an hour; + // otherwise inputs such as "1 2" retain the already consumed compact prefix. + return digits >= 2 || (digits == 1 && digits < len(str) && str[digits] == ':') } func mysqlClockFieldsForExtract(str string) (uint64, uint8, uint8, bool) { diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index e5ace59e4339e..4ec68dd275972 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4491,6 +4491,61 @@ func TestStringTimeExtractAmbiguousPrefixOwnership(t *testing.T) { } } +func TestStringTimeExtractReviewerGrammarBoundaries(t *testing.T) { + for _, typ := range []types.T{types.T_varchar, types.T_char, types.T_text} { + t.Run(typ.String(), func(t *testing.T) { + proc := testutil.NewProcess(t) + input := NewFunctionTestInput(typ.ToType(), []string{ + // A bare one-digit post-space field remains a compact prefix. A + // following clock separator transfers ownership to day-TIME. + "1 2", "1 2:3", "1 2:3:4", + "12 3", "12 3:4", "12 3:4:5", + // Separated DATETIME accepts two through four year digits. One + // digit remains compact TIME, and five digits remain invalid. + "1-2-3 4:5:6", "12-2-3 4:5:6", "123-2-3 4:5:6", + "1234-2-3 4:5:6", "12345-2-3 4:5:6", + // An unconsumed trailing sign after a complete clock terminates + // DATETIME ownership and leaves the DATE-prefix fallback. + "2024-12-20 12:34:56+", "2024-12-20 12:34:56-", + }, nil) + + for _, tc := range []struct { + name string + fn fEvalFn + expect FunctionTestResult + }{ + { + name: "hour", + fn: StringToHour, + expect: NewFunctionTestResult(types.T_uint32.ToType(), false, + []uint32{0, 26, 26, 0, 291, 291, 0, 4, 4, 4, 0, 0, 0}, + []bool{false, false, false, false, false, false, false, false, false, false, true, false, false}), + }, + { + name: "minute", + fn: StringToMinute, + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, + []uint8{0, 3, 3, 0, 4, 4, 0, 5, 5, 5, 0, 20, 20}, + []bool{false, false, false, false, false, false, false, false, false, false, true, false, false}), + }, + { + name: "second", + fn: StringToSecond, + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, + []uint8{1, 0, 4, 12, 0, 5, 1, 6, 6, 6, 0, 24, 24}, + []bool{false, false, false, false, false, false, false, false, false, false, true, false, false}), + }, + } { + t.Run(tc.name, func(t *testing.T) { + ftc := NewFunctionTestCase(proc, []FunctionTestInput{input}, tc.expect, tc.fn) + success, info := ftc.Run() + require.True(t, success, info) + }) + } + }) + } +} + func TestStringTimeExtractWhitespace(t *testing.T) { for _, typ := range []types.T{types.T_char, types.T_varchar, types.T_text} { t.Run(typ.String(), func(t *testing.T) { diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index 163be3eab0612..1418d6888c850 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -247,7 +247,17 @@ INSERT INTO time_extract_ambiguous_prefix_ownership VALUES ('2024-12-20 ++12:34', '2024-12-20 ++12:34', '2024-12-20 ++12:34'), ('2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc'), ('2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc'), -('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'); +('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'), +('1 2:3', '1 2:3', '1 2:3'), +('1 2:3:4', '1 2:3:4', '1 2:3:4'), +('12 3', '12 3', '12 3'), +('12 3:4', '12 3:4', '12 3:4'), +('12 3:4:5', '12 3:4:5', '12 3:4:5'), +('12-2-3 4:5:6', '12-2-3 4:5:6', '12-2-3 4:5:6'), +('123-2-3 4:5:6', '123-2-3 4:5:6', '123-2-3 4:5:6'), +('1234-2-3 4:5:6', '1234-2-3 4:5:6', '1234-2-3 4:5:6'), +('2024-12-20 12:34:56+', '2024-12-20 12:34:56+', '2024-12-20 12:34:56+'), +('2024-12-20 12:34:56-', '2024-12-20 12:34:56-', '2024-12-20 12:34:56-'); SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_ambiguous_prefix_ownership; ➤ HOUR(v)[4,32,0] ¦ HOUR(c)[4,32,0] ¦ HOUR(t)[4,32,0] 𝄀 0 ¦ 0 ¦ 0 𝄀 @@ -263,5 +273,15 @@ null ¦ null ¦ null 𝄀 12 ¦ 12 ¦ 12 𝄀 12 ¦ 12 ¦ 12 𝄀 0 ¦ 0 ¦ 0 𝄀 +0 ¦ 0 ¦ 0 𝄀 +26 ¦ 26 ¦ 26 𝄀 +26 ¦ 26 ¦ 26 𝄀 +0 ¦ 0 ¦ 0 𝄀 +291 ¦ 291 ¦ 291 𝄀 +291 ¦ 291 ¦ 291 𝄀 +4 ¦ 4 ¦ 4 𝄀 +4 ¦ 4 ¦ 4 𝄀 +4 ¦ 4 ¦ 4 𝄀 +0 ¦ 0 ¦ 0 𝄀 0 ¦ 0 ¦ 0 DROP TABLE time_extract_ambiguous_prefix_ownership; diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index fda890a87e6ba..bc03d93e9e9a2 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -203,6 +203,16 @@ INSERT INTO time_extract_ambiguous_prefix_ownership VALUES ('2024-12-20 ++12:34', '2024-12-20 ++12:34', '2024-12-20 ++12:34'), ('2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc'), ('2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc'), - ('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'); + ('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'), + ('1 2:3', '1 2:3', '1 2:3'), + ('1 2:3:4', '1 2:3:4', '1 2:3:4'), + ('12 3', '12 3', '12 3'), + ('12 3:4', '12 3:4', '12 3:4'), + ('12 3:4:5', '12 3:4:5', '12 3:4:5'), + ('12-2-3 4:5:6', '12-2-3 4:5:6', '12-2-3 4:5:6'), + ('123-2-3 4:5:6', '123-2-3 4:5:6', '123-2-3 4:5:6'), + ('1234-2-3 4:5:6', '1234-2-3 4:5:6', '1234-2-3 4:5:6'), + ('2024-12-20 12:34:56+', '2024-12-20 12:34:56+', '2024-12-20 12:34:56+'), + ('2024-12-20 12:34:56-', '2024-12-20 12:34:56-', '2024-12-20 12:34:56-'); SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_ambiguous_prefix_ownership; DROP TABLE time_extract_ambiguous_prefix_ownership; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index 321952290dd07..c25d27557a3d8 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -235,7 +235,17 @@ INSERT INTO time_extract_ambiguous_prefix_ownership VALUES ('2024-12-20 ++12:34', '2024-12-20 ++12:34', '2024-12-20 ++12:34'), ('2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc'), ('2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc'), -('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'); +('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'), +('1 2:3', '1 2:3', '1 2:3'), +('1 2:3:4', '1 2:3:4', '1 2:3:4'), +('12 3', '12 3', '12 3'), +('12 3:4', '12 3:4', '12 3:4'), +('12 3:4:5', '12 3:4:5', '12 3:4:5'), +('12-2-3 4:5:6', '12-2-3 4:5:6', '12-2-3 4:5:6'), +('123-2-3 4:5:6', '123-2-3 4:5:6', '123-2-3 4:5:6'), +('1234-2-3 4:5:6', '1234-2-3 4:5:6', '1234-2-3 4:5:6'), +('2024-12-20 12:34:56+', '2024-12-20 12:34:56+', '2024-12-20 12:34:56+'), +('2024-12-20 12:34:56-', '2024-12-20 12:34:56-', '2024-12-20 12:34:56-'); SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_ambiguous_prefix_ownership; ➤ MINUTE(v)[-6,8,0] ¦ MINUTE(c)[-6,8,0] ¦ MINUTE(t)[-6,8,0] 𝄀 0 ¦ 0 ¦ 0 𝄀 @@ -251,5 +261,15 @@ null ¦ null ¦ null 𝄀 34 ¦ 34 ¦ 34 𝄀 34 ¦ 34 ¦ 34 𝄀 20 ¦ 20 ¦ 20 𝄀 +20 ¦ 20 ¦ 20 𝄀 +3 ¦ 3 ¦ 3 𝄀 +3 ¦ 3 ¦ 3 𝄀 +0 ¦ 0 ¦ 0 𝄀 +4 ¦ 4 ¦ 4 𝄀 +4 ¦ 4 ¦ 4 𝄀 +5 ¦ 5 ¦ 5 𝄀 +5 ¦ 5 ¦ 5 𝄀 +5 ¦ 5 ¦ 5 𝄀 +20 ¦ 20 ¦ 20 𝄀 20 ¦ 20 ¦ 20 DROP TABLE time_extract_ambiguous_prefix_ownership; diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index 603eb2a9b6358..2a9f04c4a68df 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -199,6 +199,16 @@ INSERT INTO time_extract_ambiguous_prefix_ownership VALUES ('2024-12-20 ++12:34', '2024-12-20 ++12:34', '2024-12-20 ++12:34'), ('2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc'), ('2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc'), - ('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'); + ('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'), + ('1 2:3', '1 2:3', '1 2:3'), + ('1 2:3:4', '1 2:3:4', '1 2:3:4'), + ('12 3', '12 3', '12 3'), + ('12 3:4', '12 3:4', '12 3:4'), + ('12 3:4:5', '12 3:4:5', '12 3:4:5'), + ('12-2-3 4:5:6', '12-2-3 4:5:6', '12-2-3 4:5:6'), + ('123-2-3 4:5:6', '123-2-3 4:5:6', '123-2-3 4:5:6'), + ('1234-2-3 4:5:6', '1234-2-3 4:5:6', '1234-2-3 4:5:6'), + ('2024-12-20 12:34:56+', '2024-12-20 12:34:56+', '2024-12-20 12:34:56+'), + ('2024-12-20 12:34:56-', '2024-12-20 12:34:56-', '2024-12-20 12:34:56-'); SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_ambiguous_prefix_ownership; DROP TABLE time_extract_ambiguous_prefix_ownership; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index d310e5e03f7dd..86a73699419cb 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -236,7 +236,17 @@ INSERT INTO time_extract_ambiguous_prefix_ownership VALUES ('2024-12-20 ++12:34', '2024-12-20 ++12:34', '2024-12-20 ++12:34'), ('2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc'), ('2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc'), -('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'); +('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'), +('1 2:3', '1 2:3', '1 2:3'), +('1 2:3:4', '1 2:3:4', '1 2:3:4'), +('12 3', '12 3', '12 3'), +('12 3:4', '12 3:4', '12 3:4'), +('12 3:4:5', '12 3:4:5', '12 3:4:5'), +('12-2-3 4:5:6', '12-2-3 4:5:6', '12-2-3 4:5:6'), +('123-2-3 4:5:6', '123-2-3 4:5:6', '123-2-3 4:5:6'), +('1234-2-3 4:5:6', '1234-2-3 4:5:6', '1234-2-3 4:5:6'), +('2024-12-20 12:34:56+', '2024-12-20 12:34:56+', '2024-12-20 12:34:56+'), +('2024-12-20 12:34:56-', '2024-12-20 12:34:56-', '2024-12-20 12:34:56-'); SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_ambiguous_prefix_ownership; ➤ SECOND(v)[-6,8,0] ¦ SECOND(c)[-6,8,0] ¦ SECOND(t)[-6,8,0] 𝄀 1 ¦ 1 ¦ 1 𝄀 @@ -252,5 +262,15 @@ null ¦ null ¦ null 𝄀 0 ¦ 0 ¦ 0 𝄀 56 ¦ 56 ¦ 56 𝄀 24 ¦ 24 ¦ 24 𝄀 +24 ¦ 24 ¦ 24 𝄀 +0 ¦ 0 ¦ 0 𝄀 +4 ¦ 4 ¦ 4 𝄀 +12 ¦ 12 ¦ 12 𝄀 +0 ¦ 0 ¦ 0 𝄀 +5 ¦ 5 ¦ 5 𝄀 +6 ¦ 6 ¦ 6 𝄀 +6 ¦ 6 ¦ 6 𝄀 +6 ¦ 6 ¦ 6 𝄀 +24 ¦ 24 ¦ 24 𝄀 24 ¦ 24 ¦ 24 DROP TABLE time_extract_ambiguous_prefix_ownership; diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 70e8b6a296f92..63dc4694c804b 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -199,6 +199,16 @@ INSERT INTO time_extract_ambiguous_prefix_ownership VALUES ('2024-12-20 ++12:34', '2024-12-20 ++12:34', '2024-12-20 ++12:34'), ('2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc', '2024-12-20 12:34:56abc'), ('2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc', '2024-12-20 12:34:56+abc'), - ('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'); + ('2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc', '2024-12-20 12:34:56-abc'), + ('1 2:3', '1 2:3', '1 2:3'), + ('1 2:3:4', '1 2:3:4', '1 2:3:4'), + ('12 3', '12 3', '12 3'), + ('12 3:4', '12 3:4', '12 3:4'), + ('12 3:4:5', '12 3:4:5', '12 3:4:5'), + ('12-2-3 4:5:6', '12-2-3 4:5:6', '12-2-3 4:5:6'), + ('123-2-3 4:5:6', '123-2-3 4:5:6', '123-2-3 4:5:6'), + ('1234-2-3 4:5:6', '1234-2-3 4:5:6', '1234-2-3 4:5:6'), + ('2024-12-20 12:34:56+', '2024-12-20 12:34:56+', '2024-12-20 12:34:56+'), + ('2024-12-20 12:34:56-', '2024-12-20 12:34:56-', '2024-12-20 12:34:56-'); SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_ambiguous_prefix_ownership; DROP TABLE time_extract_ambiguous_prefix_ownership; From c33a3162e278122768d75cfd82d74f8bf65b625a Mon Sep 17 00:00:00 2001 From: daviszhen Date: Mon, 3 Aug 2026 22:56:04 +0800 Subject: [PATCH 25/25] update --- pkg/sql/plan/function/func_unary.go | 85 ++++++++++++++++++- pkg/sql/plan/function/func_unary_test.go | 58 +++++++++++++ .../cases/function/func_datetime_hour.result | 16 ++++ .../cases/function/func_datetime_hour.test | 12 +++ .../function/func_datetime_minute.result | 16 ++++ .../cases/function/func_datetime_minute.test | 12 +++ .../function/func_datetime_second.result | 16 ++++ .../cases/function/func_datetime_second.test | 12 +++ 8 files changed, 223 insertions(+), 4 deletions(-) diff --git a/pkg/sql/plan/function/func_unary.go b/pkg/sql/plan/function/func_unary.go index dd51e6263a6c1..36804d1b3576c 100644 --- a/pkg/sql/plan/function/func_unary.go +++ b/pkg/sql/plan/function/func_unary.go @@ -4774,8 +4774,8 @@ func timeStringToFixedWithNullOnError[T types.FixedSizeTExceptStrType]( } strVal, null := strParam.GetStrValue(i) - str := strings.TrimSpace(functionUtil.QuickBytesToStr(strVal)) - if null || len(str) == 0 { + str := functionUtil.QuickBytesToStr(strVal) + if null || mysqlAllWhitespaceForExtract(str) { if err := rs.Append(zero, true); err != nil { return err } @@ -4809,6 +4809,12 @@ func timeStringToFixedWithNullOnError[T types.FixedSizeTExceptStrType]( // The general temporal parsers accept different grammars and must not receive // arbitrary TIME-shaped user input here. func timeStringToClockForExtract(str string) (uint64, uint8, uint8, bool) { + // Leading whitespace belongs to the TIME scanner. Keep trailing whitespace: + // it can decide whether an otherwise ambiguous prefix is DATETIME or TIME. + str = mysqlTrimLeftWhitespaceForExtract(str) + if len(str) == 0 { + return 0, 0, 0, false + } if str[0] == '-' { str = str[1:] if len(str) == 0 { @@ -4887,7 +4893,13 @@ type timeExtractParseResult struct { func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { pos := 0 year, yearDigits, ok := mysqlVariableDigitsForExtract(str, &pos) - if !ok || yearDigits < 2 || yearDigits > 4 || pos >= len(str) || !mysqlDatetimePunctuationForExtract(str[pos]) { + if !ok || yearDigits > 4 || pos >= len(str) || !mysqlDatetimePunctuationForExtract(str[pos]) { + return timeExtractParseResult{} + } + // A one-digit year shares a prefix with compact TIME. It belongs to + // separated DATETIME only when the complete following clock is an + // unambiguous HH:MM:SS spelling; otherwise the TIME scanner owns it. + if yearDigits == 1 && !mysqlOneDigitYearDatetimeClockShapeForExtract(str, pos) { return timeExtractParseResult{} } dateSeparator := str[pos] @@ -4916,6 +4928,11 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { } result := timeExtractParseResult{matched: true} if !mysqlDatetimeDateForExtract(year, month, day) { + // A padded three-digit date-shaped prefix is a TIME prefix in MySQL. + // Do not let the DATETIME classifier discard its consumed clock fields. + if yearDigits == 3 && mysqlEndsWithWhitespaceForExtract(str) { + return timeExtractParseResult{} + } return result } for pos < len(str) && mysqlWhitespaceForExtract(str[pos]) { @@ -4976,6 +4993,44 @@ func mysqlSeparatedDatetimeClockForExtract(str string) timeExtractParseResult { return result } +func mysqlOneDigitYearDatetimeClockShapeForExtract(str string, pos int) bool { + // pos points at the separator immediately after the one-digit year. + for pos < len(str) && mysqlDatetimePunctuationForExtract(str[pos]) { + pos++ + } + if _, _, ok := mysqlVariableDigitsForExtract(str, &pos); !ok || + pos >= len(str) || !mysqlDatetimePunctuationForExtract(str[pos]) { + return false + } + for pos < len(str) && mysqlDatetimePunctuationForExtract(str[pos]) { + pos++ + } + if _, _, ok := mysqlVariableDigitsForExtract(str, &pos); !ok || + pos >= len(str) || !mysqlWhitespaceForExtract(str[pos]) { + return false + } + for pos < len(str) && mysqlWhitespaceForExtract(str[pos]) { + pos++ + } + mysqlConsumeDatetimeClockSignsForExtract(str, &pos) + if _, digits, ok := mysqlVariableDigitsForExtract(str, &pos); !ok || digits != 2 || + pos >= len(str) || !mysqlDatetimePunctuationForExtract(str[pos]) { + return false + } + for pos < len(str) && mysqlDatetimePunctuationForExtract(str[pos]) { + pos++ + } + if _, digits, ok := mysqlVariableDigitsForExtract(str, &pos); !ok || digits != 2 || + pos >= len(str) || !mysqlDatetimePunctuationForExtract(str[pos]) { + return false + } + for pos < len(str) && mysqlDatetimePunctuationForExtract(str[pos]) { + pos++ + } + _, digits, ok := mysqlVariableDigitsForExtract(str, &pos) + return ok && digits == 2 +} + func mysqlConsumeDatetimeClockSignsForExtract(str string, pos *int) { for *pos < len(str) && (str[*pos] == '+' || str[*pos] == '-') { *pos = *pos + 1 @@ -4987,7 +5042,13 @@ func mysqlDatetimeClockSuffixForExtract(str string, pos int) bool { return true } if mysqlWhitespaceForExtract(str[pos]) { - return false + // A trailing whitespace-only suffix terminates a complete DATETIME + // clock. Whitespace followed by another token transfers ownership back + // to the TIME/date-prefix grammar. + for pos < len(str) && mysqlWhitespaceForExtract(str[pos]) { + pos++ + } + return pos == len(str) } if str[pos] == '+' || str[pos] == '-' { return false @@ -5048,6 +5109,22 @@ func mysqlWhitespaceForExtract(c byte) bool { } } +func mysqlAllWhitespaceForExtract(str string) bool { + if len(str) == 0 { + return true + } + for i := 0; i < len(str); i++ { + if !mysqlWhitespaceForExtract(str[i]) { + return false + } + } + return true +} + +func mysqlEndsWithWhitespaceForExtract(str string) bool { + return len(str) > 0 && mysqlWhitespaceForExtract(str[len(str)-1]) +} + func mysqlTrimLeftWhitespaceForExtract(str string) string { pos := 0 for pos < len(str) && mysqlWhitespaceForExtract(str[pos]) { diff --git a/pkg/sql/plan/function/func_unary_test.go b/pkg/sql/plan/function/func_unary_test.go index 4ec68dd275972..55fcb3dd1f115 100644 --- a/pkg/sql/plan/function/func_unary_test.go +++ b/pkg/sql/plan/function/func_unary_test.go @@ -4546,6 +4546,64 @@ func TestStringTimeExtractReviewerGrammarBoundaries(t *testing.T) { } } +func TestStringTimeExtractOneDigitYearAndWhitespaceOwnership(t *testing.T) { + for _, typ := range []types.T{types.T_varchar, types.T_char, types.T_text} { + t.Run(typ.String(), func(t *testing.T) { + proc := testutil.NewProcess(t) + input := NewFunctionTestInput(typ.ToType(), []string{ + // One-digit years are DATETIME only with a complete two-digit + // clock. Every one-/two-digit clock-width boundary otherwise + // retains compact-TIME ownership. + "1-1-1 1:2:3", "1-1-1 01:2:3", "1-1-1 1:02:3", "1-1-1 1:2:03", + "1-1-1 01:02:3", "1-1-1 01:2:03", "1-1-1 1:02:03", "1-1-1 01:02:03", + // The same width contract applies to the zero-year spelling. + "0-1-1 1:2:3", "0-1-1 12:34:56", + // Trailing whitespace is part of the ordered grammar. It must not + // be removed before the date-shaped TIME prefix is classified. + "123:34:56 78", "123:34:56 78 ", "\t123:34:56 78\t", + "838:59:59 78", "838:59:59 78 ", "\t838:59:59 78\t", + // A valid DATETIME remains owned with trailing whitespace; this is + // the nearest control for the padded invalid date-shaped prefixes. + "2024-12-20 12:34:56 ", + }, nil) + + for _, tc := range []struct { + name string + fn fEvalFn + expect FunctionTestResult + }{ + { + name: "hour", + fn: StringToHour, + expect: NewFunctionTestResult(types.T_uint32.ToType(), false, + []uint32{0, 0, 0, 0, 0, 0, 0, 1, 0, 12, 0, 123, 123, 0, 838, 838, 12}, + []bool{false, false, false, false, false, false, false, false, false, false, true, false, false, true, false, false, false}), + }, + { + name: "minute", + fn: StringToMinute, + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, + []uint8{0, 0, 0, 0, 0, 0, 0, 2, 0, 34, 0, 34, 34, 0, 59, 59, 34}, + []bool{false, false, false, false, false, false, false, false, false, false, true, false, false, true, false, false, false}), + }, + { + name: "second", + fn: StringToSecond, + expect: NewFunctionTestResult(types.T_uint8.ToType(), false, + []uint8{1, 1, 1, 1, 1, 1, 1, 3, 0, 56, 0, 56, 56, 0, 59, 59, 56}, + []bool{false, false, false, false, false, false, false, false, false, false, true, false, false, true, false, false, false}), + }, + } { + t.Run(tc.name, func(t *testing.T) { + ftc := NewFunctionTestCase(proc, []FunctionTestInput{input}, tc.expect, tc.fn) + success, info := ftc.Run() + require.True(t, success, info) + }) + } + }) + } +} + func TestStringTimeExtractWhitespace(t *testing.T) { for _, typ := range []types.T{types.T_char, types.T_varchar, types.T_text} { t.Run(typ.String(), func(t *testing.T) { diff --git a/test/distributed/cases/function/func_datetime_hour.result b/test/distributed/cases/function/func_datetime_hour.result index 1418d6888c850..a259f1d0c1ba8 100644 --- a/test/distributed/cases/function/func_datetime_hour.result +++ b/test/distributed/cases/function/func_datetime_hour.result @@ -256,6 +256,10 @@ INSERT INTO time_extract_ambiguous_prefix_ownership VALUES ('12-2-3 4:5:6', '12-2-3 4:5:6', '12-2-3 4:5:6'), ('123-2-3 4:5:6', '123-2-3 4:5:6', '123-2-3 4:5:6'), ('1234-2-3 4:5:6', '1234-2-3 4:5:6', '1234-2-3 4:5:6'), +('1-1-1 1:2:3', '1-1-1 1:2:3', '1-1-1 1:2:3'), +('1-1-1 01:02:03', '1-1-1 01:02:03', '1-1-1 01:02:03'), +('1-2-3 12:34:56', '1-2-3 12:34:56', '1-2-3 12:34:56'), +('0-1-1 12:34:56', '0-1-1 12:34:56', '0-1-1 12:34:56'), ('2024-12-20 12:34:56+', '2024-12-20 12:34:56+', '2024-12-20 12:34:56+'), ('2024-12-20 12:34:56-', '2024-12-20 12:34:56-', '2024-12-20 12:34:56-'); SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_ambiguous_prefix_ownership; @@ -283,5 +287,17 @@ null ¦ null ¦ null 𝄀 4 ¦ 4 ¦ 4 𝄀 4 ¦ 4 ¦ 4 𝄀 0 ¦ 0 ¦ 0 𝄀 +1 ¦ 1 ¦ 1 𝄀 +12 ¦ 12 ¦ 12 𝄀 +12 ¦ 12 ¦ 12 𝄀 +0 ¦ 0 ¦ 0 𝄀 0 ¦ 0 ¦ 0 DROP TABLE time_extract_ambiguous_prefix_ownership; +SELECT HOUR('123:34:56 78') AS unpadded_123_hour, +HOUR('123:34:56 78 ') AS padded_123_hour, +HOUR('\t123:34:56 78\t') AS tab_padded_123_hour, +HOUR('838:59:59 78') AS unpadded_838_hour, +HOUR('838:59:59 78 ') AS padded_838_hour, +HOUR('\t838:59:59 78\t') AS tab_padded_838_hour; +➤ unpadded_123_hour[4,32,0] ¦ padded_123_hour[4,32,0] ¦ tab_padded_123_hour[4,32,0] ¦ unpadded_838_hour[4,32,0] ¦ padded_838_hour[4,32,0] ¦ tab_padded_838_hour[4,32,0] 𝄀 +null ¦ 123 ¦ 123 ¦ null ¦ 838 ¦ 838 diff --git a/test/distributed/cases/function/func_datetime_hour.test b/test/distributed/cases/function/func_datetime_hour.test index bc03d93e9e9a2..1f5a75a8c4224 100644 --- a/test/distributed/cases/function/func_datetime_hour.test +++ b/test/distributed/cases/function/func_datetime_hour.test @@ -212,7 +212,19 @@ INSERT INTO time_extract_ambiguous_prefix_ownership VALUES ('12-2-3 4:5:6', '12-2-3 4:5:6', '12-2-3 4:5:6'), ('123-2-3 4:5:6', '123-2-3 4:5:6', '123-2-3 4:5:6'), ('1234-2-3 4:5:6', '1234-2-3 4:5:6', '1234-2-3 4:5:6'), + ('1-1-1 1:2:3', '1-1-1 1:2:3', '1-1-1 1:2:3'), + ('1-1-1 01:02:03', '1-1-1 01:02:03', '1-1-1 01:02:03'), + ('1-2-3 12:34:56', '1-2-3 12:34:56', '1-2-3 12:34:56'), + ('0-1-1 12:34:56', '0-1-1 12:34:56', '0-1-1 12:34:56'), ('2024-12-20 12:34:56+', '2024-12-20 12:34:56+', '2024-12-20 12:34:56+'), ('2024-12-20 12:34:56-', '2024-12-20 12:34:56-', '2024-12-20 12:34:56-'); SELECT HOUR(v), HOUR(c), HOUR(t) FROM time_extract_ambiguous_prefix_ownership; DROP TABLE time_extract_ambiguous_prefix_ownership; + +-- Preserve nonempty trailing whitespace until the ordered parser assigns its owner. +SELECT HOUR('123:34:56 78') AS unpadded_123_hour, + HOUR('123:34:56 78 ') AS padded_123_hour, + HOUR('\t123:34:56 78\t') AS tab_padded_123_hour, + HOUR('838:59:59 78') AS unpadded_838_hour, + HOUR('838:59:59 78 ') AS padded_838_hour, + HOUR('\t838:59:59 78\t') AS tab_padded_838_hour; diff --git a/test/distributed/cases/function/func_datetime_minute.result b/test/distributed/cases/function/func_datetime_minute.result index c25d27557a3d8..731bea23fe8de 100644 --- a/test/distributed/cases/function/func_datetime_minute.result +++ b/test/distributed/cases/function/func_datetime_minute.result @@ -244,6 +244,10 @@ INSERT INTO time_extract_ambiguous_prefix_ownership VALUES ('12-2-3 4:5:6', '12-2-3 4:5:6', '12-2-3 4:5:6'), ('123-2-3 4:5:6', '123-2-3 4:5:6', '123-2-3 4:5:6'), ('1234-2-3 4:5:6', '1234-2-3 4:5:6', '1234-2-3 4:5:6'), +('1-1-1 1:2:3', '1-1-1 1:2:3', '1-1-1 1:2:3'), +('1-1-1 01:02:03', '1-1-1 01:02:03', '1-1-1 01:02:03'), +('1-2-3 12:34:56', '1-2-3 12:34:56', '1-2-3 12:34:56'), +('0-1-1 12:34:56', '0-1-1 12:34:56', '0-1-1 12:34:56'), ('2024-12-20 12:34:56+', '2024-12-20 12:34:56+', '2024-12-20 12:34:56+'), ('2024-12-20 12:34:56-', '2024-12-20 12:34:56-', '2024-12-20 12:34:56-'); SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_ambiguous_prefix_ownership; @@ -270,6 +274,18 @@ null ¦ null ¦ null 𝄀 5 ¦ 5 ¦ 5 𝄀 5 ¦ 5 ¦ 5 𝄀 5 ¦ 5 ¦ 5 𝄀 +0 ¦ 0 ¦ 0 𝄀 +2 ¦ 2 ¦ 2 𝄀 +34 ¦ 34 ¦ 34 𝄀 +34 ¦ 34 ¦ 34 𝄀 20 ¦ 20 ¦ 20 𝄀 20 ¦ 20 ¦ 20 DROP TABLE time_extract_ambiguous_prefix_ownership; +SELECT MINUTE('123:34:56 78') AS unpadded_123_minute, +MINUTE('123:34:56 78 ') AS padded_123_minute, +MINUTE('\t123:34:56 78\t') AS tab_padded_123_minute, +MINUTE('838:59:59 78') AS unpadded_838_minute, +MINUTE('838:59:59 78 ') AS padded_838_minute, +MINUTE('\t838:59:59 78\t') AS tab_padded_838_minute; +➤ unpadded_123_minute[-6,8,0] ¦ padded_123_minute[-6,8,0] ¦ tab_padded_123_minute[-6,8,0] ¦ unpadded_838_minute[-6,8,0] ¦ padded_838_minute[-6,8,0] ¦ tab_padded_838_minute[-6,8,0] 𝄀 +null ¦ 34 ¦ 34 ¦ null ¦ 59 ¦ 59 diff --git a/test/distributed/cases/function/func_datetime_minute.test b/test/distributed/cases/function/func_datetime_minute.test index 2a9f04c4a68df..a95885c20ab1b 100644 --- a/test/distributed/cases/function/func_datetime_minute.test +++ b/test/distributed/cases/function/func_datetime_minute.test @@ -208,7 +208,19 @@ INSERT INTO time_extract_ambiguous_prefix_ownership VALUES ('12-2-3 4:5:6', '12-2-3 4:5:6', '12-2-3 4:5:6'), ('123-2-3 4:5:6', '123-2-3 4:5:6', '123-2-3 4:5:6'), ('1234-2-3 4:5:6', '1234-2-3 4:5:6', '1234-2-3 4:5:6'), + ('1-1-1 1:2:3', '1-1-1 1:2:3', '1-1-1 1:2:3'), + ('1-1-1 01:02:03', '1-1-1 01:02:03', '1-1-1 01:02:03'), + ('1-2-3 12:34:56', '1-2-3 12:34:56', '1-2-3 12:34:56'), + ('0-1-1 12:34:56', '0-1-1 12:34:56', '0-1-1 12:34:56'), ('2024-12-20 12:34:56+', '2024-12-20 12:34:56+', '2024-12-20 12:34:56+'), ('2024-12-20 12:34:56-', '2024-12-20 12:34:56-', '2024-12-20 12:34:56-'); SELECT MINUTE(v), MINUTE(c), MINUTE(t) FROM time_extract_ambiguous_prefix_ownership; DROP TABLE time_extract_ambiguous_prefix_ownership; + +-- Preserve nonempty trailing whitespace until the ordered parser assigns its owner. +SELECT MINUTE('123:34:56 78') AS unpadded_123_minute, + MINUTE('123:34:56 78 ') AS padded_123_minute, + MINUTE('\t123:34:56 78\t') AS tab_padded_123_minute, + MINUTE('838:59:59 78') AS unpadded_838_minute, + MINUTE('838:59:59 78 ') AS padded_838_minute, + MINUTE('\t838:59:59 78\t') AS tab_padded_838_minute; diff --git a/test/distributed/cases/function/func_datetime_second.result b/test/distributed/cases/function/func_datetime_second.result index 86a73699419cb..e66a06e1a9354 100644 --- a/test/distributed/cases/function/func_datetime_second.result +++ b/test/distributed/cases/function/func_datetime_second.result @@ -245,6 +245,10 @@ INSERT INTO time_extract_ambiguous_prefix_ownership VALUES ('12-2-3 4:5:6', '12-2-3 4:5:6', '12-2-3 4:5:6'), ('123-2-3 4:5:6', '123-2-3 4:5:6', '123-2-3 4:5:6'), ('1234-2-3 4:5:6', '1234-2-3 4:5:6', '1234-2-3 4:5:6'), +('1-1-1 1:2:3', '1-1-1 1:2:3', '1-1-1 1:2:3'), +('1-1-1 01:02:03', '1-1-1 01:02:03', '1-1-1 01:02:03'), +('1-2-3 12:34:56', '1-2-3 12:34:56', '1-2-3 12:34:56'), +('0-1-1 12:34:56', '0-1-1 12:34:56', '0-1-1 12:34:56'), ('2024-12-20 12:34:56+', '2024-12-20 12:34:56+', '2024-12-20 12:34:56+'), ('2024-12-20 12:34:56-', '2024-12-20 12:34:56-', '2024-12-20 12:34:56-'); SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_ambiguous_prefix_ownership; @@ -271,6 +275,18 @@ null ¦ null ¦ null 𝄀 6 ¦ 6 ¦ 6 𝄀 6 ¦ 6 ¦ 6 𝄀 6 ¦ 6 ¦ 6 𝄀 +1 ¦ 1 ¦ 1 𝄀 +3 ¦ 3 ¦ 3 𝄀 +56 ¦ 56 ¦ 56 𝄀 +56 ¦ 56 ¦ 56 𝄀 24 ¦ 24 ¦ 24 𝄀 24 ¦ 24 ¦ 24 DROP TABLE time_extract_ambiguous_prefix_ownership; +SELECT SECOND('123:34:56 78') AS unpadded_123_second, +SECOND('123:34:56 78 ') AS padded_123_second, +SECOND('\t123:34:56 78\t') AS tab_padded_123_second, +SECOND('838:59:59 78') AS unpadded_838_second, +SECOND('838:59:59 78 ') AS padded_838_second, +SECOND('\t838:59:59 78\t') AS tab_padded_838_second; +➤ unpadded_123_second[-6,8,0] ¦ padded_123_second[-6,8,0] ¦ tab_padded_123_second[-6,8,0] ¦ unpadded_838_second[-6,8,0] ¦ padded_838_second[-6,8,0] ¦ tab_padded_838_second[-6,8,0] 𝄀 +null ¦ 56 ¦ 56 ¦ null ¦ 59 ¦ 59 diff --git a/test/distributed/cases/function/func_datetime_second.test b/test/distributed/cases/function/func_datetime_second.test index 63dc4694c804b..f8ea2b4e29914 100644 --- a/test/distributed/cases/function/func_datetime_second.test +++ b/test/distributed/cases/function/func_datetime_second.test @@ -208,7 +208,19 @@ INSERT INTO time_extract_ambiguous_prefix_ownership VALUES ('12-2-3 4:5:6', '12-2-3 4:5:6', '12-2-3 4:5:6'), ('123-2-3 4:5:6', '123-2-3 4:5:6', '123-2-3 4:5:6'), ('1234-2-3 4:5:6', '1234-2-3 4:5:6', '1234-2-3 4:5:6'), + ('1-1-1 1:2:3', '1-1-1 1:2:3', '1-1-1 1:2:3'), + ('1-1-1 01:02:03', '1-1-1 01:02:03', '1-1-1 01:02:03'), + ('1-2-3 12:34:56', '1-2-3 12:34:56', '1-2-3 12:34:56'), + ('0-1-1 12:34:56', '0-1-1 12:34:56', '0-1-1 12:34:56'), ('2024-12-20 12:34:56+', '2024-12-20 12:34:56+', '2024-12-20 12:34:56+'), ('2024-12-20 12:34:56-', '2024-12-20 12:34:56-', '2024-12-20 12:34:56-'); SELECT SECOND(v), SECOND(c), SECOND(t) FROM time_extract_ambiguous_prefix_ownership; DROP TABLE time_extract_ambiguous_prefix_ownership; + +-- Preserve nonempty trailing whitespace until the ordered parser assigns its owner. +SELECT SECOND('123:34:56 78') AS unpadded_123_second, + SECOND('123:34:56 78 ') AS padded_123_second, + SECOND('\t123:34:56 78\t') AS tab_padded_123_second, + SECOND('838:59:59 78') AS unpadded_838_second, + SECOND('838:59:59 78 ') AS padded_838_second, + SECOND('\t838:59:59 78\t') AS tab_padded_838_second;