From 69692f295d4575c3d713f07cc521d7d3250574b0 Mon Sep 17 00:00:00 2001 From: Alexandre Felipe Date: Sun, 23 Aug 2026 20:53:01 +0100 Subject: [PATCH-v15 6/6] [SLOPE] Timezone Add slope support to functions that use a `timestamp` computed from a `timestamp with timezone`. Time in certain time zones might have discontinuities, mostly due to dailight saving time (DST), but maybe others. The `timestamp with timezone` is represented with resolution of 1 microsecond at UTC, when converted to a `timestamp` the session timezone offset of the timezone at the given time is computed and applied, the offset varies with time, and the two common discontinuities are a spring-forward when the clock advances creating effectively skipping a range of timestamps, or a fall-back when the clock moves backward, creating a range of ambiguous timestamps. The fall-back discontinuities cause the timestamp to be discontinuous, but certain parts of the timestamp might remain monotonic. The most relevant example is the date of a timestamp, in most of the timezones, a fall back stays within the same day. Follows a query to the discontinuities discussed above. WITH RECURSIVE bin AS ( -- search a intervals of duration t_step SELECT n.name AS tz, t AS lo, t + t_step AS hi, t_step FROM pg_timezone_names n, (VALUES (timestamptz '1900-01-01', timestamptz '2026-01-01', interval '7 days')) AS range(t_from, t_to, t_step), generate_series(t_from, t_to, t_step) AS t WHERE -- keep only those having a discontinuity timezone(name, t + t_step) - timezone(name, t) <> t_step UNION ALL SELECT tz, CASE WHEN pick_hi THEN mid ELSE lo END, CASE WHEN pick_hi THEN hi ELSE mid END, t_step / 2 FROM bin, LATERAL (SELECT (lo + t_step / 2) AS mid, timezone(tz, lo + t_step / 2) - timezone(tz, lo) = t_step / 2 as pick_hi ) ), discontinuities AS ( SELECT tz, hi AS utc, timezone(tz, lo) AS before, timezone(tz, hi) AS after FROM bin WHERE hi - lo < interval '2 microseconds' ) SELECT * FROM discontinuities -- Having a list of discontinuities, it is easier to respond -- questions such as which discontinuities caused the date to -- move back WHERE after::date < before::date The functions supported now are - date_trunc(unit text, timestamptz) - date_trunc(unit text, timestamptz, timezone text) - timezone(timestamptz) - timezone(timezone text, timestamptz) - date(timestamptz) The timezone monotonicity is performed once when the timezone is loaded and checked when the monotonicity of one of those functions is requested. --- src/backend/utils/adt/timestamp.c | 8 +- src/backend/utils/fmgr/slopesupport.c | 166 +++++++++++++++++++- src/include/c.h | 5 + src/include/catalog/pg_proc.dat | 24 ++- src/include/pgtime.h | 12 ++ src/test/regress/expected/slope.out | 125 +++++++++++---- src/test/regress/expected/slope_catalog.out | 15 +- src/test/regress/sql/slope.sql | 82 ++++++++-- src/timezone/localtime.c | 11 ++ src/timezone/pgtz.c | 96 ++++++++++- src/timezone/pgtz.h | 2 + 11 files changed, 484 insertions(+), 62 deletions(-) diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c index 9c17ba2f905..de596eefc67 100644 --- a/src/backend/utils/adt/timestamp.c +++ b/src/backend/utils/adt/timestamp.c @@ -1921,10 +1921,6 @@ timestamp2tm(Timestamp dt, int *tzp, struct pg_tm *tm, fsec_t *fsec, const char Timestamp time; pg_time_t utime; - /* Use session timezone if caller asks for default */ - if (attimezone == NULL) - attimezone = session_timezone; - time = dt; TMODULO(time, date, USECS_PER_DAY); @@ -1970,7 +1966,9 @@ timestamp2tm(Timestamp dt, int *tzp, struct pg_tm *tm, fsec_t *fsec, const char utime = (pg_time_t) dt; if ((Timestamp) utime == dt) { - struct pg_tm *tx = pg_localtime(&utime, attimezone); + /* Use session timezone doesn't pass one */ + struct pg_tm *tx = pg_localtime(&utime, + attimezone == NULL ? session_timezone : attimezone); tm->tm_year = tx->tm_year + 1900; tm->tm_mon = tx->tm_mon + 1; diff --git a/src/backend/utils/fmgr/slopesupport.c b/src/backend/utils/fmgr/slopesupport.c index d14e1f8dcb4..d24115d2a5a 100644 --- a/src/backend/utils/fmgr/slopesupport.c +++ b/src/backend/utils/fmgr/slopesupport.c @@ -1,9 +1,14 @@ #include "postgres.h" -#include "c.h" +#include "catalog/pg_type.h" #include "nodes/supportnodes.h" +#include "parser/scansup.h" +#include "pgtime.h" +#include "utils/builtins.h" +#include "utils/datetime.h" #include "utils/fmgrprotos.h" #include "utils/numeric.h" +#include "varatt.h" /** * Extended numeric sign, the usual -1, 0, 1, @@ -358,3 +363,162 @@ divide_slope_support(PG_FUNCTION_ARGS) } PG_RETURN_POINTER(NULL); } + +/* + * Look up a timezone from a constant text argument. + */ +static pg_tz * +get_const_timezone_arg(List *args, int argno) +{ + Node *tz_arg_node; + Const *tz_const; + text *zone; + char tzname[TZ_STRLEN_MAX + 1]; + + if (args == NULL || list_length(args) <= argno) + return NULL; + + tz_arg_node = list_nth(args, argno); + if (!IsA(tz_arg_node, Const)) + return NULL; + + tz_const = (Const *) tz_arg_node; + + if (tz_const->constisnull || tz_const->consttype != TEXTOID) + return NULL; + + zone = DatumGetTextPP(tz_const->constvalue); + text_to_cstring_buffer(zone, tzname, sizeof(tzname)); + return DecodeTimezoneNameToTz(tzname); +} + +/* + * Slope support for date(timestamptz) + */ +Datum +timestamptz_date_slope_support(PG_FUNCTION_ARGS) +{ + Node *rawreq = (Node *) PG_GETARG_POINTER(0); + + if (pg_timezone_is_monotonic(session_timezone, TZ_MONOTONIC_DAY)) + return monotonic_slope_support(rawreq, 1, asc0_slope); + else + PG_RETURN_POINTER(NULL); +} + +static Datum +timestamptz_support_lookup(Node* rawreq, Datum fixed_unit, pg_tz *tzp) +{ + text *units; + int val; + char *lowunits; + int type; + TZMonotonicityBits tz_unit; + + units = DatumGetTextPP(fixed_unit); + + lowunits = downcase_truncate_identifier(VARDATA_ANY(units), + VARSIZE_ANY_EXHDR(units), + false); + + type = DecodeUnits(0, lowunits, &val); + + if (type != UNITS) + PG_RETURN_POINTER(NULL); + + switch (val) + { + case DTK_WEEK: + PG_RETURN_POINTER(NULL); + case DTK_MILLENNIUM: + case DTK_CENTURY: + case DTK_DECADE: + case DTK_YEAR: + tz_unit = TZ_MONOTONIC_YEAR; + break; + case DTK_QUARTER: + case DTK_MONTH: + tz_unit = TZ_MONOTONIC_MONTH; + break; + case DTK_DAY: + tz_unit = TZ_MONOTONIC_DAY; + break; + case DTK_HOUR: + tz_unit = TZ_MONOTONIC_HOUR; + break; + case DTK_MINUTE: + tz_unit = TZ_MONOTONIC_MINUTE; + break; + case DTK_SECOND: + case DTK_MILLISEC: + case DTK_MICROSEC: + tz_unit = TZ_MONOTONIC_SECOND; + break; + default: + PG_RETURN_POINTER(NULL); + } + if(pg_timezone_is_monotonic(tzp, tz_unit)) + return monotonic_slope_support(rawreq, 2, asc1_slope); + else + PG_RETURN_POINTER(NULL); +} + +Datum +timestamptz_trunc_slope_support(PG_FUNCTION_ARGS) +{ + Node *rawreq = (Node *) PG_GETARG_POINTER(0); + List *args = get_arg_list(rawreq); + Const *unit_arg; + pg_tz *tzp; + + if (args == NULL) + PG_RETURN_POINTER(NULL); + + + unit_arg = (Const *) linitial(args); + if (list_length(args) > 2) + tzp = get_const_timezone_arg(args, 2); + else + tzp = session_timezone; + if (!IsA(unit_arg, Const) || unit_arg->constisnull || tzp == NULL) + PG_RETURN_POINTER(NULL); + + return timestamptz_support_lookup(rawreq, unit_arg->constvalue, tzp); +} + +/* + * Slope support for timezone(text, timestamptz). + */ +Datum +timestamptz_zone_slope_support(PG_FUNCTION_ARGS) +{ + Node *rawreq = (Node *) PG_GETARG_POINTER(0); + List *args = get_arg_list(rawreq); + pg_tz *tzp; + + if (args == NULL || list_length(args) < 2) + PG_RETURN_POINTER(NULL); + + tzp = get_const_timezone_arg(args, 0); + if (tzp == NULL) + PG_RETURN_POINTER(NULL); + + if (pg_timezone_is_monotonic(tzp, TZ_MONOTONIC_SECOND)) + return monotonic_slope_support(rawreq, 2, asc1_slope); + else + PG_RETURN_POINTER(NULL); +} + +/* + * Slope support for timezone(timestamptz). + */ +Datum +timestamptz_at_local_slope_support(PG_FUNCTION_ARGS) +{ + Node *rawreq = (Node *) PG_GETARG_POINTER(0); + + if (pg_timezone_is_monotonic(session_timezone, TZ_MONOTONIC_SECOND)) + return monotonic_slope_support(rawreq, 1, asc0_slope); + else + PG_RETURN_POINTER(NULL); +} diff --git a/src/include/c.h b/src/include/c.h index 20cfbac54e7..6328dd1a39a 100644 --- a/src/include/c.h +++ b/src/include/c.h @@ -1285,6 +1285,9 @@ typedef struct PGAlignedXLogBlock PGAlignedXLogBlock; /* msb for char */ #define HIGHBIT (0x80) #define IS_HIGHBIT_SET(ch) ((unsigned char)(ch) & HIGHBIT) +/* ____-___ */ #define BITWISE_HI_AT(n) (1llu << n) +/* _____--- */ #define BITWISE_HI_RIGHT(n) ((1llu << (n + 1)) - 1) +/* -----___ */ #define BITWISE_LO_RIGHT(n) ~BIT_MASK_HI_RIGHT(n) /* * Support macros for escaping strings. escape_backslash should be true @@ -1565,6 +1568,8 @@ typedef uint32_t char32_t; #endif #endif + + /* IWYU pragma: end_exports */ #endif /* C_H */ diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index edc7dab98de..0295ec4ea8e 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -2471,10 +2471,12 @@ prorettype => 'timestamptz', proargtypes => 'float8', prosrc => 'float8_timestamptz' }, { oid => '1159', descr => 'adjust timestamp to new time zone', - proname => 'timezone', prorettype => 'timestamp', + proname => 'timezone', prosupport => 'timestamptz_zone_slope_support', + prorettype => 'timestamp', proargtypes => 'text timestamptz', prosrc => 'timestamptz_zone' }, { oid => '6334', descr => 'adjust timestamp to local time zone', - proname => 'timezone', provolatile => 's', prorettype => 'timestamp', + proname => 'timezone', prosupport => 'timestamptz_at_local_slope_support', + provolatile => 's', prorettype => 'timestamp', proargtypes => 'timestamptz', prosrc => 'timestamptz_at_local' }, { oid => '1160', descr => 'I/O', @@ -2552,6 +2554,7 @@ prosrc => 'see system_functions.sql' }, { oid => '1178', descr => 'convert timestamp with time zone to date', proname => 'date', provolatile => 's', prorettype => 'date', + prosupport => 'timestamptz_date_slope_support', proargtypes => 'timestamptz', prosrc => 'timestamptz_date' }, { oid => '1181', descr => 'age of a transaction ID, in transactions before current transaction', @@ -2635,10 +2638,12 @@ { oid => '1217', descr => 'truncate timestamp with time zone to specified units', proname => 'date_trunc', provolatile => 's', prorettype => 'timestamptz', + prosupport => 'timestamptz_trunc_slope_support', proargtypes => 'text timestamptz', prosrc => 'timestamptz_trunc' }, { oid => '1284', descr => 'truncate timestamp with time zone to specified units in specified time zone', proname => 'date_trunc', prorettype => 'timestamptz', + prosupport => 'timestamptz_trunc_slope_support', proargtypes => 'text timestamptz text', prosrc => 'timestamptz_trunc_zone' }, { oid => '1218', descr => 'truncate interval to specified units', proname => 'date_trunc', prorettype => 'interval', @@ -6735,7 +6740,8 @@ prosrc => 'see system_functions.sql' }, { oid => '2069', descr => 'adjust timestamp to new time zone', - proname => 'timezone', prorettype => 'timestamptz', + proname => 'timezone', prosupport => 'arg1_asc_slope_support', + prorettype => 'timestamptz', proargtypes => 'text timestamp', prosrc => 'timestamp_zone' }, { oid => '2070', descr => 'adjust timestamp to new time zone', proname => 'timezone', prorettype => 'timestamptz', @@ -12865,6 +12871,18 @@ { oid => '9960', descr => 'planner support for divide slope (sign-dependent)', proname => 'divide_slope_support', prorettype => 'internal', proargtypes => 'internal', prosrc => 'divide_slope_support' }, +{ oid => '9961', descr => 'planner support date(timestamptz)', + proname => 'timestamptz_date_slope_support', prorettype => 'internal', + proargtypes => 'internal', prosrc => 'timestamptz_date_slope_support' }, +{ oid => '9962', descr => 'planner support date_trunc(text, timestamptz)', + proname => 'timestamptz_trunc_slope_support', prorettype => 'internal', + proargtypes => 'internal', prosrc => 'timestamptz_trunc_slope_support' }, +{ oid => '9963', descr => 'planner support timezone(text, timestamptz)', + proname => 'timestamptz_zone_slope_support', prorettype => 'internal', + proargtypes => 'internal', prosrc => 'timestamptz_zone_slope_support' }, +{ oid => '9964', descr => 'planner support timezone(timestamptz)', + proname => 'timestamptz_at_local_slope_support', prorettype => 'internal', + proargtypes => 'internal', prosrc => 'timestamptz_at_local_slope_support' }, # AIO related functions { oid => '6399', descr => 'information about in-progress asynchronous IOs', diff --git a/src/include/pgtime.h b/src/include/pgtime.h index ba6705f71bd..6c5a397487a 100644 --- a/src/include/pgtime.h +++ b/src/include/pgtime.h @@ -46,6 +46,16 @@ struct pg_tm const char *tm_zone; }; +typedef enum TZMonotonicityBits { + TZ_MONOTONIC_YEAR , + TZ_MONOTONIC_MONTH , + TZ_MONOTONIC_DAY , + TZ_MONOTONIC_HOUR , + TZ_MONOTONIC_MINUTE, + TZ_MONOTONIC_SECOND, + TZ_MONOTONIC_NUM_BITS +} TZMonotonicityBits; + /* These structs are opaque outside the timezone library */ typedef struct pg_tz pg_tz; typedef struct pg_tzenum pg_tzenum; @@ -56,6 +66,8 @@ typedef struct pg_tzenum pg_tzenum; /* these functions are in localtime.c */ extern struct pg_tm *pg_localtime(const pg_time_t *timep, const pg_tz *tz); +extern bool pg_timezone_is_monotonic(const pg_tz *tz, TZMonotonicityBits unit); + extern struct pg_tm *pg_gmtime(const pg_time_t *timep); extern int pg_next_dst_boundary(const pg_time_t *timep, long int *before_gmtoff, diff --git a/src/test/regress/expected/slope.out b/src/test/regress/expected/slope.out index ebc4bd9b9d2..33b82dcc4ff 100644 --- a/src/test/regress/expected/slope.out +++ b/src/test/regress/expected/slope.out @@ -252,21 +252,6 @@ select date_trunc('day', ts), count(*) from src group by 1; Output: date_trunc('day'::text, ts) (5 rows) --- date_trunc on timestamptz should not use index -explain (costs off, verbose) -select date_trunc('day', tstz), count(*) from src group by 1; - QUERY PLAN -------------------------------------------------------------- - GroupAggregate - Output: (date_trunc('day'::text, tstz)), count(*) - Group Key: (date_trunc('day'::text, src.tstz)) - -> Sort - Output: (date_trunc('day'::text, tstz)) - Sort Key: (date_trunc('day'::text, src.tstz)) - -> Index Only Scan using src_tstz_idx on slope.src - Output: date_trunc('day'::text, tstz) -(8 rows) - -- -- Test arithmetic operations -- @@ -425,23 +410,6 @@ select -v_int4 from src order by 1; Output: (- v_int4) (5 rows) --- --- Group and order --- -explain (costs off, verbose) -select tstz::date, count(*) from src group by 1 order by 1; - QUERY PLAN -------------------------------------------------------------- - GroupAggregate - Output: ((tstz)::date), count(*) - Group Key: ((src.tstz)::date) - -> Sort - Output: ((tstz)::date) - Sort Key: ((src.tstz)::date) - -> Index Only Scan using src_tstz_idx on slope.src - Output: (tstz)::date -(8 rows) - -- -- Test nested monotonic function -- @@ -844,8 +812,98 @@ SELECT v_int4::oid FROM src ORDER BY 1; -> Index Only Scan using src_v_int4_idx on src (3 rows) +-- +-- Now that some plans were shown, and we see that in many cases what +-- we care is whether a plan has a index scan or not. This function will +-- check that for us and just return +-- +CREATE OR REPLACE FUNCTION index_plan (query text) RETURNS BOOLEAN LANGUAGE plpgsql AS $$ +DECLARE + plan json; +BEGIN + EXECUTE 'EXPLAIN (COSTS OFF, FORMAT JSON) SELECT ' || query || ' ORDER BY 1' into plan; + RETURN plan->0->'Plan'->>'Node Type' LIKE 'Index%Scan'; +END +$$; +-- +-- Functions that take a timezone argument +-- +EXECUTE query; +ERROR: prepared statement "query" does not exist +SELECT * +FROM unnest(ARRAY[ + 'Africa/Ouagadougou', 'Europe/London', 'Antarctica/Troll', 'Pacific/Guam', 'America/Goose_Bay' +]) AS timezones(tz_name), +LATERAL ( + SELECT + string_agg(unit, ', ') FILTER ( + WHERE index_plan('date_trunc(''' || unit || ''', tstz, ''' || tz_name || ''') FROM src') + ) AS "monotonic date_trunc units" + FROM unnest(ARRAY['year', 'month', 'day', 'hour', 'minute', 'second']) AS s(unit) +) AS trunc, +LATERAL ( + SELECT index_plan('timezone(''' || tz_name || ''', tstz) FROM src') AS "timezone" +) AS tz; + tz_name | monotonic date_trunc units | timezone +--------------------+----------------------------------------+---------- + Africa/Ouagadougou | year, month, day, hour, minute, second | t + Europe/London | year, month, day, hour | f + Antarctica/Troll | year, month, day | f + Pacific/Guam | year, month | f + America/Goose_Bay | year | f +(5 rows) + +-- +-- Functions that depends on session timezone +-- +PREPARE query AS +SELECT index_plan($$ tstz::date FROM src $$) date, + (SELECT string_agg( unit, ' ') FILTER ( + WHERE index_plan($$ date_trunc('$$ || unit || $$', tstz) FROM src $$) + ) FROM unnest(ARRAY['year', 'month', 'day', 'hour', 'minute', 'second']) AS s(unit) +) as "monotonic date_turnc units"; +-- No DST at all +SET timezone = 'Africa/Ouagadougou'; +EXECUTE query; + date | monotonic date_turnc units +------+----------------------------------- + t | year month day hour minute second +(1 row) + +-- The common case: one hour DST +SET timezone = 'Europe/London'; +EXECUTE query; + date | monotonic date_turnc units +------+---------------------------- + t | year month day hour +(1 row) + +-- DST might affect the hour +SET timezone = 'Antarctica/Troll'; +EXECUTE query; + date | monotonic date_turnc units +------+---------------------------- + t | year month day +(1 row) + +-- A weird case: used to go back a day +SET timezone = 'Pacific/Guam'; +EXECUTE query; + date | monotonic date_turnc units +------+---------------------------- + f | year month +(1 row) + +-- Even weirder, went back a month +SET timezone = 'America/Goose_Bay'; +EXECUTE query; + date | monotonic date_turnc units +------+---------------------------- + f | year +(1 row) + DROP SCHEMA slope CASCADE; -NOTICE: drop cascades to 14 other objects +NOTICE: drop cascades to 15 other objects DETAIL: drop cascades to table t drop cascades to table u drop cascades to operator family test_int4_ops for access method btree @@ -860,3 +918,4 @@ drop cascades to type non42 drop cascades to type fp_real drop cascades to table numeric_corners drop cascades to table inc +drop cascades to function index_plan(text) diff --git a/src/test/regress/expected/slope_catalog.out b/src/test/regress/expected/slope_catalog.out index 670f711466e..555a2419523 100644 --- a/src/test/regress/expected/slope_catalog.out +++ b/src/test/regress/expected/slope_catalog.out @@ -133,8 +133,8 @@ JOIN pg_proc sp ON sp.oid = p.prosupport WHERE sp.proname LIKE '%slope%' AND NOT EXISTS (SELECT 1 FROM pg_operator o WHERE o.oprcode = p.oid) ORDER BY sp.proname, p.proname, arguments; - oid | function | arguments | returns | prosupport -------+--------------+-----------------------------------+-----------------------------+------------------------- + oid | function | arguments | returns | prosupport +------+--------------+-----------------------------------+-----------------------------+--------------------------------- 2025 | timestamp | date, time without time zone | timestamp without time zone | addition_slope_support 2466 | acosh | double precision | double precision | arg0_asc_slope_support 1600 | asin | double precision | double precision | arg0_asc_slope_support @@ -184,7 +184,12 @@ ORDER BY sp.proname, p.proname, arguments; 2732 | acosd | double precision | double precision | arg0_desc_slope_support 6220 | erfc | double precision | double precision | arg0_desc_slope_support 2020 | date_trunc | text, timestamp without time zone | timestamp without time zone | arg1_asc_slope_support -(49 rows) + 2069 | timezone | text, timestamp without time zone | timestamp with time zone | arg1_asc_slope_support + 1178 | date | timestamp with time zone | date | timestamptz_date_slope_support + 6334 | timezone | timestamp with time zone | timestamp without time zone | timestamptz_at_local_slope_support + 1217 | date_trunc | text, timestamp with time zone | timestamp with time zone | timestamptz_trunc_slope_support + 1159 | timezone | text, timestamp with time zone | timestamp without time zone | timestamptz_zone_slope_support +(54 rows) -- Operators whose name has slope support for some types but not others SELECT @@ -280,9 +285,7 @@ WHERE (u.prosupport = 0 OR NOT EXISTS ( ORDER BY u.proname, arguments; oid | function | arguments | returns ------+--------------+--------------------------------------+----------------------------- - 1178 | date | timestamp with time zone | date 1218 | date_trunc | text, interval | interval - 1217 | date_trunc | text, timestamp with time zone | timestamp with time zone 1284 | date_trunc | text, timestamp with time zone, text | timestamp with time zone 1736 | log | numeric, numeric | numeric 2027 | timestamp | timestamp with time zone | timestamp without time zone @@ -292,5 +295,5 @@ ORDER BY u.proname, arguments; 1778 | to_timestamp | text, text | timestamp with time zone 753 | trunc | macaddr | macaddr 4112 | trunc | macaddr8 | macaddr8 -(12 rows) +(10 rows) diff --git a/src/test/regress/sql/slope.sql b/src/test/regress/sql/slope.sql index 1defff4d955..b99f4428dc3 100644 --- a/src/test/regress/sql/slope.sql +++ b/src/test/regress/sql/slope.sql @@ -178,10 +178,6 @@ select ts::date, count(*) from src group by 1; explain (costs off, verbose) select date_trunc('day', ts), count(*) from src group by 1; --- date_trunc on timestamptz should not use index -explain (costs off, verbose) -select date_trunc('day', tstz), count(*) from src group by 1; - -- -- Test arithmetic operations @@ -203,7 +199,6 @@ select v_int4 * 2, count(*) from src group by 1; explain (costs off, verbose) select v_int4 / 2, count(*) from src group by 1; - -- -- Test decreasing functions -- These queries can't use the index order because group pathkeys @@ -248,13 +243,6 @@ select -v_int4 from src order by 1 desc; explain (costs off, verbose) select -v_int4 from src order by 1; --- --- Group and order --- - -explain (costs off, verbose) -select tstz::date, count(*) from src group by 1 order by 1; - -- -- Test nested monotonic function -- @@ -536,4 +524,74 @@ from src; EXPLAIN (COSTS OFF) SELECT v_int4::oid FROM src ORDER BY 1; + + +-- +-- Now that some plans were shown, and we see that in many cases what +-- we care is whether a plan has a index scan or not. This function will +-- check that for us and just return +-- +CREATE OR REPLACE FUNCTION index_plan (query text) RETURNS BOOLEAN LANGUAGE plpgsql AS $$ +DECLARE + plan json; +BEGIN + EXECUTE 'EXPLAIN (COSTS OFF, FORMAT JSON) SELECT ' || query || ' ORDER BY 1' into plan; + RETURN plan->0->'Plan'->>'Node Type' LIKE 'Index%Scan'; +END +$$; + + +-- +-- Functions that take a timezone argument +-- +EXECUTE query; +SELECT * +FROM unnest(ARRAY[ + 'Africa/Ouagadougou', 'Europe/London', 'Antarctica/Troll', 'Pacific/Guam', 'America/Goose_Bay' +]) AS timezones(tz_name), +LATERAL ( + SELECT + string_agg(unit, ', ') FILTER ( + WHERE index_plan('date_trunc(''' || unit || ''', tstz, ''' || tz_name || ''') FROM src') + ) AS "monotonic date_trunc units" + FROM unnest(ARRAY['year', 'month', 'day', 'hour', 'minute', 'second']) AS s(unit) +) AS trunc, +LATERAL ( + SELECT index_plan('timezone(''' || tz_name || ''', tstz) FROM src') AS "timezone" +) AS tz; + + +-- +-- Functions that depends on session timezone +-- +PREPARE query AS +SELECT index_plan($$ tstz::date FROM src $$) date, + (SELECT string_agg( unit, ' ') FILTER ( + WHERE index_plan($$ date_trunc('$$ || unit || $$', tstz) FROM src $$) + ) FROM unnest(ARRAY['year', 'month', 'day', 'hour', 'minute', 'second']) AS s(unit) +) as "monotonic date_turnc units"; + +-- No DST at all +SET timezone = 'Africa/Ouagadougou'; +EXECUTE query; + +-- The common case: one hour DST +SET timezone = 'Europe/London'; +EXECUTE query; + +-- DST might affect the hour +SET timezone = 'Antarctica/Troll'; +EXECUTE query; + +-- A weird case: used to go back a day +SET timezone = 'Pacific/Guam'; +EXECUTE query; + +-- Even weirder, went back a month +SET timezone = 'America/Goose_Bay'; +EXECUTE query; + + + + DROP SCHEMA slope CASCADE; \ No newline at end of file diff --git a/src/timezone/localtime.c b/src/timezone/localtime.c index a3eb3bacb9b..98bbc20aac2 100644 --- a/src/timezone/localtime.c +++ b/src/timezone/localtime.c @@ -1769,6 +1769,17 @@ pg_next_dst_boundary(const pg_time_t *timep, return 1; } +/* + * Determine whether timestamps truncated to the specified + * unit is monotonic. The results are precomputed when loading + * a timezone + */ +bool +pg_timezone_is_monotonic(const pg_tz *tz, TZMonotonicityBits unit) +{ + return tz->state.monotonicity & BITWISE_HI_AT(unit); +} + /* * Identify a timezone abbreviation's meaning in the given zone * diff --git a/src/timezone/pgtz.c b/src/timezone/pgtz.c index 9561d94a67d..001c018ba0e 100644 --- a/src/timezone/pgtz.c +++ b/src/timezone/pgtz.c @@ -216,6 +216,98 @@ init_timezone_hashtable(void) return true; } +/* + * Check every time transition that moves time backwards + * up to which part of the timestamp the effects are observable + * on a timestamp truncation fashion. This information will be stored + * in the timezone state to assist decisions related to monotonicity + * analysis. + * 2026-07-02 01:59:59.999999 to + * 2026-07-02 01:00:00.000000 + * ==== == == == << xx xxxxxx + * + * would not violate monotonicity for year, month, day, and hour + * but would break for minutes and seconds. + */ +static void +inspect_monotonicity(struct pg_tz *tz) +{ + struct state *sp; + struct pg_tm tm_before; + struct pg_tm *p; + pg_time_t t_before; + pg_time_t t_after; + int result; + + sp = &tz->state; + + /* conservative initialization */ + sp->monotonicity = 0; + if (sp == NULL || sp->typecnt <= 0) + return; + /* Fixed-offset zone: no transitions => fully monotonic */ + if (sp->timecnt == 0) + { + sp->monotonicity = BITWISE_HI_RIGHT(TZ_MONOTONIC_NUM_BITS); + return; + } + result = BITWISE_HI_RIGHT(TZ_MONOTONIC_NUM_BITS); + for (int i = 0; i < sp->timecnt; i++) + { + int old_type; + int new_type; + + t_after = sp->ats[i]; + t_before = t_after - 1; + + /* + * Only fall-back transitions can break truncation monotonicity. + * Skip spring-forward and other offset-increase transitions. + */ + old_type = (i == 0) ? 0 : sp->types[i - 1]; + new_type = sp->types[i]; + if (sp->ttis[new_type].tt_utoff >= sp->ttis[old_type].tt_utoff) + continue; + + /* determine local time parts at the transition */ + p = pg_localtime(&t_before, tz); + if (p == NULL) + { + elog(NOTICE, "Failed to get local time on timezone %s", tz->TZname); + return; + } + tm_before = *p; + p = pg_localtime(&t_after, tz); + if (p == NULL) + { + elog(NOTICE, "Failed to get local time on timezone %s", tz->TZname); + return; + } + #define mark(field, unit) \ + do { \ + if (tm_before.tm_##field > p->tm_##field) \ + { \ + if (TZ_MONOTONIC_##unit == 0) \ + result = 0; \ + else \ + result &= BITWISE_HI_RIGHT(TZ_MONOTONIC_##unit - 1); \ + } \ + else if (tm_before.tm_##field < p->tm_##field) \ + goto next_transition; \ + } while (0) + mark(year, YEAR); + mark(mon, MONTH); + mark(mday, DAY); + mark(hour, HOUR); + mark(min, MINUTE); + mark(sec, SECOND); + #undef mark +next_transition: + ; + } + sp->monotonicity = result; +} + /* * Load a timezone from file or from cache. * Does not verify that the timezone is acceptable! @@ -276,11 +368,11 @@ pg_tzset(const char *tzname) uppername, HASH_ENTER, NULL); - + /* hash_search already copied uppername into the hash key */ strcpy(tzp->tz.TZname, canonname); memcpy(&tzp->tz.state, &tzstate, sizeof(tzstate)); - + inspect_monotonicity(&tzp->tz); return &tzp->tz; } diff --git a/src/timezone/pgtz.h b/src/timezone/pgtz.h index d4ae94de74a..c721f846a9f 100644 --- a/src/timezone/pgtz.h +++ b/src/timezone/pgtz.h @@ -25,6 +25,7 @@ */ #define TZ_RUNTIME_LEAPS 1 + /* * Limit to time zone abbreviation length in proleptic TZ strings. * This is distinct from TZ_MAX_CHARS, which limits TZif file contents. @@ -90,6 +91,7 @@ struct state int charcnt; bool goback; bool goahead; + int monotonicity; pg_time_t ats[TZ_MAX_TIMES]; unsigned char types[TZ_MAX_TIMES]; struct ttinfo ttis[TZ_MAX_TYPES]; -- 2.53.0