doc/src/sgml/func.sgml | 38 +++++++++-
src/backend/utils/adt/timestamp.c | 121 ++++++++++++++++++++++++++++++++
src/include/catalog/pg_proc.dat | 8 +++
src/test/regress/expected/timestamp.out | 92 ++++++++++++++++++++++++
src/test/regress/sql/timestamp.sql | 51 ++++++++++++++
5 files changed, 309 insertions(+), 1 deletion(-)
diff --git a/doc/src/sgml/func.sgml b/doc/src/sgml/func.sgml
index 323366feb6..3bc3464cd0 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -6960,6 +6960,15 @@ SELECT regexp_match('abc01234xyz', '(?:(.*?)(\d+)(.*)){1,1}');
2 days 03:00:00
+
+ date_trunc_interval(interval, timestamp , timestamp)
+ timestamp
+ Truncate to specified precision; see
+
+ date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40')
+ 2001-02-16 20:30:00
+
+
@@ -7829,7 +7838,7 @@ SELECT date_part('hour', INTERVAL '4 hours 3 minutes');
- date_trunc
+ date_trunc, date_trunc_interval
date_trunc
@@ -7913,6 +7922,33 @@ SELECT date_trunc('hour', INTERVAL '3 days 02:47:33');
Result: 3 days 02:00:00
+
+
+ The function date_trunc_interval is
+ similar to the date_trunc, except that it
+ truncates to an arbitrary interval.
+
+
+
+ Example:
+
+SELECT date_trunc_interval('5 minutes', TIMESTAMP '2001-02-16 20:38:40');
+Result: 2001-02-16 20:35:00
+
+
+
+
+ The boundaries of the interval to truncate on can be controlled by setting the optional origin parameter. If not specfied, the default origin is January 1st, 2001.
+
+
+
+ Example:
+
+SELECT date_trunc_interval('5 years'::interval, TIMESTAMP '2020-02-01', TIMESTAMP '2012-01-01');
+Result: 2017-01-01 00:00:00
+
+
+
diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index 0b6c9d5ea8..1f72bfca1a 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -3804,6 +3804,127 @@ timestamptz_age(PG_FUNCTION_ARGS)
*---------------------------------------------------------*/
+static Timestamp
+timestamp_trunc_interval_internal(Interval *stride,
+ Timestamp timestamp,
+ Timestamp origin)
+{
+ Timestamp result,
+ tm_diff;
+ fsec_t ofsec,
+ tfsec;
+ int origin_months,
+ tm_months,
+ month_diff;
+
+ struct pg_tm ot;
+ struct pg_tm tt;
+
+ /*
+ * For strides measured in days or smaller units, do one simple calculation
+ * on the time in microseconds.
+ */
+ if (stride->month == 0)
+ {
+ tm_diff = timestamp - origin;
+ tm_diff -= tm_diff % (stride->day * USECS_PER_DAY + stride->time);
+ return origin + tm_diff;
+ }
+
+ /*
+ * For strides measured in years and/or months, convert origin and input
+ * timestamps to months.
+ */
+ if (stride->day != 0 || stride->time != 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("cannot mix year or month interval units with day units or smaller")));
+
+ if (timestamp2tm(timestamp, NULL, &tt, &tfsec, NULL, NULL) != 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+ errmsg("timestamp out of range")));
+
+ if (timestamp2tm(origin, NULL, &ot, &ofsec, NULL, NULL) != 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+ errmsg("timestamp out of range")));
+
+ origin_months = ot.tm_year * 12 + ot.tm_mon - 1;
+ tm_months = tt.tm_year * 12 + tt.tm_mon - 1;
+
+ /* do the truncation */
+ month_diff = tm_months - origin_months;
+ month_diff -= month_diff % stride->month;
+ tm_months = origin_months + month_diff;
+
+ /* make sure truncation happens in the right direction */
+ if (tt.tm_year < 0)
+ tm_months -= stride->month;
+
+ tt.tm_year = tm_months / 12;
+ tt.tm_mon = (tm_months % 12) + 1;
+
+ /* justify all smaller timestamp units */
+ tt.tm_mday = 1;
+ tt.tm_hour = 0;
+ tt.tm_min = 0;
+ tt.tm_sec = 0;
+ tfsec = 0;
+
+ if (tm2timestamp(&tt, tfsec, NULL, &result) != 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+ errmsg("timestamp out of range")));
+
+ return result;
+}
+
+/* timestamp_trunc_interval()
+ * Truncate timestamp to specified interval.
+ */
+Datum
+timestamp_trunc_interval(PG_FUNCTION_ARGS)
+{
+ Interval *stride = PG_GETARG_INTERVAL_P(0);
+ Timestamp timestamp = PG_GETARG_TIMESTAMP(1);
+ Timestamp result;
+
+ if (TIMESTAMP_NOT_FINITE(timestamp))
+ PG_RETURN_TIMESTAMP(timestamp);
+
+ /*
+ * The default origin is 2001-01-01, which matches date_trunc as far as
+ * aligning on ISO weeks. This numeric value should match the internal
+ * value of SELECT make_timestamp(2001, 1, 1, 0, 0, 0);
+ */
+ result = timestamp_trunc_interval_internal(stride, timestamp,
+ 31622400000000);
+
+ PG_RETURN_TIMESTAMP(result);
+}
+
+Datum
+timestamp_trunc_interval_origin(PG_FUNCTION_ARGS)
+{
+ Interval *stride = PG_GETARG_INTERVAL_P(0);
+ Timestamp timestamp = PG_GETARG_TIMESTAMP(1);
+ Timestamp origin = PG_GETARG_TIMESTAMP(2);
+ Timestamp result;
+
+ if (TIMESTAMP_NOT_FINITE(timestamp))
+ PG_RETURN_TIMESTAMP(timestamp);
+
+ if (TIMESTAMP_NOT_FINITE(origin))
+ ereport(ERROR,
+ (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+ errmsg("timestamp out of range")));
+
+ result = timestamp_trunc_interval_internal(stride, timestamp, origin);
+
+ PG_RETURN_TIMESTAMP(result);
+}
+
/* timestamp_trunc()
* Truncate timestamp to specified units.
*/
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 07a86c7b7b..6216e64862 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -5663,6 +5663,14 @@
{ oid => '2020', descr => 'truncate timestamp to specified units',
proname => 'date_trunc', prorettype => 'timestamp',
proargtypes => 'text timestamp', prosrc => 'timestamp_trunc' },
+{ oid => '8989', descr => 'truncate timestamp to specified interval',
+ proname => 'date_trunc_interval', prorettype => 'timestamp',
+ proargtypes => 'interval timestamp', prosrc => 'timestamp_trunc_interval' },
+{ oid => '8990',
+ descr => 'truncate timestamp to specified interval and origin',
+ proname => 'date_trunc_interval', prorettype => 'timestamp',
+ proargtypes => 'interval timestamp timestamp',
+ prosrc => 'timestamp_trunc_interval_origin' },
{ oid => '2021', descr => 'extract field from timestamp',
proname => 'date_part', prorettype => 'float8',
proargtypes => 'text timestamp', prosrc => 'timestamp_part' },
diff --git a/src/test/regress/expected/timestamp.out b/src/test/regress/expected/timestamp.out
index 5f97505a30..d47901bb0c 100644
--- a/src/test/regress/expected/timestamp.out
+++ b/src/test/regress/expected/timestamp.out
@@ -545,6 +545,98 @@ SELECT '' AS date_trunc_week, date_trunc( 'week', timestamp '2004-02-29 15:44:17
| Mon Feb 23 00:00:00 2004
(1 row)
+-- verify date_trunc_interval behaves the same as date_trunc (excluding decade)
+SELECT
+ str,
+ interval,
+ date_trunc(str, ts) = date_trunc_interval(interval::interval, ts) AS equal
+FROM (
+ VALUES
+ ('millennium', '1000 y'),
+ ('century', '100 y'),
+ ('year', '1 y'),
+ ('quarter', '3 mon'),
+ ('month', '1 mon'),
+ ('week', '7 d'),
+ ('day', '1 d'),
+ ('hour', '1 h'),
+ ('minute', '1 m'),
+ ('second', '1 s'),
+ ('millisecond', '1 ms'),
+ ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamp '2004-02-29 15:44:17.71393') ts (ts);
+ str | interval | equal
+-------------+----------+-------
+ millennium | 1000 y | t
+ century | 100 y | t
+ year | 1 y | t
+ quarter | 3 mon | t
+ month | 1 mon | t
+ week | 7 d | t
+ day | 1 d | t
+ hour | 1 h | t
+ minute | 1 m | t
+ second | 1 s | t
+ millisecond | 1 ms | t
+ microsecond | 1 us | t
+(12 rows)
+
+-- truncate timestamps on arbitrary intervals
+SELECT
+ interval,
+ date_trunc_interval(interval::interval, ts)
+FROM (
+ VALUES
+ ('50 years'),
+ ('1.5 years'),
+ ('18 months'),
+ ('6 months'),
+ ('15 days'),
+ ('2 hours'),
+ ('15 minutes'),
+ ('10 seconds'),
+ ('100 milliseconds'),
+ ('250 microseconds')
+) intervals (interval),
+(SELECT TIMESTAMP '2020-02-11 15:44:17.71393') ts (ts);
+ interval | date_trunc_interval
+------------------+--------------------------------
+ 50 years | Mon Jan 01 00:00:00 2001
+ 1.5 years | Tue Jan 01 00:00:00 2019
+ 18 months | Tue Jan 01 00:00:00 2019
+ 6 months | Wed Jan 01 00:00:00 2020
+ 15 days | Thu Feb 06 00:00:00 2020
+ 2 hours | Tue Feb 11 14:00:00 2020
+ 15 minutes | Tue Feb 11 15:30:00 2020
+ 10 seconds | Tue Feb 11 15:44:10 2020
+ 100 milliseconds | Tue Feb 11 15:44:17.7 2020
+ 250 microseconds | Tue Feb 11 15:44:17.71375 2020
+(10 rows)
+
+-- truncate BC timestamps on intervals
+SELECT date_trunc_interval('100 year'::interval, timestamp '0055-06-1 01:01:01.0 BC');
+ date_trunc_interval
+-----------------------------
+ Tue Jan 01 00:00:00 0100 BC
+(1 row)
+
+-- shift bins using the origin parameter:
+SELECT date_trunc_interval('5 min'::interval, timestamp '2020-02-1 01:01:01', timestamp '2020-02-01 00:02:30');
+ date_trunc_interval
+--------------------------
+ Sat Feb 01 00:57:30 2020
+(1 row)
+
+SELECT date_trunc_interval('5 years'::interval, timestamp '2020-02-1 01:01:01', timestamp '2012-01-01');
+ date_trunc_interval
+--------------------------
+ Sun Jan 01 00:00:00 2017
+(1 row)
+
+-- not defined
+SELECT date_trunc_interval('1 month 1 day', timestamp '2001-02-16 20:38:40.123456');
+ERROR: cannot mix year or month interval units with day units or smaller
-- Test casting within a BETWEEN qualifier
SELECT '' AS "54", d1 - timestamp without time zone '1997-01-02' AS diff
FROM TIMESTAMP_TBL
diff --git a/src/test/regress/sql/timestamp.sql b/src/test/regress/sql/timestamp.sql
index 7b58c3cfa5..55ce22ecde 100644
--- a/src/test/regress/sql/timestamp.sql
+++ b/src/test/regress/sql/timestamp.sql
@@ -166,6 +166,57 @@ SELECT '' AS "54", d1 - timestamp without time zone '1997-01-02' AS diff
SELECT '' AS date_trunc_week, date_trunc( 'week', timestamp '2004-02-29 15:44:17.71393' ) AS week_trunc;
+-- verify date_trunc_interval behaves the same as date_trunc (excluding decade)
+SELECT
+ str,
+ interval,
+ date_trunc(str, ts) = date_trunc_interval(interval::interval, ts) AS equal
+FROM (
+ VALUES
+ ('millennium', '1000 y'),
+ ('century', '100 y'),
+ ('year', '1 y'),
+ ('quarter', '3 mon'),
+ ('month', '1 mon'),
+ ('week', '7 d'),
+ ('day', '1 d'),
+ ('hour', '1 h'),
+ ('minute', '1 m'),
+ ('second', '1 s'),
+ ('millisecond', '1 ms'),
+ ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamp '2004-02-29 15:44:17.71393') ts (ts);
+
+-- truncate timestamps on arbitrary intervals
+SELECT
+ interval,
+ date_trunc_interval(interval::interval, ts)
+FROM (
+ VALUES
+ ('50 years'),
+ ('1.5 years'),
+ ('18 months'),
+ ('6 months'),
+ ('15 days'),
+ ('2 hours'),
+ ('15 minutes'),
+ ('10 seconds'),
+ ('100 milliseconds'),
+ ('250 microseconds')
+) intervals (interval),
+(SELECT TIMESTAMP '2020-02-11 15:44:17.71393') ts (ts);
+
+-- truncate BC timestamps on intervals
+SELECT date_trunc_interval('100 year'::interval, timestamp '0055-06-1 01:01:01.0 BC');
+
+-- shift bins using the origin parameter:
+SELECT date_trunc_interval('5 min'::interval, timestamp '2020-02-1 01:01:01', timestamp '2020-02-01 00:02:30');
+SELECT date_trunc_interval('5 years'::interval, timestamp '2020-02-1 01:01:01', timestamp '2012-01-01');
+
+-- not defined
+SELECT date_trunc_interval('1 month 1 day', timestamp '2001-02-16 20:38:40.123456');
+
-- Test casting within a BETWEEN qualifier
SELECT '' AS "54", d1 - timestamp without time zone '1997-01-02' AS diff
FROM TIMESTAMP_TBL