diff --git a/docs/generated/sql/functions.md b/docs/generated/sql/functions.md index da03e66b2922..2af2ea2e9486 100644 --- a/docs/generated/sql/functions.md +++ b/docs/generated/sql/functions.md @@ -661,8 +661,14 @@ has no relationship with the commit order of concurrent transactions.

Immutable to_char(interval: interval) → string

Convert an interval to a string assuming the Postgres IntervalStyle.

Immutable +to_char(interval: interval, format: string) → string

Convert an interval to a string using the given format.

+
Stable to_char(timestamp: timestamp) → string

Convert an timestamp to a string assuming the ISO, MDY DateStyle.

Immutable +to_char(timestamp: timestamp, format: string) → string

Convert an timestamp to a string using the given format.

+
Stable +to_char(timestamptz: timestamptz, format: string) → string

Convert a timestamp with time zone to a string using the given format.

+
Stable to_timestamp(timestamp: float) → timestamptz

Convert Unix epoch (seconds since 1970-01-01 00:00:00+00) to timestamp with time zone.

Immutable transaction_timestamp() → date

Returns the time of the current transaction.

diff --git a/pkg/BUILD.bazel b/pkg/BUILD.bazel index f08182ce2828..b09f34e912cd 100644 --- a/pkg/BUILD.bazel +++ b/pkg/BUILD.bazel @@ -597,6 +597,7 @@ ALL_TESTS = [ "//pkg/util/timetz:timetz_test", "//pkg/util/timeutil/pgdate:pgdate_test", "//pkg/util/timeutil:timeutil_test", + "//pkg/util/tochar:tochar_test", "//pkg/util/tracing/collector:collector_test", "//pkg/util/tracing/grpcinterceptor:grpcinterceptor_test", "//pkg/util/tracing/service:service_test", @@ -2043,6 +2044,8 @@ GO_TARGETS = [ "//pkg/util/timeutil/ptp:ptp", "//pkg/util/timeutil:timeutil", "//pkg/util/timeutil:timeutil_test", + "//pkg/util/tochar:tochar", + "//pkg/util/tochar:tochar_test", "//pkg/util/tracing/collector:collector", "//pkg/util/tracing/collector:collector_test", "//pkg/util/tracing/grpcinterceptor:grpcinterceptor", @@ -2988,6 +2991,7 @@ GET_X_DATA_TARGETS = [ "//pkg/util/timeutil/gen:get_x_data", "//pkg/util/timeutil/pgdate:get_x_data", "//pkg/util/timeutil/ptp:get_x_data", + "//pkg/util/tochar:get_x_data", "//pkg/util/tracing:get_x_data", "//pkg/util/tracing/collector:get_x_data", "//pkg/util/tracing/grpcinterceptor:get_x_data", diff --git a/pkg/sql/logictest/testdata/logic_test/interval b/pkg/sql/logictest/testdata/logic_test/interval index 38026cb2f0ee..83f99a035d85 100644 --- a/pkg/sql/logictest/testdata/logic_test/interval +++ b/pkg/sql/logictest/testdata/logic_test/interval @@ -624,6 +624,55 @@ SELECT i, i::INTERVAL FROM interval_parsing ORDER BY pk -10 years 22 months -1 day 01:02:03 -8-2 -1 +1:02:03 -10 years 22 months -1 day -01:02:03 -8-2 -1 -1:02:03 +# Could not find any regress tests for to_char in PG, so we're making our own up! +statement ok +CREATE TABLE intvl_tbl (id SERIAL, d1 INTERVAL); +INSERT INTO intvl_tbl (d1) VALUES + ('355 months 40 days 123:45:12'), + ('-400 months -30 days -100:12:13') + +query T +SELECT to_char(d1, 'Y,YYY YYYY YYY YY Y CC Q MM WW DDD DD J') + FROM intvl_tbl ORDER BY id +---- +0,029 0029 029 29 9 00 3 07 1528 10690 40 1731873 +0,-33 -0033 -033 -33 -3 00 0 -04 -1717 -12030 -30 1708823 + +query T +SELECT to_char(d1, 'FMY,YYY FMYYYY FMYYY FMYY FMY FMCC FMQ FMMM FMWW FMDDD FMDD FMJ') + FROM intvl_tbl ORDER BY id +---- +0,029 29 29 29 9 0 3 7 1528 10690 40 1731873 +0,-33 -33 -33 -33 -3 0 0 -4 -1717 -12030 -30 1708823 + +query T +SELECT to_char(d1, 'HH HH12 HH24 MI SS SSSS') + FROM intvl_tbl ORDER BY id +---- +03 03 123 45 12 445512 +-04 -04 -100 -12 -13 -360733 + +query T +SELECT to_char(d1, E'"HH:MI:SS is" HH:MI:SS "\\"text between quote marks\\""') + FROM intvl_tbl ORDER BY id +---- +HH:MI:SS is 03:45:12 "text between quote marks" +HH:MI:SS is -04:-12:-13 "text between quote marks" + +query T +SELECT to_char(d1, 'HH24--text--MI--text--SS') + FROM intvl_tbl ORDER BY id +---- +123--text--45--text--12 +-100--text---12--text---13 + +query T +SELECT to_char(d1, 'YYYYTH YYYYth Jth') + FROM intvl_tbl ORDER BY id +---- +0029TH 0029th 1731873rd +-0033RD -0033rd 1708823rd + # Test intervalstyle being respected in pg_indexes. statement ok diff --git a/pkg/sql/logictest/testdata/logic_test/pg_catalog b/pkg/sql/logictest/testdata/logic_test/pg_catalog index 771b8e1999de..cca5b17ba028 100644 --- a/pkg/sql/logictest/testdata/logic_test/pg_catalog +++ b/pkg/sql/logictest/testdata/logic_test/pg_catalog @@ -3251,7 +3251,7 @@ FROM pg_proc p JOIN pg_type t ON t.typinput = p.oid WHERE t.typname = '_int4' ---- -2018 array_in array_in +2021 array_in array_in ## #16285 ## int2vectors should be 0-indexed @@ -3287,7 +3287,7 @@ SELECT cur_max_builtin_oid FROM [SELECT max(oid) as cur_max_builtin_oid FROM pg_ query TT SELECT proname, oid FROM pg_catalog.pg_proc WHERE oid = $cur_max_builtin_oid ---- -to_regtype 2038 +to_regtype 2041 ## Ensure that unnest works with oid wrapper arrays diff --git a/pkg/sql/logictest/testdata/logic_test/timestamp b/pkg/sql/logictest/testdata/logic_test/timestamp index 9ceaeca7cb78..7a4e6a5fb443 100644 --- a/pkg/sql/logictest/testdata/logic_test/timestamp +++ b/pkg/sql/logictest/testdata/logic_test/timestamp @@ -550,3 +550,218 @@ query T SELECT (t2 - t1) FROM t ---- 1 day + +# Rough translation of the to_char tests from PostgreSQL. +statement ok +CREATE TABLE TIMESTAMPTZ_TBL (id SERIAL, d1 timestamp(2) with time zone); +INSERT INTO TIMESTAMPTZ_TBL (d1) VALUES + ('1997-06-10 17:32:01 -07:00'), + ('2001-09-22T18:19:20'); + +query T +SELECT to_char(d1, 'DAY Day day DY Dy dy MONTH Month month MON Mon mon') + FROM TIMESTAMPTZ_TBL ORDER BY id +---- +wednesday Wednesday wednesday wed Wed wed JUNE June june JUN Jun jun +saturday Saturday saturday sat Sat sat SEPTEMBER September september SEP Sep sep + +query T +SELECT to_char(d1, 'FMDAY FMDay FMday FMMONTH FMMonth FMmonth') + FROM TIMESTAMPTZ_TBL ORDER BY id +---- +wednesday Wednesday wednesday JUNE June june +saturday Saturday saturday SEPTEMBER September september + +query T +SELECT to_char(d1, 'Y,YYY YYYY YYY YY Y CC Q MM WW DDD DD D J') + FROM TIMESTAMPTZ_TBL ORDER BY id +---- +1,997 1997 997 97 7 20 2 06 24 162 11 4 2450611 +2,001 2001 001 01 1 21 3 09 38 265 22 7 2452175 + +query T +SELECT to_char(d1, 'FMY,YYY FMYYYY FMYYY FMYY FMY FMCC FMQ FMMM FMWW FMDDD FMDD FMD FMJ') + FROM TIMESTAMPTZ_TBL ORDER BY id +---- +1,997 1997 997 97 7 20 2 6 24 162 11 4 2450611 +2,001 2001 1 1 1 21 3 9 38 265 22 7 2452175 + +query T +SELECT to_char(d1::timestamp, 'FMY,YYY FMYYYY FMYYY FMYY FMY FMCC FMQ FMMM FMWW FMDDD FMDD FMD FMJ') + FROM TIMESTAMPTZ_TBL ORDER BY id +---- +1,997 1997 997 97 7 20 2 6 24 162 11 4 2450611 +2,001 2001 1 1 1 21 3 9 38 265 22 7 2452175 + +query T +SELECT to_char(d1, 'HH HH12 HH24 MI SS SSSS') + FROM TIMESTAMPTZ_TBL ORDER BY id +---- +12 12 00 32 01 1921 +06 06 18 19 20 65960 + +query T +SELECT to_char(d1, E'"HH:MI:SS is" HH:MI:SS "\\"text between quote marks\\""') + FROM TIMESTAMPTZ_TBL ORDER BY id +---- +HH:MI:SS is 12:32:01 "text between quote marks" +HH:MI:SS is 06:19:20 "text between quote marks" + +query T +SELECT to_char(d1, 'HH24--text--MI--text--SS') + FROM TIMESTAMPTZ_TBL ORDER BY id +---- +00--text--32--text--01 +18--text--19--text--20 + +query T +SELECT to_char(d1, 'YYYYTH YYYYth Jth') + FROM TIMESTAMPTZ_TBL ORDER BY id +---- +1997TH 1997th 2450611th +2001ST 2001st 2452175th + +query T +SELECT to_char(d1, 'YYYY A.D. YYYY a.d. YYYY bc HH:MI:SS P.M. HH:MI:SS p.m. HH:MI:SS pm') + FROM TIMESTAMPTZ_TBL ORDER BY id +---- +1997 A.D. 1997 a.d. 1997 ad 12:32:01 A.M. 12:32:01 a.m. 12:32:01 am +2001 A.D. 2001 a.d. 2001 ad 06:19:20 P.M. 06:19:20 p.m. 06:19:20 pm + +query T +SELECT to_char(d1, 'IYYY IYY IY I IW IDDD ID') + FROM TIMESTAMPTZ_TBL ORDER BY id +---- +1997 997 97 7 24 164 3 +2001 001 01 1 38 265 6 + +query T +SELECT to_char(d1, 'FMIYYY FMIYY FMIY FMI FMIW FMIDDD FMID') + FROM TIMESTAMPTZ_TBL ORDER BY id +---- +1997 997 97 7 24 164 3 +2001 1 1 1 38 265 6 + +query T +SELECT to_char(d, 'FF1 FF2 FF3 FF4 FF5 FF6 ff1 ff2 ff3 ff4 ff5 ff6 MS US') + FROM (VALUES + ('2018-11-02 12:34:56'::timestamptz), + ('2018-11-02 12:34:56.78'::timestamptz), + ('2018-11-02 12:34:56.78901'::timestamptz), + ('2018-11-02 12:34:56.78901234'::timestamptz) + ) d(d) +---- +0 00 000 0000 00000 000000 0 00 000 0000 00000 000000 000 000000 +7 78 780 7800 78000 780000 7 78 780 7800 78000 780000 780 780000 +7 78 789 7890 78901 789010 7 78 789 7890 78901 789010 789 789010 +7 78 789 7890 78901 789012 7 78 789 7890 78901 789012 789 789012 + +query TT +SET timezone = '00:00'; +SELECT to_char(now(), 'OF') as of_t, to_char(now(), 'TZH:TZM') as "TZH:TZM"; +---- ++00 +00:00 + +query TT +SET timezone = '+02:00'; +SELECT to_char(now(), 'OF') as of_t, to_char(now(), 'TZH:TZM') as "TZH:TZM"; +---- +-02 -02:00 + +query TT +SET timezone = '-13:00'; +SELECT to_char(now(), 'OF') as of_t, to_char(now(), 'TZH:TZM') as "TZH:TZM"; +---- ++13 +13:00 + +query TT +SET timezone = '-00:30'; +SELECT to_char(now(), 'OF') as of_t, to_char(now(), 'TZH:TZM') as "TZH:TZM"; +---- ++00:30 +00:30 + +query TT +SET timezone = '00:30'; +SELECT to_char(now(), 'OF') as of_t, to_char(now(), 'TZH:TZM') as "TZH:TZM"; +---- +-00:30 -00:30 + +query TT +SET timezone = '-04:30'; +SELECT to_char(now(), 'OF') as of_t, to_char(now(), 'TZH:TZM') as "TZH:TZM"; +---- ++04:30 +04:30 + +query TT +SET timezone = '04:30'; +SELECT to_char(now(), 'OF') as of_t, to_char(now(), 'TZH:TZM') as "TZH:TZM"; +---- +-04:30 -04:30 + +query TT +SET timezone = '-04:15'; +SELECT to_char(now(), 'OF') as of_t, to_char(now(), 'TZH:TZM') as "TZH:TZM"; +---- ++04:15 +04:15 + +query TT +SET timezone = '04:15'; +SELECT to_char(now(), 'OF') as of_t, to_char(now(), 'TZH:TZM') as "TZH:TZM"; +---- +-04:15 -04:15 + +query TT +RESET timezone; +-- Check of, tzh, tzm with various zone offsets. +SET timezone = '00:00'; +SELECT to_char(now(), 'of') as of_t, to_char(now(), 'tzh:tzm') as "tzh:tzm"; +---- ++00 +00:00 + +query TT +SET timezone = '+02:00'; +SELECT to_char(now(), 'of') as of_t, to_char(now(), 'tzh:tzm') as "tzh:tzm"; +---- +-02 -02:00 + +query TT +SET timezone = '-13:00'; +SELECT to_char(now(), 'of') as of_t, to_char(now(), 'tzh:tzm') as "tzh:tzm"; +---- ++13 +13:00 + +query TT +SET timezone = '-00:30'; +SELECT to_char(now(), 'of') as of_t, to_char(now(), 'tzh:tzm') as "tzh:tzm"; +---- ++00:30 +00:30 + +query TT +SET timezone = '00:30'; +SELECT to_char(now(), 'of') as of_t, to_char(now(), 'tzh:tzm') as "tzh:tzm"; +---- +-00:30 -00:30 + +query TT +SET timezone = '-04:30'; +SELECT to_char(now(), 'of') as of_t, to_char(now(), 'tzh:tzm') as "tzh:tzm"; +---- ++04:30 +04:30 + +query TT +SET timezone = '04:30'; +SELECT to_char(now(), 'of') as of_t, to_char(now(), 'tzh:tzm') as "tzh:tzm"; +---- +-04:30 -04:30 + +query TT +SET timezone = '-04:15'; +SELECT to_char(now(), 'of') as of_t, to_char(now(), 'tzh:tzm') as "tzh:tzm"; +---- ++04:15 +04:15 + +query TT +SET timezone = '04:15'; +SELECT to_char(now(), 'of') as of_t, to_char(now(), 'tzh:tzm') as "tzh:tzm"; +---- +-04:15 -04:15 diff --git a/pkg/sql/sem/builtins/BUILD.bazel b/pkg/sql/sem/builtins/BUILD.bazel index 813a7a48dadc..9a4c2ea93aed 100644 --- a/pkg/sql/sem/builtins/BUILD.bazel +++ b/pkg/sql/sem/builtins/BUILD.bazel @@ -109,6 +109,7 @@ go_library( "//pkg/util/timetz", "//pkg/util/timeutil", "//pkg/util/timeutil/pgdate", + "//pkg/util/tochar", "//pkg/util/tracing", "//pkg/util/tracing/tracingpb", "//pkg/util/trigram", diff --git a/pkg/sql/sem/builtins/builtins.go b/pkg/sql/sem/builtins/builtins.go index 2b1ecc8d5eb5..6eb32d8eda2f 100644 --- a/pkg/sql/sem/builtins/builtins.go +++ b/pkg/sql/sem/builtins/builtins.go @@ -88,6 +88,7 @@ import ( "github.com/cockroachdb/cockroach/pkg/util/timetz" "github.com/cockroachdb/cockroach/pkg/util/timeutil" "github.com/cockroachdb/cockroach/pkg/util/timeutil/pgdate" + "github.com/cockroachdb/cockroach/pkg/util/tochar" "github.com/cockroachdb/cockroach/pkg/util/tracing" "github.com/cockroachdb/cockroach/pkg/util/tracing/tracingpb" "github.com/cockroachdb/cockroach/pkg/util/trigram" @@ -2436,6 +2437,42 @@ var regularBuiltins = map[string]builtinDefinition{ Info: "Convert an timestamp to a string assuming the ISO, MDY DateStyle.", Volatility: volatility.Immutable, }, + tree.Overload{ + Types: tree.ArgTypes{{"interval", types.Interval}, {"format", types.String}}, + ReturnType: tree.FixedReturnType(types.String), + Fn: func(_ *eval.Context, args tree.Datums) (tree.Datum, error) { + d := tree.MustBeDInterval(args[0]) + f := tree.MustBeDString(args[1]) + s, err := tochar.DurationToChar(d.Duration, string(f)) + return tree.NewDString(s), err + }, + Info: "Convert an interval to a string using the given format.", + Volatility: volatility.Stable, + }, + tree.Overload{ + Types: tree.ArgTypes{{"timestamp", types.Timestamp}, {"format", types.String}}, + ReturnType: tree.FixedReturnType(types.String), + Fn: func(_ *eval.Context, args tree.Datums) (tree.Datum, error) { + ts := tree.MustBeDTimestamp(args[0]) + f := tree.MustBeDString(args[1]) + s, err := tochar.TimeToChar(ts.Time, string(f)) + return tree.NewDString(s), err + }, + Info: "Convert an timestamp to a string using the given format.", + Volatility: volatility.Stable, + }, + tree.Overload{ + Types: tree.ArgTypes{{"timestamptz", types.TimestampTZ}, {"format", types.String}}, + ReturnType: tree.FixedReturnType(types.String), + Fn: func(_ *eval.Context, args tree.Datums) (tree.Datum, error) { + ts := tree.MustBeDTimestampTZ(args[0]) + f := tree.MustBeDString(args[1]) + s, err := tochar.TimeToChar(ts.Time, string(f)) + return tree.NewDString(s), err + }, + Info: "Convert a timestamp with time zone to a string using the given format.", + Volatility: volatility.Stable, + }, tree.Overload{ Types: tree.ArgTypes{{"date", types.Date}}, ReturnType: tree.FixedReturnType(types.String), @@ -9130,24 +9167,6 @@ func extractTimeSpanFromTimeOfDay(t timeofday.TimeOfDay, timeSpan string) (tree. } } -// dateToJulianDay is based on the date2j function in PostgreSQL 10.5. -func dateToJulianDay(year int, month int, day int) int { - if month > 2 { - month++ - year += 4800 - } else { - month += 13 - year += 4799 - } - - century := year / 100 - jd := year*365 - 32167 - jd += year/4 - century + century/4 - jd += 7834*month/256 + day - - return jd -} - func extractTimeSpanFromTimestampTZ( ctx *eval.Context, fromTime time.Time, timeSpan string, ) (tree.Datum, error) { @@ -9274,7 +9293,7 @@ func extractTimeSpanFromTimestamp( return tree.NewDFloat(tree.DFloat(fromTime.YearDay())), nil case "julian": - julianDay := float64(dateToJulianDay(fromTime.Year(), int(fromTime.Month()), fromTime.Day())) + + julianDay := float64(pgdate.DateToJulianDay(fromTime.Year(), int(fromTime.Month()), fromTime.Day())) + (float64(fromTime.Hour()*duration.SecsPerHour+fromTime.Minute()*duration.SecsPerMinute+fromTime.Second())+ float64(fromTime.Nanosecond())/float64(time.Second))/duration.SecsPerDay return tree.NewDFloat(tree.DFloat(julianDay)), nil diff --git a/pkg/sql/sem/tree/BUILD.bazel b/pkg/sql/sem/tree/BUILD.bazel index cdb40f077de8..e8dd40e5c1b5 100644 --- a/pkg/sql/sem/tree/BUILD.bazel +++ b/pkg/sql/sem/tree/BUILD.bazel @@ -59,7 +59,6 @@ go_library( "import.go", "indexed_vars.go", "insert.go", - "interval.go", "name_part.go", "name_resolution.go", "object_name.go", @@ -181,7 +180,6 @@ go_test( "function_definition_test.go", "function_name_test.go", "indexed_vars_test.go", - "interval_test.go", "json_test.go", "main_test.go", "name_part_test.go", diff --git a/pkg/sql/sem/tree/datum.go b/pkg/sql/sem/tree/datum.go index a6a48548a485..1009fa932fdd 100644 --- a/pkg/sql/sem/tree/datum.go +++ b/pkg/sql/sem/tree/datum.go @@ -20,7 +20,6 @@ import ( "strconv" "strings" "time" - "unicode" "unicode/utf8" "unsafe" @@ -3107,7 +3106,7 @@ func ParseDIntervalWithTypeMetadata( func ParseIntervalWithTypeMetadata( style duration.IntervalStyle, s string, itm types.IntervalTypeMetadata, ) (duration.Duration, error) { - d, err := parseInterval(style, s, itm) + d, err := duration.ParseInterval(style, s, itm) if err != nil { return d, err } @@ -3115,48 +3114,6 @@ func ParseIntervalWithTypeMetadata( return d, nil } -func parseInterval( - style duration.IntervalStyle, s string, itm types.IntervalTypeMetadata, -) (duration.Duration, error) { - // At this time the only supported interval formats are: - // - SQL standard. - // - Postgres compatible. - // - iso8601 format (with designators only), see interval.go for - // sources of documentation. - // - Golang time.parseDuration compatible. - - // If it's a blank string, exit early. - if len(s) == 0 { - return duration.Duration{}, MakeParseError(s, types.Interval, nil) - } - if s[0] == 'P' { - // If it has a leading P we're most likely working with an iso8601 - // interval. - dur, err := iso8601ToDuration(s) - if err != nil { - return duration.Duration{}, MakeParseError(s, types.Interval, err) - } - return dur, nil - } - if strings.IndexFunc(s, unicode.IsLetter) == -1 { - // If it has no letter, then we're most likely working with a SQL standard - // interval, as both postgres and golang have letter(s) and iso8601 has been tested. - dur, err := sqlStdToDuration(s, itm) - if err != nil { - return duration.Duration{}, MakeParseError(s, types.Interval, err) - } - return dur, nil - } - - // We're either a postgres string or a Go duration. - // Our postgres syntax parser also supports golang, so just use that for both. - dur, err := parseDuration(style, s, itm) - if err != nil { - return duration.Duration{}, MakeParseError(s, types.Interval, err) - } - return dur, nil -} - // ResolvedType implements the TypedExpr interface. func (*DInterval) ResolvedType() *types.T { return types.Interval diff --git a/pkg/testutils/lint/lint_test.go b/pkg/testutils/lint/lint_test.go index 641016ddd5fd..dcbeae391771 100644 --- a/pkg/testutils/lint/lint_test.go +++ b/pkg/testutils/lint/lint_test.go @@ -1693,6 +1693,10 @@ func TestLint(t *testing.T) { // The Observability Service doesn't want this blunt rule. stream.GrepNot("pkg/obsservice.*error strings should not be capitalized or end with punctuation or a newline"), + // The constants here are copied from PG. + stream.GrepNot("pkg/util/tochar/constants.go.*don't use ALL_CAPS in Go names"), + stream.GrepNot("pkg/util/tochar/constants.go.*don't use underscores in Go names"), + stream.GrepNot("pkg/sql/job_exec_context_test_util.go.*exported method ExtendedEvalContext returns unexported type"), stream.GrepNot("pkg/sql/job_exec_context_test_util.go.*exported method SessionDataMutatorIterator returns unexported type"), diff --git a/pkg/util/duration/BUILD.bazel b/pkg/util/duration/BUILD.bazel index aeebc17e5416..866538eea1bc 100644 --- a/pkg/util/duration/BUILD.bazel +++ b/pkg/util/duration/BUILD.bazel @@ -5,13 +5,17 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") go_library( name = "duration", - srcs = ["duration.go"], + srcs = [ + "duration.go", + "parse.go", + ], embed = [":duration_go_proto"], importpath = "github.com/cockroachdb/cockroach/pkg/util/duration", visibility = ["//visibility:public"], deps = [ "//pkg/sql/pgwire/pgcode", "//pkg/sql/pgwire/pgerror", + "//pkg/sql/types", "//pkg/util/arith", "@com_github_cockroachdb_apd_v3//:apd", "@com_github_cockroachdb_errors//:errors", @@ -21,10 +25,15 @@ go_library( go_test( name = "duration_test", size = "small", - srcs = ["duration_test.go"], + srcs = [ + "duration_test.go", + "parse_test.go", + ], args = ["-test.timeout=55s"], embed = [":duration"], deps = [ + "//pkg/sql/types", + "//pkg/util/leaktest", "//pkg/util/log", "//pkg/util/timeutil", "@com_github_stretchr_testify//require", diff --git a/pkg/util/duration/duration.go b/pkg/util/duration/duration.go index 5a162b435917..ea0d34dee9a3 100644 --- a/pkg/util/duration/duration.go +++ b/pkg/util/duration/duration.go @@ -17,10 +17,12 @@ import ( "math/big" "strings" "time" + "unicode" "github.com/cockroachdb/apd/v3" "github.com/cockroachdb/cockroach/pkg/sql/pgwire/pgcode" "github.com/cockroachdb/cockroach/pkg/sql/pgwire/pgerror" + "github.com/cockroachdb/cockroach/pkg/sql/types" "github.com/cockroachdb/cockroach/pkg/util/arith" "github.com/cockroachdb/errors" ) @@ -1024,3 +1026,55 @@ func Truncate(d time.Duration, r time.Duration) time.Duration { } return d - (d % r) } + +// ParseInterval parses the given interval in the given style. +func ParseInterval( + style IntervalStyle, s string, itm types.IntervalTypeMetadata, +) (Duration, error) { + // At this time the only supported interval formats are: + // - SQL standard. + // - Postgres compatible. + // - iso8601 format (with designators only), see interval.go for + // sources of documentation. + // - Golang time.parseDuration compatible. + + // If it's a blank string, exit early. + if len(s) == 0 { + return Duration{}, makeParseError(s, types.Interval, nil) + } + if s[0] == 'P' { + // If it has a leading P we're most likely working with an iso8601 + // interval. + dur, err := iso8601ToDuration(s) + if err != nil { + return Duration{}, makeParseError(s, types.Interval, err) + } + return dur, nil + } + if strings.IndexFunc(s, unicode.IsLetter) == -1 { + // If it has no letter, then we're most likely working with a SQL standard + // interval, as both postgres and golang have letter(s) and iso8601 has been tested. + dur, err := sqlStdToDuration(s, itm) + if err != nil { + return Duration{}, makeParseError(s, types.Interval, err) + } + return dur, nil + } + + // We're either a postgres string or a Go duration. + // Our postgres syntax parser also supports golang, so just use that for both. + dur, err := parseDuration(style, s, itm) + if err != nil { + return Duration{}, makeParseError(s, types.Interval, err) + } + return dur, nil +} + +func makeParseError(s string, typ *types.T, err error) error { + if err != nil { + return pgerror.Wrapf(err, pgcode.InvalidTextRepresentation, + "could not parse %q as type %s", s, typ) + } + return pgerror.Newf(pgcode.InvalidTextRepresentation, + "could not parse %q as type %s", s, typ) +} diff --git a/pkg/sql/sem/tree/interval.go b/pkg/util/duration/parse.go similarity index 85% rename from pkg/sql/sem/tree/interval.go rename to pkg/util/duration/parse.go index e5a2cabdf827..afff4d2e0d88 100644 --- a/pkg/sql/sem/tree/interval.go +++ b/pkg/util/duration/parse.go @@ -8,7 +8,7 @@ // by the Apache License, Version 2.0, included in the file // licenses/APL.txt. -package tree +package duration import ( "math" @@ -19,7 +19,6 @@ import ( "github.com/cockroachdb/cockroach/pkg/sql/pgwire/pgcode" "github.com/cockroachdb/cockroach/pkg/sql/pgwire/pgerror" "github.com/cockroachdb/cockroach/pkg/sql/types" - "github.com/cockroachdb/cockroach/pkg/util/duration" "github.com/cockroachdb/errors" ) @@ -153,17 +152,17 @@ func (l *intervalLexer) consumeSpaces() { } // ISO Units. -var isoDateUnitMap = map[string]duration.Duration{ - "D": duration.MakeDuration(0, 1, 0), - "W": duration.MakeDuration(0, 7, 0), - "M": duration.MakeDuration(0, 0, 1), - "Y": duration.MakeDuration(0, 0, 12), +var isoDateUnitMap = map[string]Duration{ + "D": MakeDuration(0, 1, 0), + "W": MakeDuration(0, 7, 0), + "M": MakeDuration(0, 0, 1), + "Y": MakeDuration(0, 0, 12), } -var isoTimeUnitMap = map[string]duration.Duration{ - "S": duration.MakeDuration(time.Second.Nanoseconds(), 0, 0), - "M": duration.MakeDuration(time.Minute.Nanoseconds(), 0, 0), - "H": duration.MakeDuration(time.Hour.Nanoseconds(), 0, 0), +var isoTimeUnitMap = map[string]Duration{ + "S": MakeDuration(time.Second.Nanoseconds(), 0, 0), + "M": MakeDuration(time.Minute.Nanoseconds(), 0, 0), + "H": MakeDuration(time.Hour.Nanoseconds(), 0, 0), } const errInvalidSQLDuration = "invalid input syntax for type interval %s" @@ -185,8 +184,8 @@ func newInvalidSQLDurationError(s string) error { // See the following links for examples: // - http://www.postgresql.org/docs/9.1/static/datatype-datetime.html#DATATYPE-INTERVAL-INPUT-EXAMPLES // - http://www.ibm.com/support/knowledgecenter/SSGU8G_12.1.0/com.ibm.esqlc.doc/ids_esqlc_0190.htm -func sqlStdToDuration(s string, itm types.IntervalTypeMetadata) (duration.Duration, error) { - var d duration.Duration +func sqlStdToDuration(s string, itm types.IntervalTypeMetadata) (Duration, error) { + var d Duration parts := strings.Fields(s) if len(parts) > 3 || len(parts) == 0 { return d, newInvalidSQLDurationError(s) @@ -265,7 +264,7 @@ func sqlStdToDuration(s string, itm types.IntervalTypeMetadata) (duration.Durati if err != nil { return d, newInvalidSQLDurationError(s) } - d = d.Add(duration.MakeDuration(0, 1, 0).MulFloat(days)) + d = d.Add(MakeDuration(0, 1, 0).MulFloat(days)) } hms = hms[1:] @@ -324,9 +323,9 @@ func sqlStdToDuration(s string, itm types.IntervalTypeMetadata) (duration.Durati return d, newInvalidSQLDurationError(s) } - d = d.Add(duration.MakeDuration(time.Hour.Nanoseconds(), 0, 0).Mul(mult * hours)) - d = d.Add(duration.MakeDuration(time.Minute.Nanoseconds(), 0, 0).Mul(mult * mins)) - d = d.Add(duration.MakeDuration(time.Second.Nanoseconds(), 0, 0).MulFloat(float64(mult) * secs)) + d = d.Add(MakeDuration(time.Hour.Nanoseconds(), 0, 0).Mul(mult * hours)) + d = d.Add(MakeDuration(time.Minute.Nanoseconds(), 0, 0).Mul(mult * mins)) + d = d.Add(MakeDuration(time.Second.Nanoseconds(), 0, 0).MulFloat(float64(mult) * secs)) } else if strings.ContainsRune(part, '-') { // Try to parse as Year-Month. if parsedIdx >= yearMonthParsed { @@ -351,7 +350,7 @@ func sqlStdToDuration(s string, itm types.IntervalTypeMetadata) (duration.Durati month, errMonth = strconv.Atoi(yms[1]) } if errYear == nil && errMonth == nil { - delta := duration.MakeDuration(0, 0, 1).Mul(int64(year)*12 + int64(month)) + delta := MakeDuration(0, 0, 1).Mul(int64(year)*12 + int64(month)) if mult < 0 { d = d.Sub(delta) } else { @@ -371,26 +370,26 @@ func sqlStdToDuration(s string, itm types.IntervalTypeMetadata) (duration.Durati // It must be part because nothing has been parsed. switch itm.DurationField.DurationType { case types.IntervalDurationType_YEAR: - d = d.Add(duration.MakeDuration(0, 0, 12).MulFloat(value * float64(mult))) + d = d.Add(MakeDuration(0, 0, 12).MulFloat(value * float64(mult))) case types.IntervalDurationType_MONTH: - d = d.Add(duration.MakeDuration(0, 0, 1).MulFloat(value * float64(mult))) + d = d.Add(MakeDuration(0, 0, 1).MulFloat(value * float64(mult))) case types.IntervalDurationType_DAY: - d = d.Add(duration.MakeDuration(0, 1, 0).MulFloat(value * float64(mult))) + d = d.Add(MakeDuration(0, 1, 0).MulFloat(value * float64(mult))) case types.IntervalDurationType_HOUR: - d = d.Add(duration.MakeDuration(time.Hour.Nanoseconds(), 0, 0).MulFloat(value * float64(mult))) + d = d.Add(MakeDuration(time.Hour.Nanoseconds(), 0, 0).MulFloat(value * float64(mult))) case types.IntervalDurationType_MINUTE: - d = d.Add(duration.MakeDuration(time.Minute.Nanoseconds(), 0, 0).MulFloat(value * float64(mult))) + d = d.Add(MakeDuration(time.Minute.Nanoseconds(), 0, 0).MulFloat(value * float64(mult))) case types.IntervalDurationType_SECOND, types.IntervalDurationType_UNSET: - d = d.Add(duration.MakeDuration(time.Second.Nanoseconds(), 0, 0).MulFloat(value * float64(mult))) + d = d.Add(MakeDuration(time.Second.Nanoseconds(), 0, 0).MulFloat(value * float64(mult))) case types.IntervalDurationType_MILLISECOND: - d = d.Add(duration.MakeDuration(time.Millisecond.Nanoseconds(), 0, 0).MulFloat(value * float64(mult))) + d = d.Add(MakeDuration(time.Millisecond.Nanoseconds(), 0, 0).MulFloat(value * float64(mult))) default: return d, errors.AssertionFailedf("unhandled DurationField constant %#v", itm.DurationField) } parsedIdx = hmsParsed } else if parsedIdx == hmsParsed { // Day part. - delta := duration.MakeDuration(0, 1, 0).MulFloat(value) + delta := MakeDuration(0, 1, 0).MulFloat(value) if mult < 0 { d = d.Sub(delta) } else { @@ -412,8 +411,8 @@ func sqlStdToDuration(s string, itm types.IntervalTypeMetadata) (duration.Durati // - http://www.postgresql.org/docs/9.1/static/datatype-datetime.html#DATATYPE-INTERVAL-INPUT-EXAMPLES // - https://en.wikipedia.org/wiki/ISO_8601#Time_intervals // - https://en.wikipedia.org/wiki/ISO_8601#Durations -func iso8601ToDuration(s string) (duration.Duration, error) { - var d duration.Duration +func iso8601ToDuration(s string) (Duration, error) { + var d Duration if len(s) == 0 || s[0] != 'P' { return d, newInvalidSQLDurationError(s) } @@ -457,9 +456,9 @@ func iso8601ToDuration(s string) (duration.Duration, error) { // unitMap defines for each unit name what is the time duration for // that unit. var unitMap = func( - units map[string]duration.Duration, + units map[string]Duration, aliases map[string][]string, -) map[string]duration.Duration { +) map[string]Duration { for a, alist := range aliases { // Pluralize. units[a+"s"] = units[a] @@ -469,18 +468,18 @@ var unitMap = func( } } return units -}(map[string]duration.Duration{ +}(map[string]Duration{ // Use DecodeDuration here because ns is the only unit for which we do not // want to round nanoseconds since it is only used for multiplication. - "microsecond": duration.MakeDuration(time.Microsecond.Nanoseconds(), 0, 0), - "millisecond": duration.MakeDuration(time.Millisecond.Nanoseconds(), 0, 0), - "second": duration.MakeDuration(time.Second.Nanoseconds(), 0, 0), - "minute": duration.MakeDuration(time.Minute.Nanoseconds(), 0, 0), - "hour": duration.MakeDuration(time.Hour.Nanoseconds(), 0, 0), - "day": duration.MakeDuration(0, 1, 0), - "week": duration.MakeDuration(0, 7, 0), - "month": duration.MakeDuration(0, 0, 1), - "year": duration.MakeDuration(0, 0, 12), + "microsecond": MakeDuration(time.Microsecond.Nanoseconds(), 0, 0), + "millisecond": MakeDuration(time.Millisecond.Nanoseconds(), 0, 0), + "second": MakeDuration(time.Second.Nanoseconds(), 0, 0), + "minute": MakeDuration(time.Minute.Nanoseconds(), 0, 0), + "hour": MakeDuration(time.Hour.Nanoseconds(), 0, 0), + "day": MakeDuration(0, 1, 0), + "week": MakeDuration(0, 7, 0), + "month": MakeDuration(0, 0, 1), + "year": MakeDuration(0, 0, 12), }, map[string][]string{ // Include PostgreSQL's unit keywords for compatibility; see // https://github.com/postgres/postgres/blob/a01d0fa1d889cc2003e1941e8b98707c4d701ba9/src/backend/utils/adt/datetime.c#L175-L240 @@ -502,9 +501,9 @@ var unitMap = func( // format (e.g. '1 day 2 hours', '1 day 03:02:04', etc.) or golang // format (e.g. '1d2h', '1d3h2m4s', etc.) func parseDuration( - style duration.IntervalStyle, s string, itm types.IntervalTypeMetadata, -) (duration.Duration, error) { - var d duration.Duration + style IntervalStyle, s string, itm types.IntervalTypeMetadata, +) (Duration, error) { + var d Duration l := intervalLexer{str: s, offset: 0, err: nil} l.consumeSpaces() @@ -516,7 +515,7 @@ func parseDuration( // If we have strictly one negative at the beginning belonging to a // in SQL Standard parsing, treat everything as negative. isSQLStandardNegative := - style == duration.IntervalStyle_SQL_STANDARD && + style == IntervalStyle_SQL_STANDARD && (l.offset+1) < len(l.str) && l.str[l.offset] == '-' && !strings.ContainsAny(l.str[l.offset+1:], "+-") if isSQLStandardNegative { @@ -568,7 +567,7 @@ func parseDuration( pgcode.InvalidDatetimeFormat, "interval: missing unit at position %d: %q", l.offset, s) } if isSQLStandardNegative { - return duration.MakeDuration( + return MakeDuration( -d.Nanos(), -d.Days, -d.Months, @@ -579,7 +578,7 @@ func parseDuration( func (l *intervalLexer) parseShortDuration( h int64, hasSign bool, itm types.IntervalTypeMetadata, -) (duration.Duration, error) { +) (Duration, error) { sign := int64(1) if hasSign { sign = -1 @@ -588,7 +587,7 @@ func (l *intervalLexer) parseShortDuration( // first number, so that we can check here there are no unwanted // spaces. if l.str[l.offset] != ':' { - return duration.Duration{}, pgerror.Newf( + return Duration{}, pgerror.Newf( pgcode.InvalidDatetimeFormat, "interval: invalid format %s", l.str[l.offset:]) } l.offset++ @@ -596,7 +595,7 @@ func (l *intervalLexer) parseShortDuration( m, hasDecimal, mp := l.consumeNum() if m < 0 { - return duration.Duration{}, pgerror.Newf( + return Duration{}, pgerror.Newf( pgcode.InvalidDatetimeFormat, "interval: invalid format: %s", l.str) } // We have three possible formats: @@ -608,7 +607,7 @@ func (l *intervalLexer) parseShortDuration( // represent minutes. Get this out of the way first. if hasDecimal { l.consumeSpaces() - return duration.MakeDuration( + return MakeDuration( h*time.Minute.Nanoseconds()+ sign*(m*time.Second.Nanoseconds()+ floatToNanos(mp)), @@ -627,7 +626,7 @@ func (l *intervalLexer) parseShortDuration( l.offset++ s, _, sp = l.consumeNum() if s < 0 { - return duration.Duration{}, pgerror.Newf( + return Duration{}, pgerror.Newf( pgcode.InvalidDatetimeFormat, "interval: invalid format: %s", l.str) } } @@ -635,13 +634,13 @@ func (l *intervalLexer) parseShortDuration( l.consumeSpaces() if !hasSecondsComponent && itm.DurationField.IsMinuteToSecond() { - return duration.MakeDuration( + return MakeDuration( h*time.Minute.Nanoseconds()+sign*(m*time.Second.Nanoseconds()), 0, 0, ), nil } - return duration.MakeDuration( + return MakeDuration( h*time.Hour.Nanoseconds()+ sign*(m*time.Minute.Nanoseconds()+ int64(mp*float64(time.Minute.Nanoseconds()))+ @@ -656,7 +655,7 @@ func (l *intervalLexer) parseShortDuration( // given as second argument multiplied by the factor in the third // argument. For computing fractions there are 30 days to a month and // 24 hours to a day. -func addFrac(d duration.Duration, unit duration.Duration, f float64) (duration.Duration, error) { +func addFrac(d Duration, unit Duration, f float64) (Duration, error) { if unit.Months > 0 { f = f * float64(unit.Months) d.Months += int64(f) @@ -671,7 +670,7 @@ func addFrac(d duration.Duration, unit duration.Duration, f float64) (duration.D // months. Do not continue to add precision to the interval. // See issue #55226 for more details on this. default: - return duration.Duration{}, errors.AssertionFailedf("unhandled unit type %v", unit) + return Duration{}, errors.AssertionFailedf("unhandled unit type %v", unit) } } else if unit.Days > 0 { f = f * float64(unit.Days) diff --git a/pkg/sql/sem/tree/interval_test.go b/pkg/util/duration/parse_test.go similarity index 95% rename from pkg/sql/sem/tree/interval_test.go rename to pkg/util/duration/parse_test.go index 5523fecb07a2..7348bca938d6 100644 --- a/pkg/sql/sem/tree/interval_test.go +++ b/pkg/util/duration/parse_test.go @@ -8,13 +8,12 @@ // by the Apache License, Version 2.0, included in the file // licenses/APL.txt. -package tree +package duration import ( "testing" "github.com/cockroachdb/cockroach/pkg/sql/types" - "github.com/cockroachdb/cockroach/pkg/util/duration" "github.com/cockroachdb/cockroach/pkg/util/leaktest" "github.com/cockroachdb/cockroach/pkg/util/log" ) @@ -181,9 +180,9 @@ func TestValidSQLIntervalSyntax(t *testing.T) { t.Fatalf(`%q: got "%s", expected "%s"`, test.input, s, test.output) } - for style := range duration.IntervalStyle_value { + for style := range IntervalStyle_value { t.Run(style, func(t *testing.T) { - styleVal := duration.IntervalStyle(duration.IntervalStyle_value[style]) + styleVal := IntervalStyle(IntervalStyle_value[style]) dur2, err := parseDuration(styleVal, s, test.itm) if err != nil { t.Fatalf(`%q: repr "%s" is not parsable: %v`, test.input, s, err) @@ -195,7 +194,7 @@ func TestValidSQLIntervalSyntax(t *testing.T) { } // Test that a Datum recognizes the format. - di, err := parseInterval(styleVal, test.input, test.itm) + di, err := ParseInterval(styleVal, test.input, test.itm) if err != nil { t.Fatalf(`%q: unrecognized as datum: %v`, test.input, err) } @@ -421,11 +420,11 @@ func TestPGIntervalSyntax(t *testing.T) { } for _, test := range testData { t.Run(test.input, func(t *testing.T) { - for style := range duration.IntervalStyle_value { + for style := range IntervalStyle_value { t.Run(style, func(t *testing.T) { - styleVal := duration.IntervalStyle(duration.IntervalStyle_value[style]) + styleVal := IntervalStyle(IntervalStyle_value[style]) expectedError := test.error - if test.errorSQLStandard != "" && styleVal == duration.IntervalStyle_SQL_STANDARD { + if test.errorSQLStandard != "" && styleVal == IntervalStyle_SQL_STANDARD { expectedError = test.errorSQLStandard } @@ -445,7 +444,7 @@ func TestPGIntervalSyntax(t *testing.T) { } s := dur.String() expected := test.output - if test.outputSQLStandard != "" && styleVal == duration.IntervalStyle_SQL_STANDARD { + if test.outputSQLStandard != "" && styleVal == IntervalStyle_SQL_STANDARD { expected = test.outputSQLStandard } if s != expected { @@ -462,7 +461,7 @@ func TestPGIntervalSyntax(t *testing.T) { } // Test that a Datum recognizes the format. - di, err := parseInterval(styleVal, test.input, test.itm) + di, err := ParseInterval(styleVal, test.input, test.itm) if err != nil { t.Fatalf(`%q: unrecognized as datum: %v`, test.input, err) } @@ -573,9 +572,9 @@ func TestISO8601IntervalSyntax(t *testing.T) { t.Fatalf(`%q: got "%s", expected "%s"`, test.input, s, test.output) } - for style := range duration.IntervalStyle_value { + for style := range IntervalStyle_value { t.Run(style, func(t *testing.T) { - dur2, err := parseDuration(duration.IntervalStyle(duration.IntervalStyle_value[style]), s, test.itm) + dur2, err := parseDuration(IntervalStyle(IntervalStyle_value[style]), s, test.itm) if err != nil { t.Fatalf(`%q: repr "%s" is not parsable: %v`, test.input, s, err) } @@ -585,7 +584,7 @@ func TestISO8601IntervalSyntax(t *testing.T) { } // Test that a Datum recognizes the format. - di, err := parseInterval(duration.IntervalStyle(duration.IntervalStyle_value[style]), test.input, test.itm) + di, err := ParseInterval(IntervalStyle(IntervalStyle_value[style]), test.input, test.itm) if err != nil { t.Fatalf(`%q: unrecognized as datum: %v`, test.input, err) } @@ -597,7 +596,7 @@ func TestISO8601IntervalSyntax(t *testing.T) { // Test that ISO 8601 output format also round-trips s4 := dur.ISO8601String() - di2, err := parseInterval(duration.IntervalStyle(duration.IntervalStyle_value[style]), s4, test.itm) + di2, err := ParseInterval(IntervalStyle(IntervalStyle_value[style]), s4, test.itm) if err != nil { t.Fatalf(`%q: ISO8601String "%s" unrecognized as datum: %v`, test.input, s4, err) } diff --git a/pkg/util/timeutil/pgdate/field_extract.go b/pkg/util/timeutil/pgdate/field_extract.go index da5e94f1a92f..3fe0afac37bb 100644 --- a/pkg/util/timeutil/pgdate/field_extract.go +++ b/pkg/util/timeutil/pgdate/field_extract.go @@ -861,7 +861,7 @@ func (fe *fieldExtract) SetDayOfYear(chunk numberChunk) error { if !ok { return errors.AssertionFailedf("year must be set before day of year") } - y, m, d := julianDayToDate(dateToJulianDay(y, 1, 1) + chunk.v - 1) + y, m, d := julianDayToDate(DateToJulianDay(y, 1, 1) + chunk.v - 1) if err := fe.Reset(fieldYear, y); err != nil { return err } diff --git a/pkg/util/timeutil/pgdate/math.go b/pkg/util/timeutil/pgdate/math.go index be43838b5a74..21e9ebeacbfa 100644 --- a/pkg/util/timeutil/pgdate/math.go +++ b/pkg/util/timeutil/pgdate/math.go @@ -20,8 +20,8 @@ var daysInMonth = [2][13]int{ {0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}, } -// dateToJulianDay is based on the date2j function in PostgreSQL 10.5. -func dateToJulianDay(year int, month int, day int) int { +// DateToJulianDay is based on the date2j function in PostgreSQL 10.5. +func DateToJulianDay(year int, month int, day int) int { if month > 2 { month++ year += 4800 diff --git a/pkg/util/tochar/BUILD.bazel b/pkg/util/tochar/BUILD.bazel new file mode 100644 index 000000000000..f4b268f91c34 --- /dev/null +++ b/pkg/util/tochar/BUILD.bazel @@ -0,0 +1,39 @@ +load("//build/bazelutil/unused_checker:unused.bzl", "get_x_data") +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "tochar", + srcs = [ + "constants.go", + "tochar.go", + ], + importpath = "github.com/cockroachdb/cockroach/pkg/util/tochar", + visibility = ["//visibility:public"], + deps = [ + "//pkg/sql/pgwire/pgcode", + "//pkg/sql/pgwire/pgerror", + "//pkg/util/duration", + "//pkg/util/timeutil/pgdate", + "@com_github_cockroachdb_errors//:errors", + ], +) + +go_test( + name = "tochar_test", + srcs = ["tochar_test.go"], + args = ["-test.timeout=295s"], + data = glob(["testdata/**"]), + embed = [":tochar"], + deps = [ + "//pkg/sql/pgwire/pgerror", + "//pkg/sql/types", + "//pkg/util/duration", + "//pkg/util/timeutil", + "//pkg/util/timeutil/pgdate", + "@com_github_cockroachdb_datadriven//:datadriven", + "@com_github_cockroachdb_errors//:errors", + "@com_github_stretchr_testify//require", + ], +) + +get_x_data(name = "get_x_data") diff --git a/pkg/util/tochar/constants.go b/pkg/util/tochar/constants.go new file mode 100644 index 000000000000..1d7641168b24 --- /dev/null +++ b/pkg/util/tochar/constants.go @@ -0,0 +1,378 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package tochar + +// fromCharDateMode is used to denote date conventions. +// Taken from https://github.com/postgres/postgres/blob/b0b72c64a0ce7bf5dd78a80b33d85c89c943ad0d/src/backend/utils/adt/formatting.c#L170-L175. +type fromCharDateMode int + +const ( + fromCharDateNone = 0 + fromCharDateGregorian + fromCharDateISOWeek +) + +// Taken from https://github.com/postgres/postgres/blob/b0b72c64a0ce7bf5dd78a80b33d85c89c943ad0d/src/backend/utils/adt/formatting.c#L632. +// Denotes a "keyword" used for to_char. +type dchPos int + +// This block is word-for-word out of the enum for dch_pos in PG. +const ( + DCH_A_D dchPos = iota + DCH_A_M + DCH_AD + DCH_AM + DCH_B_C + DCH_BC + DCH_CC + DCH_DAY + DCH_DDD + DCH_DD + DCH_DY + DCH_Day + DCH_Dy + DCH_D + DCH_FF1 + DCH_FF2 + DCH_FF3 + DCH_FF4 + DCH_FF5 + DCH_FF6 + DCH_FX + DCH_HH24 + DCH_HH12 + DCH_HH + DCH_IDDD + DCH_ID + DCH_IW + DCH_IYYY + DCH_IYY + DCH_IY + DCH_I + DCH_J + DCH_MI + DCH_MM + DCH_MONTH + DCH_MON + DCH_MS + DCH_Month + DCH_Mon + DCH_OF + DCH_P_M + DCH_PM + DCH_Q + DCH_RM + DCH_SSSSS + DCH_SSSS + DCH_SS + DCH_TZH + DCH_TZM + DCH_TZ + DCH_US + DCH_WW + DCH_W + DCH_Y_YYY + DCH_YYYY + DCH_YYY + DCH_YY + DCH_Y + DCH_a_d + DCH_a_m + DCH_ad + DCH_am + DCH_b_c + DCH_bc + DCH_cc + DCH_day + DCH_ddd + DCH_dd + DCH_dy + DCH_d + DCH_ff1 + DCH_ff2 + DCH_ff3 + DCH_ff4 + DCH_ff5 + DCH_ff6 + DCH_fx + DCH_hh24 + DCH_hh12 + DCH_hh + DCH_iddd + DCH_id + DCH_iw + DCH_iyyy + DCH_iyy + DCH_iy + DCH_i + DCH_j + DCH_mi + DCH_mm + DCH_month + DCH_mon + DCH_ms + DCH_of + DCH_p_m + DCH_pm + DCH_q + DCH_rm + DCH_sssss + DCH_ssss + DCH_ss + DCH_tzh + DCH_tzm + DCH_tz + DCH_us + DCH_ww + DCH_w + DCH_y_yyy + DCH_yyyy + DCH_yyy + DCH_yy + DCH_y + + DCH_last +) + +// KeyWord is used to denote properties from the dchPos. +// Taken from https://github.com/postgres/postgres/blob/b0b72c64a0ce7bf5dd78a80b33d85c89c943ad0d/src/backend/utils/adt/formatting.c#L177-L184 +type keyWord struct { + name string + id dchPos + isDigit bool + dateMode fromCharDateMode +} + +// dchKeywords is an array of keyWords, ordered in ASCII order. +// Taken from https://github.com/postgres/postgres/blob/b0b72c64a0ce7bf5dd78a80b33d85c89c943ad0d/src/backend/utils/adt/formatting.c#L798. +var dchKeywords = []keyWord{ + {"A.D.", DCH_A_D, false, fromCharDateNone}, /* A */ + {"A.M.", DCH_A_M, false, fromCharDateNone}, + {"AD", DCH_AD, false, fromCharDateNone}, + {"AM", DCH_AM, false, fromCharDateNone}, + {"B.C.", DCH_B_C, false, fromCharDateNone}, /* B */ + {"BC", DCH_BC, false, fromCharDateNone}, + {"CC", DCH_CC, true, fromCharDateNone}, /* C */ + {"DAY", DCH_DAY, false, fromCharDateNone}, /* D */ + {"DDD", DCH_DDD, true, fromCharDateGregorian}, + {"DD", DCH_DD, true, fromCharDateGregorian}, + {"DY", DCH_DY, false, fromCharDateNone}, + {"Day", DCH_Day, false, fromCharDateNone}, + {"Dy", DCH_Dy, false, fromCharDateNone}, + {"D", DCH_D, true, fromCharDateGregorian}, + {"FF1", DCH_FF1, false, fromCharDateNone}, /* F */ + {"FF2", DCH_FF2, false, fromCharDateNone}, + {"FF3", DCH_FF3, false, fromCharDateNone}, + {"FF4", DCH_FF4, false, fromCharDateNone}, + {"FF5", DCH_FF5, false, fromCharDateNone}, + {"FF6", DCH_FF6, false, fromCharDateNone}, + {"FX", DCH_FX, false, fromCharDateNone}, + {"HH24", DCH_HH24, true, fromCharDateNone}, /* H */ + {"HH12", DCH_HH12, true, fromCharDateNone}, + {"HH", DCH_HH, true, fromCharDateNone}, + {"IDDD", DCH_IDDD, true, fromCharDateISOWeek}, /* I */ + {"ID", DCH_ID, true, fromCharDateISOWeek}, + {"IW", DCH_IW, true, fromCharDateISOWeek}, + {"IYYY", DCH_IYYY, true, fromCharDateISOWeek}, + {"IYY", DCH_IYY, true, fromCharDateISOWeek}, + {"IY", DCH_IY, true, fromCharDateISOWeek}, + {"I", DCH_I, true, fromCharDateISOWeek}, + {"J", DCH_J, true, fromCharDateNone}, /* J */ + {"MI", DCH_MI, true, fromCharDateNone}, /* M */ + {"MM", DCH_MM, true, fromCharDateGregorian}, + {"MONTH", DCH_MONTH, false, fromCharDateGregorian}, + {"MON", DCH_MON, false, fromCharDateGregorian}, + {"MS", DCH_MS, true, fromCharDateNone}, + {"Month", DCH_Month, false, fromCharDateGregorian}, + {"Mon", DCH_Mon, false, fromCharDateGregorian}, + {"OF", DCH_OF, false, fromCharDateNone}, /* O */ + {"P.M.", DCH_P_M, false, fromCharDateNone}, /* P */ + {"PM", DCH_PM, false, fromCharDateNone}, + {"Q", DCH_Q, true, fromCharDateNone}, /* Q */ + {"RM", DCH_RM, false, fromCharDateGregorian}, /* R */ + {"SSSSS", DCH_SSSS, true, fromCharDateNone}, /* S */ + {"SSSS", DCH_SSSS, true, fromCharDateNone}, + {"SS", DCH_SS, true, fromCharDateNone}, + {"TZH", DCH_TZH, false, fromCharDateNone}, /* T */ + {"TZM", DCH_TZM, true, fromCharDateNone}, + {"TZ", DCH_TZ, false, fromCharDateNone}, + {"US", DCH_US, true, fromCharDateNone}, /* U */ + {"WW", DCH_WW, true, fromCharDateGregorian}, /* W */ + {"W", DCH_W, true, fromCharDateGregorian}, + {"Y,YYY", DCH_Y_YYY, true, fromCharDateGregorian}, /* Y */ + {"YYYY", DCH_YYYY, true, fromCharDateGregorian}, + {"YYY", DCH_YYY, true, fromCharDateGregorian}, + {"YY", DCH_YY, true, fromCharDateGregorian}, + {"Y", DCH_Y, true, fromCharDateGregorian}, + {"a.d.", DCH_a_d, false, fromCharDateNone}, /* a */ + {"a.m.", DCH_a_m, false, fromCharDateNone}, + {"ad", DCH_ad, false, fromCharDateNone}, + {"am", DCH_am, false, fromCharDateNone}, + {"b.c.", DCH_b_c, false, fromCharDateNone}, /* b */ + {"bc", DCH_bc, false, fromCharDateNone}, + {"cc", DCH_CC, true, fromCharDateNone}, /* c */ + {"day", DCH_day, false, fromCharDateNone}, /* d */ + {"ddd", DCH_DDD, true, fromCharDateGregorian}, + {"dd", DCH_DD, true, fromCharDateGregorian}, + {"dy", DCH_dy, false, fromCharDateNone}, + {"d", DCH_D, true, fromCharDateGregorian}, + {"ff1", DCH_FF1, false, fromCharDateNone}, /* f */ + {"ff2", DCH_FF2, false, fromCharDateNone}, + {"ff3", DCH_FF3, false, fromCharDateNone}, + {"ff4", DCH_FF4, false, fromCharDateNone}, + {"ff5", DCH_FF5, false, fromCharDateNone}, + {"ff6", DCH_FF6, false, fromCharDateNone}, + {"fx", DCH_FX, false, fromCharDateNone}, + {"hh24", DCH_HH24, true, fromCharDateNone}, /* h */ + {"hh12", DCH_HH12, true, fromCharDateNone}, + {"hh", DCH_HH, true, fromCharDateNone}, + {"iddd", DCH_IDDD, true, fromCharDateISOWeek}, /* i */ + {"id", DCH_ID, true, fromCharDateISOWeek}, + {"iw", DCH_IW, true, fromCharDateISOWeek}, + {"iyyy", DCH_IYYY, true, fromCharDateISOWeek}, + {"iyy", DCH_IYY, true, fromCharDateISOWeek}, + {"iy", DCH_IY, true, fromCharDateISOWeek}, + {"i", DCH_I, true, fromCharDateISOWeek}, + {"j", DCH_J, true, fromCharDateNone}, /* j */ + {"mi", DCH_MI, true, fromCharDateNone}, /* m */ + {"mm", DCH_MM, true, fromCharDateGregorian}, + {"month", DCH_month, false, fromCharDateGregorian}, + {"mon", DCH_mon, false, fromCharDateGregorian}, + {"ms", DCH_MS, true, fromCharDateNone}, + {"of", DCH_OF, false, fromCharDateNone}, /* o */ + {"p.m.", DCH_p_m, false, fromCharDateNone}, /* p */ + {"pm", DCH_pm, false, fromCharDateNone}, + {"q", DCH_Q, true, fromCharDateNone}, /* q */ + {"rm", DCH_rm, false, fromCharDateGregorian}, /* r */ + {"sssss", DCH_SSSS, true, fromCharDateNone}, /* s */ + {"ssss", DCH_SSSS, true, fromCharDateNone}, + {"ss", DCH_SS, true, fromCharDateNone}, + {"tzh", DCH_TZH, false, fromCharDateNone}, /* t */ + {"tzm", DCH_TZM, true, fromCharDateNone}, + {"tz", DCH_tz, false, fromCharDateNone}, + {"us", DCH_US, true, fromCharDateNone}, /* u */ + {"ww", DCH_WW, true, fromCharDateGregorian}, /* w */ + {"w", DCH_W, true, fromCharDateGregorian}, + {"y,yyy", DCH_Y_YYY, true, fromCharDateGregorian}, /* y */ + {"yyyy", DCH_YYYY, true, fromCharDateGregorian}, + {"yyy", DCH_YYY, true, fromCharDateGregorian}, + {"yy", DCH_YY, true, fromCharDateGregorian}, + {"y", DCH_Y, true, fromCharDateGregorian}, + + /* last */ + //{"", 0, false, 0}, +} + +// Months in roman-numeral +// (Must be in reverse order for seq_search (in FROM_CHAR), because +// 'VIII' must have higher precedence than 'V') +var ( + ucMonthRomanNumerals = []string{"XII", "XI", "X", "IX", "VIII", "VII", "VI", "V", "IV", "III", "II", "I"} + lcMonthRomanNumerals = []string{"xii", "xi", "x", "ix", "viii", "vii", "vi", "v", "iv", "iii", "ii", "i"} +) + +// From https://github.com/postgres/postgres/blob/b0b72c64a0ce7bf5dd78a80b33d85c89c943ad0d/src/backend/utils/adt/formatting.c#L194-L198. +type formatNodeType int + +const ( + formatNodeEnd formatNodeType = iota + formatNodeAction + formatNodeChar + formatNodeSeparator + formatNodeSpace +) + +type formatNode struct { + typ formatNodeType + character string + suffix dchSuffix + key *keyWord +} + +// dchIndex maps ASCII characters to the start index in dchKeywords of possible +// matches. +var dchIndex = []dchPos{ + /*---- first 0..31 chars are skipped ----*/ + + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, DCH_A_D, DCH_B_C, DCH_CC, DCH_DAY, -1, + DCH_FF1, -1, DCH_HH24, DCH_IDDD, DCH_J, -1, -1, DCH_MI, -1, DCH_OF, + DCH_P_M, DCH_Q, DCH_RM, DCH_SSSSS, DCH_TZH, DCH_US, -1, DCH_WW, -1, DCH_Y_YYY, + -1, -1, -1, -1, -1, -1, -1, DCH_a_d, DCH_b_c, DCH_cc, + DCH_day, -1, DCH_ff1, -1, DCH_hh24, DCH_iddd, DCH_j, -1, -1, DCH_mi, + -1, DCH_of, DCH_p_m, DCH_q, DCH_rm, DCH_sssss, DCH_tzh, DCH_us, -1, DCH_ww, + -1, DCH_y_yyy, -1, -1, -1, -1, + + /*---- chars over 126 are skipped ----*/ +} + +// From https://github.com/postgres/postgres/blob/master/src/backend/utils/adt/formatting.c#L155. +type keySuffixType int + +const ( + keySuffixPrefix keySuffixType = iota + keySuffixPostfix +) + +type dchSuffix int + +// This blocks contains all dch_suffix types used in PG. +const ( + DCH_S_FM dchSuffix = 0x01 + DCH_S_TH dchSuffix = 0x02 + DCH_S_th dchSuffix = 0x04 + DCH_S_SP dchSuffix = 0x08 + DCH_S_TM dchSuffix = 0x10 +) + +func (d dchSuffix) FM() bool { + return d&DCH_S_FM > 0 +} + +func (d dchSuffix) SP() bool { + return d&DCH_S_SP > 0 +} + +func (d dchSuffix) TM() bool { + return d&DCH_S_TM > 0 +} + +func (d dchSuffix) THth() bool { + return d.TH() || d.th() +} + +func (d dchSuffix) TH() bool { + return d&DCH_S_TH > 0 +} + +func (d dchSuffix) th() bool { + return d&DCH_S_th > 0 +} + +type keySuffix struct { + name string + id dchSuffix + typ keySuffixType +} + +var dchSuffixes = []keySuffix{ + {"FM", DCH_S_FM, keySuffixPrefix}, + {"fm", DCH_S_FM, keySuffixPrefix}, + {"TM", DCH_S_TM, keySuffixPrefix}, + {"tm", DCH_S_TM, keySuffixPrefix}, + {"TH", DCH_S_TH, keySuffixPostfix}, + {"th", DCH_S_th, keySuffixPostfix}, + {"SP", DCH_S_SP, keySuffixPostfix}, + /* last */ + //{"", 0, -1}, +} diff --git a/pkg/util/tochar/testdata/ampm.ddt b/pkg/util/tochar/testdata/ampm.ddt new file mode 100644 index 000000000000..6d8d1b180abb --- /dev/null +++ b/pkg/util/tochar/testdata/ampm.ddt @@ -0,0 +1,81 @@ +# Test AM,PM + +timestamp +A.M. +2020-02-22 03:16:17 +2020-02-22 12:16:17 +2020-02-22 15:16:17 +---- +2020-02-22 03:16:17: A.M. +2020-02-22 12:16:17: P.M. +2020-02-22 15:16:17: P.M. + +timestamp +P.M. +2020-02-22 03:16:17 +2020-02-22 12:16:17 +2020-02-22 15:16:17 +---- +2020-02-22 03:16:17: A.M. +2020-02-22 12:16:17: P.M. +2020-02-22 15:16:17: P.M. + +timestamp +AM +2020-02-22 03:16:17 +2020-02-22 12:16:17 +2020-02-22 15:16:17 +---- +2020-02-22 03:16:17: AM +2020-02-22 12:16:17: PM +2020-02-22 15:16:17: PM + +timestamp +PM +2020-02-22 03:16:17 +2020-02-22 12:16:17 +2020-02-22 15:16:17 +---- +2020-02-22 03:16:17: AM +2020-02-22 12:16:17: PM +2020-02-22 15:16:17: PM + +timestamp +a.m. +2020-02-22 03:16:17 +2020-02-22 12:16:17 +2020-02-22 15:16:17 +---- +2020-02-22 03:16:17: a.m. +2020-02-22 12:16:17: p.m. +2020-02-22 15:16:17: p.m. + +timestamp +p.m. +2020-02-22 03:16:17 +2020-02-22 12:16:17 +2020-02-22 15:16:17 +---- +2020-02-22 03:16:17: a.m. +2020-02-22 12:16:17: p.m. +2020-02-22 15:16:17: p.m. + +timestamp +am +2020-02-22 03:16:17 +2020-02-22 12:16:17 +2020-02-22 15:16:17 +---- +2020-02-22 03:16:17: am +2020-02-22 12:16:17: pm +2020-02-22 15:16:17: pm + +timestamp +pm +2020-02-22 03:16:17 +2020-02-22 12:16:17 +2020-02-22 15:16:17 +---- +2020-02-22 03:16:17: am +2020-02-22 12:16:17: pm +2020-02-22 15:16:17: pm diff --git a/pkg/util/tochar/testdata/bcad.ddt b/pkg/util/tochar/testdata/bcad.ddt new file mode 100644 index 000000000000..807f379f27a6 --- /dev/null +++ b/pkg/util/tochar/testdata/bcad.ddt @@ -0,0 +1,99 @@ +timestamp +BC +0001-02-22 BC 00:15:18 +0001-02-22 00:15:18 +---- +0001-02-22 BC 00:15:18: BC +0001-02-22 00:15:18: AD + +timestamp +AD +0001-02-22 BC 00:15:18 +0001-02-22 00:15:18 +---- +0001-02-22 BC 00:15:18: BC +0001-02-22 00:15:18: AD + +timestamp +B.C. +0001-02-22 BC 00:15:18 +0001-02-22 00:15:18 +---- +0001-02-22 BC 00:15:18: B.C. +0001-02-22 00:15:18: A.D. + +timestamp +A.D. +0001-02-22 BC 00:15:18 +0001-02-22 00:15:18 +---- +0001-02-22 BC 00:15:18: B.C. +0001-02-22 00:15:18: A.D. + +timestamp +bc +0001-02-22 BC 00:15:18 +0001-02-22 00:15:18 +---- +0001-02-22 BC 00:15:18: bc +0001-02-22 00:15:18: ad + +timestamp +ad +0001-02-22 BC 00:15:18 +0001-02-22 00:15:18 +---- +0001-02-22 BC 00:15:18: bc +0001-02-22 00:15:18: ad + +timestamp +b.c. +0001-02-22 BC 00:15:18 +0001-02-22 00:15:18 +---- +0001-02-22 BC 00:15:18: b.c. +0001-02-22 00:15:18: a.d. + +timestamp +a.d. +0001-02-22 BC 00:15:18 +0001-02-22 00:15:18 +---- +0001-02-22 BC 00:15:18: b.c. +0001-02-22 00:15:18: a.d. + +interval_fmt +1 day +a.d. +b.c. +A.D. +B.C. +ad +bc +AD +BC +---- +a.d.: [ERROR] invalid format specification for an interval value + DETAIL: format a.d. + HINT: intervals are not tied to specific calendar dates +b.c.: [ERROR] invalid format specification for an interval value + DETAIL: format b.c. + HINT: intervals are not tied to specific calendar dates +A.D.: [ERROR] invalid format specification for an interval value + DETAIL: format A.D. + HINT: intervals are not tied to specific calendar dates +B.C.: [ERROR] invalid format specification for an interval value + DETAIL: format B.C. + HINT: intervals are not tied to specific calendar dates +ad: [ERROR] invalid format specification for an interval value + DETAIL: format ad + HINT: intervals are not tied to specific calendar dates +bc: [ERROR] invalid format specification for an interval value + DETAIL: format bc + HINT: intervals are not tied to specific calendar dates +AD: [ERROR] invalid format specification for an interval value + DETAIL: format AD + HINT: intervals are not tied to specific calendar dates +BC: [ERROR] invalid format specification for an interval value + DETAIL: format BC + HINT: intervals are not tied to specific calendar dates diff --git a/pkg/util/tochar/testdata/parse.ddt b/pkg/util/tochar/testdata/parse.ddt new file mode 100644 index 000000000000..049e80004652 --- /dev/null +++ b/pkg/util/tochar/testdata/parse.ddt @@ -0,0 +1,5 @@ +timestamp +YYYY-MM-DD HH24:MI:SS.US "\"ignore YY\"" \i\"gnore_me+ +2020-01-02 01:03:05 +---- +2020-01-02 01:03:05: 2020-01-02 01:03:05.000000 "ignore YY" \0"gnore_me+ diff --git a/pkg/util/tochar/testdata/time.ddt b/pkg/util/tochar/testdata/time.ddt new file mode 100644 index 000000000000..92d21745914d --- /dev/null +++ b/pkg/util/tochar/testdata/time.ddt @@ -0,0 +1,134 @@ +# Test HH12, HH. + +timestamp +HH12:MI:SS +2020-02-22 00:16:17 +2020-02-22 03:16:17 +2020-02-22 12:16:17 +2020-02-22 15:16:17 +---- +2020-02-22 00:16:17: 12:16:17 +2020-02-22 03:16:17: 03:16:17 +2020-02-22 12:16:17: 12:16:17 +2020-02-22 15:16:17: 03:16:17 + +interval +HH +-50:01:02 +-23:01:02 +00:01:02 +15:16:18 +222:12:12 +---- +-50:01:02: -02 +-23:01:02: -11 +00:01:02: 12 +15:16:18: 03 +222:12:12: 06 + +timestamp +HHth:MITH:SSth +2020-02-22 00:15:18 +2020-02-22 03:11:01 +2020-02-22 12:12:02 +2020-02-22 15:03:13 +---- +2020-02-22 00:15:18: 12th:15TH:18th +2020-02-22 03:11:01: 03rd:11TH:01st +2020-02-22 12:12:02: 12th:12TH:02nd +2020-02-22 15:03:13: 03rd:03RD:13th + +# Test HH24 + +timestamp +FMHH24:MI:SS +2020-02-22 00:16:17 +2020-02-22 03:16:17 +2020-02-22 12:16:17 +2020-02-22 15:16:17 +---- +2020-02-22 00:16:17: 0:16:17 +2020-02-22 03:16:17: 3:16:17 +2020-02-22 12:16:17: 12:16:17 +2020-02-22 15:16:17: 15:16:17 + +interval +HH24 +-50:01:02 +-23:01:02 +00:01:02 +15:16:18 +222:12:12 +---- +-50:01:02: -50 +-23:01:02: -23 +00:01:02: 00 +15:16:18: 15 +222:12:12: 222 + +# Test fractional seconds. +timestamp_fmt +2020-02-22 03:16:17.123456 +HH:MI:SS.FF1 +HH:MI:SS.FF2 +HH:MI:SS.FF3 +HH:MI:SS.FF4 +HH:MI:SS.FF5 +HH:MI:SS.FF6 +HH:MI:SS.MS +HH:MI:SS.US +---- +HH:MI:SS.FF1: 03:16:17.1 +HH:MI:SS.FF2: 03:16:17.12 +HH:MI:SS.FF3: 03:16:17.123 +HH:MI:SS.FF4: 03:16:17.1234 +HH:MI:SS.FF5: 03:16:17.12345 +HH:MI:SS.FF6: 03:16:17.123456 +HH:MI:SS.MS: 03:16:17.123 +HH:MI:SS.US: 03:16:17.123456 + +interval_fmt +-15:04:05.123 +HH:MI:SS.FF1 +HH:MI:SS.FF2 +HH:MI:SS.FF3 +HH:MI:SS.FF4 +HH:MI:SS.FF5 +HH:MI:SS.FF6 +HH:MI:SS.MS +HH:MI:SS.US +HH24:MI:SS.US +---- +HH:MI:SS.FF1: -03:-04:-05.-1 +HH:MI:SS.FF2: -03:-04:-05.-12 +HH:MI:SS.FF3: -03:-04:-05.-123 +HH:MI:SS.FF4: -03:-04:-05.-1230 +HH:MI:SS.FF5: -03:-04:-05.-12300 +HH:MI:SS.FF6: -03:-04:-05.-123000 +HH:MI:SS.MS: -03:-04:-05.-123 +HH:MI:SS.US: -03:-04:-05.-123000 +HH24:MI:SS.US: -15:-04:-05.-123000 + + +# Test SSSS +timestamp +SSSS +2020-02-22 00:16:17 +2020-02-22 03:16:17 +2020-02-22 12:16:17 +2020-02-22 15:16:17 +---- +2020-02-22 00:16:17: 977 +2020-02-22 03:16:17: 11777 +2020-02-22 12:16:17: 44177 +2020-02-22 15:16:17: 54977 + +interval +SSSS +1 day 01:02:03.456 +1 month 2 days 01:02:03.456 +13 months 3 days 01:02:03.456 +---- +1 day 01:02:03.456: 3723 +1 month 2 days 01:02:03.456: 3723 +13 months 3 days 01:02:03.456: 3723 diff --git a/pkg/util/tochar/testdata/tz.ddt b/pkg/util/tochar/testdata/tz.ddt new file mode 100644 index 000000000000..bbbcb9561b37 --- /dev/null +++ b/pkg/util/tochar/testdata/tz.ddt @@ -0,0 +1,71 @@ +timestamp_fmt +2020-02-22 00:16:17 +tz +TZ +TZH +TZM +OF +FMOF +---- +tz: utc +TZ: UTC +TZH: +00 +TZM: 00 +OF: +00 +FMOF: +0 + +timestamp_fmt tz=Australia/Adelaide +2020-02-22 00:16:17 +tz +TZ +TZH +TZM +OF +FMOF +---- +tz: acdt +TZ: ACDT +TZH: +10 +TZM: 30 +OF: +10:30 +FMOF: +10:30 + +timestamp_fmt tz=America/St_Johns +2020-02-22 00:16:17 +tz +TZ +TZH +TZM +OF +FMOF +---- +tz: nst +TZ: NST +TZH: -03 +TZM: 30 +OF: -03:30 +FMOF: -3:30 + +interval_fmt +1 day +tz +TZ +TZH +TZM +OF +---- +tz: [ERROR] invalid format specification for an interval value + DETAIL: format tz + HINT: intervals are not tied to specific calendar dates +TZ: [ERROR] invalid format specification for an interval value + DETAIL: format TZ + HINT: intervals are not tied to specific calendar dates +TZH: [ERROR] invalid format specification for an interval value + DETAIL: format TZH + HINT: intervals are not tied to specific calendar dates +TZM: [ERROR] invalid format specification for an interval value + DETAIL: format TZM + HINT: intervals are not tied to specific calendar dates +OF: [ERROR] invalid format specification for an interval value + DETAIL: format OF + HINT: intervals are not tied to specific calendar dates diff --git a/pkg/util/tochar/testdata/ymd.ddt b/pkg/util/tochar/testdata/ymd.ddt new file mode 100644 index 000000000000..8b404444444f --- /dev/null +++ b/pkg/util/tochar/testdata/ymd.ddt @@ -0,0 +1,583 @@ +# Test various month operations. +timestamp_fmt +2020-07-22 00:15:18 +MONTHX +MonthX +monthX +FMMONTHX +FMMonthX +FMmonthX +MON +Mon +mon +MM +MMTH +MMth +FMMM +---- +MONTHX: JULY X +MonthX: July X +monthX: july X +FMMONTHX: JULYX +FMMonthX: JulyX +FMmonthX: julyX +MON: JUL +Mon: Jul +mon: jul +MM: 07 +MMTH: 07TH +MMth: 07th +FMMM: 7 + +interval_fmt +1 hour +MONTHX +MonthX +monthX +FMMONTHX +FMMonthX +FMmonthX +MON +Mon +mon +MM +MMTH +MMth +FMMM +---- +MONTHX: [ERROR] invalid format specification for an interval value + DETAIL: format MONTH + HINT: intervals are not tied to specific calendar dates +MonthX: [ERROR] invalid format specification for an interval value + DETAIL: format Month + HINT: intervals are not tied to specific calendar dates +monthX: [ERROR] invalid format specification for an interval value + DETAIL: format month + HINT: intervals are not tied to specific calendar dates +FMMONTHX: [ERROR] invalid format specification for an interval value + DETAIL: format MONTH + HINT: intervals are not tied to specific calendar dates +FMMonthX: [ERROR] invalid format specification for an interval value + DETAIL: format Month + HINT: intervals are not tied to specific calendar dates +FMmonthX: [ERROR] invalid format specification for an interval value + DETAIL: format month + HINT: intervals are not tied to specific calendar dates +MON: [ERROR] invalid format specification for an interval value + DETAIL: format MON + HINT: intervals are not tied to specific calendar dates +Mon: [ERROR] invalid format specification for an interval value + DETAIL: format Mon + HINT: intervals are not tied to specific calendar dates +mon: [ERROR] invalid format specification for an interval value + DETAIL: format mon + HINT: intervals are not tied to specific calendar dates +MM: 00 +MMTH: 00TH +MMth: 00th +FMMM: 0 + +interval_fmt +25 months 3 days 1 hour +MM: 00 +MMTH: 00TH +MMth: 00th +FMMM: 0 +---- +MM: 00: 01: 00 +MMTH: 00TH: 01ST: 00TH +MMth: 00th: 01st: 00th +FMMM: 0: 1: 0 + +# Test day of week operations. +timestamp_fmt +2020-07-18 00:15:18 +DAYX +DayX +dayX +FMDAYX +FMDayX +FMdayX +DY +Dy +dy +D +ID +Dth +IDTH +---- +DAYX: saturday X +DayX: Saturday X +dayX: saturday X +FMDAYX: saturdayX +FMDayX: SaturdayX +FMdayX: saturdayX +DY: sat +Dy: Sat +dy: sat +D: 7 +ID: 6 +Dth: 7th +IDTH: 6TH + +timestamp_fmt +2020-07-19 00:15:18 +DAYX +DayX +dayX +FMDAYX +FMDayX +FMdayX +DY +Dy +dy +D +ID +Dth +IDTH +---- +DAYX: sunday X +DayX: Sunday X +dayX: sunday X +FMDAYX: sundayX +FMDayX: SundayX +FMdayX: sundayX +DY: sun +Dy: Sun +dy: sun +D: 1 +ID: 7 +Dth: 1st +IDTH: 7TH + +interval_fmt +1 hour +DAYX +DayX +dayX +FMDAYX +FMDayX +FMdayX +DY +Dy +dy +D +ID +Dth +IDTH +---- +DAYX: [ERROR] invalid format specification for an interval value + DETAIL: format DAY + HINT: intervals are not tied to specific calendar dates +DayX: [ERROR] invalid format specification for an interval value + DETAIL: format Day + HINT: intervals are not tied to specific calendar dates +dayX: [ERROR] invalid format specification for an interval value + DETAIL: format day + HINT: intervals are not tied to specific calendar dates +FMDAYX: [ERROR] invalid format specification for an interval value + DETAIL: format DAY + HINT: intervals are not tied to specific calendar dates +FMDayX: [ERROR] invalid format specification for an interval value + DETAIL: format Day + HINT: intervals are not tied to specific calendar dates +FMdayX: [ERROR] invalid format specification for an interval value + DETAIL: format day + HINT: intervals are not tied to specific calendar dates +DY: [ERROR] invalid format specification for an interval value + DETAIL: format DY + HINT: intervals are not tied to specific calendar dates +Dy: [ERROR] invalid format specification for an interval value + DETAIL: format Dy + HINT: intervals are not tied to specific calendar dates +dy: [ERROR] invalid format specification for an interval value + DETAIL: format dy + HINT: intervals are not tied to specific calendar dates +D: [ERROR] invalid format specification for an interval value + DETAIL: format D + HINT: intervals are not tied to specific calendar dates +ID: [ERROR] invalid format specification for an interval value + DETAIL: format ID + HINT: intervals are not tied to specific calendar dates +Dth: [ERROR] invalid format specification for an interval value + DETAIL: format D + HINT: intervals are not tied to specific calendar dates +IDTH: [ERROR] invalid format specification for an interval value + DETAIL: format ID + HINT: intervals are not tied to specific calendar dates + +# Test day of year operations. +timestamp_fmt +2020-07-20 00:15:18 +DDD +DDDTH +IDDD +---- +DDD: 202 +DDDTH: 202ND +IDDD: 204 + +interval_fmt +3 years 1 day +DDD +DDDTH +IDDD +---- +DDD: 1081 +DDDTH: 1081ST +IDDD: [ERROR] invalid format specification for an interval value + DETAIL: format IDDD + HINT: intervals are not tied to specific calendar dates + +timestamp +IDDD +2019-12-29 00:15:18 +2019-12-30 00:15:18 +2020-01-03 01:02:03 +2020-01-04 01:02:03 +2020-01-05 01:02:03 +2020-01-06 01:02:03 +---- +2019-12-29 00:15:18: 364 +2019-12-30 00:15:18: 001 +2020-01-03 01:02:03: 005 +2020-01-04 01:02:03: 006 +2020-01-05 01:02:03: 007 +2020-01-06 01:02:03: 008 + +# Test day operations. +timestamp_fmt +2020-07-03 01:13:15 +DD +FMDD +FMDDth +---- +DD: 03 +FMDD: 3 +FMDDth: 3rd + +interval_fmt +37 months 5 days +DD +FMDD +FMDDth +---- +DD: 05 +FMDD: 5 +FMDDth: 5th + +# Test years and isoyears. +timestamp_fmt +0005-01-02 01:02:03 +YYYY +IYYY +YYYYth +FMIYYY +YYY +IYY +YY +IY +Y +I +---- +YYYY: 0005 +IYYY: 0004 +YYYYth: 0005th +FMIYYY: 4 +YYY: 005 +IYY: 004 +YY: 05 +IY: 04 +Y: 5 +I: 4 + +interval_fmt +37 months 5 days +0005-01-02 01:02:03 +YYYY +IYYY +YYYYth +FMIYYY +YYY +IYY +YY +IY +Y +I +---- +0005-01-02 01:02:03: 0005-01-02 01:02:03 +YYYY: 0003 +IYYY: [ERROR] invalid format specification for an interval value + DETAIL: format IYYY + HINT: intervals are not tied to specific calendar dates +YYYYth: 0003rd +FMIYYY: [ERROR] invalid format specification for an interval value + DETAIL: format IYYY + HINT: intervals are not tied to specific calendar dates +YYY: 003 +IYY: [ERROR] invalid format specification for an interval value + DETAIL: format IYY + HINT: intervals are not tied to specific calendar dates +YY: 03 +IY: [ERROR] invalid format specification for an interval value + DETAIL: format IY + HINT: intervals are not tied to specific calendar dates +Y: 3 +I: [ERROR] invalid format specification for an interval value + DETAIL: format I + HINT: intervals are not tied to specific calendar dates + +interval_fmt +-37 months 5 days +YYYY +IYYY +YYYYth +FMIYYY +YYY +IYY +YY +IY +Y +I +---- +YYYY: -0003 +IYYY: [ERROR] invalid format specification for an interval value + DETAIL: format IYYY + HINT: intervals are not tied to specific calendar dates +YYYYth: -0003rd +FMIYYY: [ERROR] invalid format specification for an interval value + DETAIL: format IYYY + HINT: intervals are not tied to specific calendar dates +YYY: -003 +IYY: [ERROR] invalid format specification for an interval value + DETAIL: format IYY + HINT: intervals are not tied to specific calendar dates +YY: -03 +IY: [ERROR] invalid format specification for an interval value + DETAIL: format IY + HINT: intervals are not tied to specific calendar dates +Y: -3 +I: [ERROR] invalid format specification for an interval value + DETAIL: format I + HINT: intervals are not tied to specific calendar dates + +timestamp_fmt +0003-01-02 BC 01:02:03 +YYYY +IYYY +YYYYth +FMIYYY +YYY +IYY +YY +IY +Y +I +---- +YYYY: 0003 +IYYY: 0003 +YYYYth: 0003rd +FMIYYY: 3 +YYY: 003 +IYY: 003 +YY: 03 +IY: 03 +Y: 3 +I: 3 + +# Test comma years +timestamp +Y,YYY +0005-01-02 BC 01:02:03 +0005-01-02 01:02:03 +0105-01-02 01:02:03 +2005-01-02 01:02:03 +20005-01-02 01:02:03 +---- +0005-01-02 BC 01:02:03: 0,005 +0005-01-02 01:02:03: 0,005 +0105-01-02 01:02:03: 0,105 +2005-01-02 01:02:03: 2,005 +20005-01-02 01:02:03: 20,005 + +interval +Y,YYY +3 months +15 months +12500 months +-120 months +-1500 months +---- +3 months: 0,000 +15 months: 0,001 +12500 months: 1,041 +-120 months: 0,-10 +-1500 months: 0,-125 + +# Week +timestamp_fmt +2022-01-07 01:02:03 +WW +FMWWth +IW +FMIW +FMIWth +---- +WW: 01 +FMWWth: 1st +IW: 01 +FMIW: 1 +FMIWth: 1st + +interval_fmt +36 months +WW +FMWWth +IW +FMIW +FMIWth +---- +WW: 155 +FMWWth: 155th +IW: [ERROR] invalid format specification for an interval value + DETAIL: format IW + HINT: intervals are not tied to specific calendar dates +FMIW: [ERROR] invalid format specification for an interval value + DETAIL: format IW + HINT: intervals are not tied to specific calendar dates +FMIWth: [ERROR] invalid format specification for an interval value + DETAIL: format IW + HINT: intervals are not tied to specific calendar dates + +# Quarters +timestamp +Q +2022-01-01 01:02:03 +2022-02-01 01:02:03 +2022-03-01 01:02:03 +2022-04-01 01:02:03 +2022-05-01 01:02:03 +2022-06-01 01:02:03 +2022-07-01 01:02:03 +2022-08-01 01:02:03 +2022-09-01 01:02:03 +2022-10-01 01:02:03 +2022-11-01 01:02:03 +2022-12-01 01:02:03 +---- +2022-01-01 01:02:03: 1 +2022-02-01 01:02:03: 1 +2022-03-01 01:02:03: 1 +2022-04-01 01:02:03: 2 +2022-05-01 01:02:03: 2 +2022-06-01 01:02:03: 2 +2022-07-01 01:02:03: 3 +2022-08-01 01:02:03: 3 +2022-09-01 01:02:03: 3 +2022-10-01 01:02:03: 4 +2022-11-01 01:02:03: 4 +2022-12-01 01:02:03: 4 + +interval +Q +1 month +3 months +12 months +13 months +---- +1 month: 1 +3 months: 1 +12 months: +13 months: 1 + +# Century +timestamp +CC +0005-01-02 BC 01:02:03 +1005-01-02 BC 01:02:03 +1999-01-01 01:02:03 +2000-01-01 01:02:03 +2001-01-01 01:02:03 +---- +0005-01-02 BC 01:02:03: -01 +1005-01-02 BC 01:02:03: -11 +1999-01-01 01:02:03: 20 +2000-01-01 01:02:03: 20 +2001-01-01 01:02:03: 21 + +timestamp +FMCCth +0005-01-02 BC 01:02:03 +---- +0005-01-02 BC 01:02:03: -1st + +interval +CC +12000 months +5 months +-12000 months +---- +12000 months: 10 +5 months: 00 +-12000 months: -10 + +# Week of month. +timestamp +Wth +2020-01-01 01:02:03 +2020-01-06 01:02:03 +2020-01-07 01:02:03 +2020-01-08 01:02:03 +---- +2020-01-01 01:02:03: 1st +2020-01-06 01:02:03: 1st +2020-01-07 01:02:03: 1st +2020-01-08 01:02:03: 2nd + +# Julian +timestamp +Jth +2020-04-07 01:02:03 +---- +2020-04-07 01:02:03: 2458947th + +interval +J +3 years 6 months 01:02:03 +---- +3 years 6 months 01:02:03: 1722306 + +# RM +timestamp +RM"x" +2020-02-01 BC 01:02:03 +2020-12-01 01:02:03 +2020-01-01 01:02:03 +---- +2020-02-01 BC 01:02:03: II x +2020-12-01 01:02:03: XII x +2020-01-01 01:02:03: I x + +timestamp +rm"< spaces here" +2020-02-01 BC 01:02:03 +2020-12-01 01:02:03 +2020-01-01 01:02:03 +---- +2020-02-01 BC 01:02:03: ii < spaces here +2020-12-01 01:02:03: xii < spaces here +2020-01-01 01:02:03: i < spaces here + +interval +FMRM" < no spaces here" +1 day +1 month 1 day +-1 month 1 day +-13 months +13 months +---- +1 day: < no spaces here +1 month 1 day: I < no spaces here +-1 month 1 day: XI < no spaces here +-13 months: XI < no spaces here +13 months: I < no spaces here diff --git a/pkg/util/tochar/tochar.go b/pkg/util/tochar/tochar.go new file mode 100644 index 000000000000..993be69abce9 --- /dev/null +++ b/pkg/util/tochar/tochar.go @@ -0,0 +1,720 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package tochar + +import ( + "fmt" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/cockroachdb/cockroach/pkg/sql/pgwire/pgcode" + "github.com/cockroachdb/cockroach/pkg/sql/pgwire/pgerror" + "github.com/cockroachdb/cockroach/pkg/util/duration" + "github.com/cockroachdb/cockroach/pkg/util/timeutil/pgdate" + "github.com/cockroachdb/errors" +) + +// TimeToChar converts a time and a `to_char` format string to a string. +func TimeToChar(t time.Time, f string) (string, error) { + return timeToChar(timeWrapper{t}, parseFormat(f)) +} + +// DurationToChar converts a duration and a `to_char` format string to a string. +func DurationToChar(d duration.Duration, f string) (string, error) { + // TODO(#sql-experience): consider caching parse formats. + return timeToChar(makeToCharDuration(d), parseFormat(f)) +} + +// timeInterface is intended as a pass through to timeToChar. +type timeInterface interface { + Year() int + Month() time.Month + Day() int + Minute() int + Hour() int + Second() int + Nanosecond() int + YearDay() int + Weekday() time.Weekday + ISOWeek() (int, int) + Zone() (string, int) + isInterval() bool +} + +// timeWrapper wraps time.Time to implement timeInterface. +type timeWrapper struct { + time.Time +} + +func (timeWrapper) isInterval() bool { + return false +} + +// toCharDuration maps as a duration to usable for timeInterface. +// We use the same logic as PG to convert intervals to a fake time here. +type toCharDuration struct { + year, day int + month time.Month + hour, minute, second, nanos int + yday int +} + +// makeToCharDuration converts a duration.Duration to a toCharDuration. +func makeToCharDuration(d duration.Duration) toCharDuration { + n := time.Duration(d.Nanos()) + return toCharDuration{ + year: int(d.Months / 12), + month: time.Month(d.Months % 12), + day: int(d.Days), + nanos: int(n % time.Second), + second: int(n/time.Second) % 60, + minute: int(n/time.Minute) % 60, + hour: int(n / time.Hour), + // Approximation that PostgreSQL uses. + yday: int(d.Months*30 + d.Days), + } +} + +func (d toCharDuration) Year() int { + return d.year +} + +func (d toCharDuration) Month() time.Month { + return d.month +} + +func (d toCharDuration) Day() int { + return d.day +} + +func (d toCharDuration) Minute() int { + return d.minute +} + +func (d toCharDuration) Hour() int { + return d.hour +} + +func (d toCharDuration) Second() int { + return d.second +} + +func (d toCharDuration) Nanosecond() int { + return d.nanos +} + +func (d toCharDuration) YearDay() int { + return d.yday +} + +func (d toCharDuration) Weekday() time.Weekday { + return time.Monday +} + +func (d toCharDuration) ISOWeek() (int, int) { + return 0, 0 +} + +func (d toCharDuration) Zone() (string, int) { + return "", 0 +} + +func (d toCharDuration) isInterval() bool { + return true +} + +func makeIntervalUnsupportedError(s string) error { + return errors.WithDetailf( + errors.WithHint( + pgerror.New( + pgcode.InvalidDatetimeFormat, + "invalid format specification for an interval value", + ), + "intervals are not tied to specific calendar dates", + ), + "format %s", + s, + ) +} + +// timeToChar maps to DCHToChar. +func timeToChar(t timeInterface, formatNodes []formatNode) (string, error) { + var sb strings.Builder + for _, fn := range formatNodes { + if fn.typ != formatNodeAction { + sb.WriteString(fn.character) + continue + } + + // NOTE(otan): postgres does localization logic if TM is specified. + // This is not supported on a number of these fields. + switch fn.key.id { + case DCH_A_M, DCH_P_M: + if t.Hour() >= 12 { + sb.WriteString("P.M.") + } else { + sb.WriteString("A.M.") + } + case DCH_AM, DCH_PM: + if t.Hour() >= 12 { + sb.WriteString("PM") + } else { + sb.WriteString("AM") + } + case DCH_a_m, DCH_p_m: + if t.Hour() >= 12 { + sb.WriteString("p.m.") + } else { + sb.WriteString("a.m.") + } + case DCH_am, DCH_pm: + if t.Hour() >= 12 { + sb.WriteString("pm") + } else { + sb.WriteString("am") + } + case DCH_HH, DCH_HH12: + hour := t.Hour() + if hour%12 == 0 { + hour = 12 + } else { + hour = hour % 12 + } + // Note t.Hour() and hour are deliberately different. + sb.WriteString(fmt.Sprintf("%0*d", fn.suffix.zeroPad2PlusNeg(t.Hour()), hour)) + sb.WriteString(fn.suffix.thVal(hour)) + case DCH_HH24: + sb.WriteString(fmt.Sprintf("%0*d", fn.suffix.zeroPad2PlusNeg(t.Hour()), t.Hour())) + sb.WriteString(fn.suffix.thVal(t.Hour())) + case DCH_MI: + sb.WriteString(fmt.Sprintf("%0*d", fn.suffix.zeroPad2PlusNeg(t.Minute()), t.Minute())) + sb.WriteString(fn.suffix.thVal(t.Minute())) + case DCH_SS: + sb.WriteString(fmt.Sprintf("%0*d", fn.suffix.zeroPad2PlusNeg(t.Second()), t.Second())) + sb.WriteString(fn.suffix.thVal(t.Second())) + case DCH_FF1: + val := t.Nanosecond() / 100000000 + sb.WriteString(fmt.Sprintf("%01d", val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_FF2: + val := t.Nanosecond() / 10000000 + sb.WriteString(fmt.Sprintf("%02d", val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_FF3, DCH_MS: + val := t.Nanosecond() / 1000000 + sb.WriteString(fmt.Sprintf("%03d", val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_FF4: + val := t.Nanosecond() / 100000 + sb.WriteString(fmt.Sprintf("%04d", val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_FF5: + val := t.Nanosecond() / 10000 + sb.WriteString(fmt.Sprintf("%05d", val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_FF6, DCH_US: + val := t.Nanosecond() / 1000 + sb.WriteString(fmt.Sprintf("%06d", val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_SSSS: + val := t.Hour()*60*60 + t.Minute()*60 + t.Second() + sb.WriteString(fmt.Sprintf("%d", val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_tz: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + n, _ := t.Zone() + sb.WriteString(strings.ToLower(n)) + case DCH_TZ: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + n, _ := t.Zone() + sb.WriteString(n) + case DCH_TZH: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + _, offset := t.Zone() + posStr := "+" + if offset < 0 { + posStr = "-" + offset = -offset + } + sb.WriteString(posStr) + sb.WriteString(fmt.Sprintf("%02d", offset/3600)) + case DCH_TZM: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + _, offset := t.Zone() + if offset < 0 { + offset = -offset + } + sb.WriteString(fmt.Sprintf("%02d", (offset%3600)/60)) + case DCH_OF: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + _, offset := t.Zone() + posStr := "+" + if offset < 0 { + posStr = "-" + offset = -offset + } + sb.WriteString(posStr) + var zeroPad int + if !fn.suffix.FM() { + zeroPad = 2 + } + sb.WriteString(fmt.Sprintf("%0*d", zeroPad, offset/3600)) + minOffset := (offset % 3600) / 60 + if minOffset != 0 { + sb.WriteString(fmt.Sprintf(":%02d", minOffset)) + } + case DCH_A_D, DCH_B_C: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + if t.Year() <= 0 { + sb.WriteString("B.C.") + } else { + sb.WriteString("A.D.") + } + case DCH_AD, DCH_BC: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + if t.Year() <= 0 { + sb.WriteString("BC") + } else { + sb.WriteString("AD") + } + case DCH_a_d, DCH_b_c: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + if t.Year() <= 0 { + sb.WriteString("b.c.") + } else { + sb.WriteString("a.d.") + } + case DCH_ad, DCH_bc: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + if t.Year() <= 0 { + sb.WriteString("bc") + } else { + sb.WriteString("ad") + } + case DCH_MONTH, DCH_Month, DCH_month: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + m := t.Month().String() + switch fn.key.id { + case DCH_MONTH: + m = strings.ToUpper(m) + case DCH_month: + m = strings.ToLower(m) + } + sb.WriteString(fmt.Sprintf("%*s", fn.suffix.zeroPad(-9), m)) + case DCH_MON, DCH_Mon, DCH_mon: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + m := t.Month().String()[:3] + switch fn.key.id { + case DCH_MON: + m = strings.ToUpper(m) + case DCH_mon: + m = strings.ToLower(m) + } + sb.WriteString(m) + case DCH_MM: + val := int(t.Month()) + sb.WriteString(fmt.Sprintf("%0*d", fn.suffix.zeroPad2PlusNeg(val), val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_DAY, DCH_Day, DCH_day: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + d := t.Weekday().String() + switch fn.key.id { + case DCH_DAY: + d = strings.ToLower(d) + case DCH_day: + d = strings.ToLower(d) + } + sb.WriteString(fmt.Sprintf("%*s", fn.suffix.zeroPad(-9), d)) + case DCH_DY, DCH_Dy, DCH_dy: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + d := t.Weekday().String()[:3] + switch fn.key.id { + case DCH_DY: + d = strings.ToLower(d) + case DCH_dy: + d = strings.ToLower(d) + } + sb.WriteString(d) + case DCH_DDD: + val := t.YearDay() + sb.WriteString(fmt.Sprintf("%0*d", fn.suffix.zeroPad(3), val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_DD: + sb.WriteString(fmt.Sprintf("%0*d", fn.suffix.zeroPad(2), t.Day())) + sb.WriteString(fn.suffix.thVal(t.Day())) + case DCH_IDDD: + // Added by us, as ISOWeek() does not work. + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + _, w := t.ISOWeek() + val := (w-1)*7 + int(t.Weekday()) + if t.Weekday() == 0 { + val += 7 + } + sb.WriteString(fmt.Sprintf("%0*d", fn.suffix.zeroPad(3), val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_D: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + val := int(t.Weekday()) + 1 + sb.WriteString(fmt.Sprintf("%d", val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_ID: + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + val := int(t.Weekday()) + if val == 0 { + val = 7 + } + sb.WriteString(fmt.Sprintf("%d", val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_WW: + val := (t.YearDay()-1)/7 + 1 + sb.WriteString(fmt.Sprintf("%0*d", fn.suffix.zeroPad(2), val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_IW: + // Added by us, as ISOWeek() does not work. + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + _, val := t.ISOWeek() + sb.WriteString(fmt.Sprintf("%0*d", fn.suffix.zeroPad(2), val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_Q: + // For intervals. + if int(t.Month()) == 0 { + break + } + val := (int(t.Month())-1)/3 + 1 + sb.WriteString(fmt.Sprintf("%d", val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_CC: + var val int + if t.isInterval() { + val = t.Year() / 100 + } else { + if t.Year() > 0 { + val = (t.Year()-1)/100 + 1 + } else { + val = t.Year()/100 - 1 + } + } + if val <= 99 || val >= 99 { + sb.WriteString(fmt.Sprintf("%0*d", fn.suffix.zeroPad2PlusNeg(val), val)) + } else { + sb.WriteString(fmt.Sprintf("%d", val)) + } + sb.WriteString(fn.suffix.thVal(val)) + case DCH_Y_YYY: + year := t.Year() + if !t.isInterval() && year < 0 { + year = -year + 1 + } + preComma := year / 1000 + postComma := year % 1000 + sb.WriteString(fmt.Sprintf("%d,%03d", preComma, postComma)) + sb.WriteString(fn.suffix.thVal(postComma)) + case DCH_YYYY, DCH_IYYY, DCH_YYY, DCH_IYY, DCH_YY, DCH_IY, DCH_Y, DCH_I: + val := t.Year() + switch fn.key.id { + case DCH_IYYY, DCH_IYY, DCH_IY, DCH_I: + // Added by us, as ISOWeek() does not work. + if t.isInterval() { + return "", makeIntervalUnsupportedError(fn.key.name) + } + val, _ = t.ISOWeek() + } + if !t.isInterval() && val < 0 { + val = -val + 1 + } + zeroPad := 0 + if !fn.suffix.FM() { + switch fn.key.id { + case DCH_IYYY, DCH_YYYY: + zeroPad = 4 + case DCH_IYY, DCH_YYY: + zeroPad = 3 + case DCH_IY, DCH_YY: + zeroPad = 2 + case DCH_I, DCH_Y: + zeroPad = 1 + } + } + switch fn.key.id { + case DCH_IYY, DCH_YYY: + val %= 1000 + case DCH_IY, DCH_YY: + val %= 100 + case DCH_I, DCH_Y: + val %= 10 + } + // For intervals, negative values get an extra digit. + if t.isInterval() && val < 0 { + zeroPad++ + } + sb.WriteString(fmt.Sprintf("%0*d", zeroPad, val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_RM, DCH_rm: + if t.Month() == 0 && t.Year() == 0 { + continue + } + // Roman numerals are in reverse order, which is why the indexes + // are reversed. + var idx int + if t.Month() == 0 { + if t.Year() >= 0 { + idx = 0 + } else { + idx = 11 + } + } else if t.Month() < 0 { + idx = -1 * int(t.Month()) + } else { + idx = 12 - int(t.Month()) + } + var str string + if fn.key.id == DCH_RM { + str = ucMonthRomanNumerals[idx] + } else { + str = lcMonthRomanNumerals[idx] + } + sb.WriteString(fmt.Sprintf("%*s", fn.suffix.zeroPad(-4), str)) + case DCH_W: + val := (t.Day()-1)/7 + 1 + sb.WriteString(fmt.Sprintf("%d", val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_J: + val := pgdate.DateToJulianDay(t.Year(), int(t.Month()), t.Day()) + sb.WriteString(fmt.Sprintf("%d", val)) + sb.WriteString(fn.suffix.thVal(val)) + case DCH_FX, DCH_fx: + // Does nothing. + default: + return "", errors.AssertionFailedf("unexpected key for to_char: %s", errors.Safe(fn.key.name)) + } + } + return sb.String(), nil +} + +func (d dchSuffix) thVal(val int) string { + if d.THth() { + return getTH(val, d.TH()) + } + return "" +} + +// getTH returns the suffix for the given value as a position, e.g. +// 1 => st for 1st, 4 => th for 4th. +func getTH(v int, upper bool) string { + if v < 0 { + v = -v + } + v = v % 100 + // 11, 12 and 13 are still -th, so don't go in here. + if v < 11 || v > 13 { + switch v % 10 { + case 1: + if upper { + return "ST" + } + return "st" + case 2: + if upper { + return "ND" + } + return "nd" + case 3: + if upper { + return "RD" + } + return "rd" + } + } + if upper { + return "TH" + } + return "th" +} + +func (d dchSuffix) zeroPad(nonFMVal int) int { + var zeroPad int + if !d.FM() { + zeroPad = nonFMVal + } + return zeroPad +} + +// zeroPad2PlusNeg pads 2 if FM is not set, accounting for the negative digit +// where necessary. +func (d dchSuffix) zeroPad2PlusNeg(val int) int { + var zeroPad int + if !d.FM() { + if val >= 0 { + zeroPad = 2 + } else { + zeroPad = 3 + } + } + return zeroPad +} + +// parseFormat matches parse_format. We do not take in a flags like PG as we only do +// datetime related items (the logic here will change if we support numeric types). +// Taken from https://github.com/postgres/postgres/blob/b0b72c64a0ce7bf5dd78a80b33d85c89c943ad0d/src/backend/utils/adt/formatting.c#L1146. +// TODO(#sql-experience): consider caching parse formats. +func parseFormat(f string) []formatNode { + var ret []formatNode + for len(f) > 0 { + var suffix dchSuffix + if s := suffSearch(f, keySuffixPrefix); s != nil { + suffix |= s.id + f = f[len(s.name):] + } + + if len(f) > 0 { + kw := indexSeqSearch(f) + if kw != nil { + ret = append(ret, formatNode{ + typ: formatNodeAction, + suffix: suffix, + key: kw, + }) + f = f[len(kw.name):] + if len(f) > 0 { + if s := suffSearch(f, keySuffixPostfix); s != nil { + ret[len(ret)-1].suffix |= s.id + f = f[len(s.name):] + } + } + } else { + if f[0] == '"' { + // Process any quoted text. + f = f[1:] + for len(f) > 0 { + if f[0] == '"' { + f = f[1:] + break + } + // Escape the next character. + if f[0] == '\\' { + f = f[1:] + } + r, size := utf8.DecodeRuneInString(f) + ret = append(ret, formatNode{ + typ: formatNodeChar, + character: string(r), + }) + f = f[size:] + } + } else { + // Outside double-quoted strings, backslash is only special if it + // immediately precedes a double quote. + if f[0] == '\\' && len(f) > 1 && f[1] == '"' { + f = f[1:] + } + + r, size := utf8.DecodeRuneInString(f) + typ := formatNodeChar + if isSeparatorChar(r) { + typ = formatNodeSeparator + } else if unicode.IsSpace(r) { + typ = formatNodeSpace + } + ret = append(ret, formatNode{ + typ: typ, + character: string(r), + }) + f = f[size:] + } + } + } + } + ret = append(ret, formatNode{typ: formatNodeEnd}) + return ret +} + +func isSeparatorChar(c rune) bool { + /* ASCII printable character, but not letter or digit */ + return c > 0x20 && c < 0x7F && + !(c >= 'A' && c <= 'Z') && + !(c >= 'a' && c <= 'z') && + !(c >= '0' && c <= '9') +} + +// indexSeqSearch matches index_seq_search. +// Taken from https://github.com/postgres/postgres/blob/b0b72c64a0ce7bf5dd78a80b33d85c89c943ad0d/src/backend/utils/adt/formatting.c#L194-L198. +func indexSeqSearch(s string) *keyWord { + if len(s) == 0 || s[0] <= ' ' || s[0] >= '~' { + return nil + } + idxOffset := int(s[0] - ' ') + if idxOffset > -1 && idxOffset < len(dchIndex) { + pos := int(dchIndex[idxOffset]) + for pos >= 0 && pos < len(dchKeywords) { + k := dchKeywords[pos] + if strings.HasPrefix(s, k.name) { + return &dchKeywords[pos] + } + pos++ + // Since we have dchKeywords sorted in alphabetical order, so if we don't + // match the first character, break. + if pos >= len(dchKeywords) || dchKeywords[pos].name[0] != s[0] { + break + } + } + } + return nil +} + +// suffSearch matches suff_search. +// Taken from https://github.com/postgres/postgres/blob/b0b72c64a0ce7bf5dd78a80b33d85c89c943ad0d/src/backend/utils/adt/formatting.c#L1146. +func suffSearch(s string, typ keySuffixType) *keySuffix { + for _, suff := range dchSuffixes { + if suff.typ != typ { + continue + } + if strings.HasPrefix(s, suff.name) { + return &suff + } + } + return nil +} diff --git a/pkg/util/tochar/tochar_test.go b/pkg/util/tochar/tochar_test.go new file mode 100644 index 000000000000..ba4772611e12 --- /dev/null +++ b/pkg/util/tochar/tochar_test.go @@ -0,0 +1,173 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package tochar + +import ( + "strings" + "testing" + "time" + + "github.com/cockroachdb/cockroach/pkg/sql/pgwire/pgerror" + "github.com/cockroachdb/cockroach/pkg/sql/types" + "github.com/cockroachdb/cockroach/pkg/util/duration" + "github.com/cockroachdb/cockroach/pkg/util/timeutil" + "github.com/cockroachdb/cockroach/pkg/util/timeutil/pgdate" + "github.com/cockroachdb/datadriven" + "github.com/cockroachdb/errors" + "github.com/stretchr/testify/require" +) + +// TestDataDriven tests the relevant to_char facilities. +func TestDataDriven(t *testing.T) { + now := timeutil.Now().UTC() + ds := pgdate.DateStyle{ + Style: pgdate.Style_ISO, + Order: pgdate.Order_YMD, + } + is := duration.IntervalStyle_POSTGRES + datadriven.Walk(t, "testdata", func(t *testing.T, path string) { + datadriven.RunTest(t, path, func(t *testing.T, d *datadriven.TestData) string { + var ret strings.Builder + switch d.Cmd { + case "timestamp": + tz := parseArgs(t, d.CmdArgs) + // Input is format, followed by timestamps separated by newlines. + // Output is the same timestamps in the given format. + lines := strings.Split(d.Input, "\n") + for i, in := range lines[1:] { + if i > 0 { + ret.WriteString("\n") + } + ts, _, err := pgdate.ParseTimestamp(now, ds, in) + require.NoError(t, err) + r, err := TimeToChar(ts.In(tz), lines[0]) + if err != nil { + ret.WriteString(in + ": [ERROR] " + err.Error() + errorDetails(err)) + } else { + ret.WriteString(in + ": " + r) + } + } + case "timestamp_fmt": + tz := parseArgs(t, d.CmdArgs) + // Input is timestamp, followed by formats separated by newlines. + // Output is the timestamp in the given formats. + lines := strings.Split(d.Input, "\n") + ts, _, err := pgdate.ParseTimestamp(now, ds, lines[0]) + for i, in := range lines[1:] { + if i > 0 { + ret.WriteString("\n") + } + require.NoError(t, err) + r, err := TimeToChar(ts.In(tz), in) + if err != nil { + ret.WriteString(in + ": [ERROR] " + err.Error() + errorDetails(err)) + } else { + ret.WriteString(in + ": " + r) + } + } + case "interval": + // Input is format, followed by intervals separated by newlines. + // Output is the same intervals in the given format. + lines := strings.Split(d.Input, "\n") + for i, in := range lines[1:] { + if i > 0 { + ret.WriteString("\n") + } + ivl, err := duration.ParseInterval(is, in, types.IntervalTypeMetadata{}) + require.NoError(t, err) + r, err := DurationToChar(ivl, lines[0]) + if err != nil { + ret.WriteString(in + ": [ERROR] " + err.Error() + errorDetails(err)) + } else { + ret.WriteString(in + ": " + r) + } + } + case "interval_fmt": + // Input is interval, followed by formats separated by newlines. + // Output is the interval in the given formats. + lines := strings.Split(d.Input, "\n") + ivl, err := duration.ParseInterval(is, lines[0], types.IntervalTypeMetadata{}) + require.NoError(t, err) + for i, in := range lines[1:] { + if i > 0 { + ret.WriteString("\n") + } + require.NoError(t, err) + r, err := DurationToChar(ivl, in) + if err != nil { + ret.WriteString(in + ": [ERROR] " + err.Error() + errorDetails(err)) + } else { + ret.WriteString(in + ": " + r) + } + } + default: + t.Errorf("unknown command %s", d.Cmd) + } + return ret.String() + }) + }) +} + +func errorDetails(err error) string { + var sb strings.Builder + for _, detail := range errors.GetAllDetails(err) { + sb.WriteString("\n DETAIL: ") + sb.WriteString(detail) + } + for _, hint := range errors.GetAllHints(err) { + sb.WriteString("\n HINT: ") + sb.WriteString(hint) + } + return sb.String() +} + +// TestAllTimestamps does a basic sanity check that all time types are supported +// and do not panic. +func TestAllTimestamps(t *testing.T) { + n := timeutil.Now() + for _, kw := range dchKeywords { + t.Run(kw.name, func(t *testing.T) { + _, err := TimeToChar(n, kw.name) + if err != nil { + require.True(t, pgerror.HasCandidateCode(err)) + } + }) + } +} + +// TestAllIntervals does a basic sanity check that all time types are supported +// and do not panic. +func TestAllIntervals(t *testing.T) { + d := duration.MakeDuration(0, 10, 15) + for _, kw := range dchKeywords { + t.Run(kw.name, func(t *testing.T) { + _, err := DurationToChar(d, kw.name) + if err != nil { + require.True(t, pgerror.HasCandidateCode(err)) + } + }) + } +} + +func parseArgs(t *testing.T, cmdArgs []datadriven.CmdArg) *time.Location { + tz := time.UTC + for _, arg := range cmdArgs { + switch arg.Key { + case "tz": + var err error + tz, err = timeutil.LoadLocation(arg.Vals[0]) + require.NoError(t, err) + default: + t.Errorf("unknown arg %s", arg.Key) + } + } + return tz +}