truncating timestamps on arbitrary intervals

Started by John Nayloralmost 6 years ago47 messages
#1John Naylor
john.naylor@2ndquadrant.com
1 attachment(s)

Hi,

When analyzing time-series data, it's useful to be able to bin
timestamps into equally spaced ranges. date_trunc() is only able to
bin on a specified whole unit. In the attached patch for the March
commitfest, I propose a new function date_trunc_interval(), which can
truncate to arbitrary intervals, e.g.:

select date_trunc_interval('15 minutes', timestamp '2020-02-16
20:48:40'); date_trunc_interval
---------------------
2020-02-16 20:45:00
(1 row)

With this addition, it might be possible to turn the existing
date_trunc() functions into wrappers. I haven't done that here because
it didn't seem practical at this point. For one, the existing
functions have special treatment for weeks, centuries, and millennia.

Note: I've only written the implementation for the type timestamp
without timezone. Adding timezone support would be pretty simple, but
I wanted to get feedback on the basic idea first before making it
complete. I've also written tests and very basic documentation.

--
John Naylor https://www.2ndQuadrant.com/
PostgreSQL Development, 24x7 Support, Remote DBA, Training & Services

Attachments:

v1-datetrunc_interval.patchapplication/octet-stream; name=v1-datetrunc_interval.patchDownload
 doc/src/sgml/func.sgml                  |  26 ++++++-
 src/backend/utils/adt/timestamp.c       | 123 ++++++++++++++++++++++++++++++++
 src/include/catalog/pg_proc.dat         |   3 +
 src/test/regress/expected/timestamp.out |  27 +++++++
 src/test/regress/sql/timestamp.sql      |  16 +++++
 5 files changed, 194 insertions(+), 1 deletion(-)

diff --git a/doc/src/sgml/func.sgml b/doc/src/sgml/func.sgml
index ceda48e0fc..3863c222a2 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -6949,6 +6949,15 @@ SELECT regexp_match('abc01234xyz', '(?:(.*?)(\d+)(.*)){1,1}');
         <entry><literal>2 days 03:00:00</literal></entry>
        </row>
 
+       <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp</type>)</function></literal></entry>
+        <entry><type>timestamp</type></entry>
+        <entry>Truncate to specified precision; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40')</literal></entry>
+        <entry><literal>2001-02-16 20:30:00</literal></entry>
+       </row>
+
        <row>
         <entry>
          <indexterm>
@@ -7818,7 +7827,7 @@ SELECT date_part('hour', INTERVAL '4 hours 3 minutes');
   </sect2>
 
   <sect2 id="functions-datetime-trunc">
-   <title><function>date_trunc</function></title>
+   <title><function>date_trunc</function>, <function>date_trunc_interval</function></title>
 
    <indexterm>
     <primary>date_trunc</primary>
@@ -7902,6 +7911,21 @@ SELECT date_trunc('hour', INTERVAL '3 days 02:47:33');
 <lineannotation>Result: </lineannotation><computeroutput>3 days 02:00:00</computeroutput>
 </screen>
    </para>
+
+   <para>
+    The function <function>date_trunc_interval</function> is 
+    similar to the <function>date_trunc</function>, except that it
+    truncates to an arbitrary interval.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 minutes', TIMESTAMP '2001-02-16 20:38:40');
+<lineannotation>Result: </lineannotation><computeroutput>2001-02-16 20:35:00</computeroutput>
+</screen>
+   </para>
+
   </sect2>
 
   <sect2 id="functions-datetime-zoneconvert">
diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index 0b6c9d5ea8..f2dd4f1995 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -3804,6 +3804,129 @@ timestamptz_age(PG_FUNCTION_ARGS)
  *---------------------------------------------------------*/
 
 
+/* timestamp_trunc_interval()
+ * Truncate timestamp to specified interval.
+ */
+Datum
+timestamp_trunc_interval(PG_FUNCTION_ARGS)
+{
+	Interval   *interval = PG_GETARG_INTERVAL_P(0);
+	Timestamp	timestamp = PG_GETARG_TIMESTAMP(1);
+	Timestamp	result;
+	fsec_t		ifsec,
+				tfsec;
+	int			unit;
+
+	struct pg_tm it;
+	struct pg_tm tt;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMP(timestamp);
+
+	if (interval2tm(*interval, &it, &ifsec) != 0)
+		elog(ERROR, "could not convert interval to tm");
+
+	if (timestamp2tm(timestamp, NULL, &tt, &tfsec, NULL, NULL) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	if (it.tm_year != 0)
+	{
+		tt.tm_year = it.tm_year * (tt.tm_year / it.tm_year);
+		unit = DTK_YEAR;
+	}
+	else if (it.tm_mon != 0)
+	{
+		tt.tm_mon = it.tm_mon * (tt.tm_mon / it.tm_mon);
+		unit = DTK_MONTH;
+	}
+	else if (it.tm_mday != 0)
+	{
+		tt.tm_mday = it.tm_mday * (tt.tm_mday / it.tm_mday);
+		unit = DTK_DAY;
+	}
+	else if (it.tm_hour != 0)
+	{
+		tt.tm_hour = it.tm_hour * (tt.tm_hour / it.tm_hour);
+		unit = DTK_HOUR;
+	}
+	else if (it.tm_min != 0)
+	{
+		tt.tm_min = it.tm_min * (tt.tm_min / it.tm_min);
+		unit = DTK_MINUTE;
+	}
+	else if (it.tm_sec != 0)
+	{
+		tt.tm_sec = it.tm_sec * (tt.tm_sec / it.tm_sec);
+		unit = DTK_SECOND;
+	}
+	else if (ifsec != 0)
+	{
+		tfsec = ifsec * (tfsec / ifsec);
+
+		if (ifsec >= 1000)
+			unit = DTK_MILLISEC;
+		else
+			unit = DTK_MICROSEC;
+	}
+	else
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("interval not initialized")));
+
+	/*
+	 * Justify all lower timestamp units and throw an error if any
+	 * of the lower interval units are non-zero.
+	 */
+	switch (unit)
+	{
+			case DTK_YEAR:
+				tt.tm_mon = 1;
+				if (it.tm_mon != 0)
+					goto error;
+			case DTK_MONTH:
+				tt.tm_mday = 1;
+				if (it.tm_mday != 0)
+					goto error;
+			case DTK_DAY:
+				tt.tm_hour = 0;
+				if (it.tm_hour != 0)
+					goto error;
+			case DTK_HOUR:
+				tt.tm_min = 0;
+				if (it.tm_min != 0)
+					goto error;
+			case DTK_MINUTE:
+				tt.tm_sec = 0;
+				if (it.tm_sec != 0)
+					goto error;
+			case DTK_SECOND:
+				tfsec = 0;
+			case DTK_MILLISEC:
+			case DTK_MICROSEC:
+				break;
+			default:
+				ereport(ERROR,
+						(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+						 errmsg("interval unit not supported")));
+
+	}
+
+	if (tm2timestamp(&tt, tfsec, NULL, &result) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	PG_RETURN_TIMESTAMP(result);
+
+error:
+	ereport(ERROR,
+			// WIP is there a better errcode?
+			(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+			 errmsg("only one interval unit allowed for truncation")));
+}
+
 /* timestamp_trunc()
  * Truncate timestamp to specified units.
  */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 0345118cdb..ddb1b84b52 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -5660,6 +5660,9 @@
 { 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 => '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..b887468a73 100644
--- a/src/test/regress/expected/timestamp.out
+++ b/src/test/regress/expected/timestamp.out
@@ -545,6 +545,33 @@ SELECT '' AS date_trunc_week, date_trunc( 'week', timestamp '2004-02-29 15:44:17
                  | Mon Feb 23 00:00:00 2004
 (1 row)
 
+SELECT
+  interval,
+  date_trunc_interval(interval::interval, ts)
+FROM (
+  VALUES
+  ('5 years'),
+  ('1 month'),
+  ('7 days'),
+  ('2 hours'),
+  ('15 minutes'),
+  ('10 seconds'),
+  ('100 millisecond'),
+  ('250 microseconds')
+) intervals (interval),
+(SELECT TIMESTAMP '2004-02-29 15:44:17.71393') ts (ts);
+     interval     |      date_trunc_interval       
+------------------+--------------------------------
+ 5 years          | Sat Jan 01 00:00:00 2000
+ 1 month          | Sun Feb 01 00:00:00 2004
+ 7 days           | Sat Feb 28 00:00:00 2004
+ 2 hours          | Sun Feb 29 14:00:00 2004
+ 15 minutes       | Sun Feb 29 15:30:00 2004
+ 10 seconds       | Sun Feb 29 15:44:10 2004
+ 100 millisecond  | Sun Feb 29 15:44:17.7 2004
+ 250 microseconds | Sun Feb 29 15:44:17.71375 2004
+(8 rows)
+
 -- 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..ffb22bc3e0 100644
--- a/src/test/regress/sql/timestamp.sql
+++ b/src/test/regress/sql/timestamp.sql
@@ -166,6 +166,22 @@ 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;
 
+SELECT
+  interval,
+  date_trunc_interval(interval::interval, ts)
+FROM (
+  VALUES
+  ('5 years'),
+  ('1 month'),
+  ('7 days'),
+  ('2 hours'),
+  ('15 minutes'),
+  ('10 seconds'),
+  ('100 millisecond'),
+  ('250 microseconds')
+) intervals (interval),
+(SELECT TIMESTAMP '2004-02-29 15:44:17.71393') ts (ts);
+
 -- Test casting within a BETWEEN qualifier
 SELECT '' AS "54", d1 - timestamp without time zone '1997-01-02' AS diff
   FROM TIMESTAMP_TBL
#2David Fetter
david@fetter.org
In reply to: John Naylor (#1)
1 attachment(s)
Re: truncating timestamps on arbitrary intervals

On Wed, Feb 26, 2020 at 10:50:19AM +0800, John Naylor wrote:

Hi,

When analyzing time-series data, it's useful to be able to bin
timestamps into equally spaced ranges. date_trunc() is only able to
bin on a specified whole unit.

Thanks for adding this very handy feature!

In the attached patch for the March
commitfest, I propose a new function date_trunc_interval(), which can
truncate to arbitrary intervals, e.g.:

select date_trunc_interval('15 minutes', timestamp '2020-02-16
20:48:40'); date_trunc_interval
---------------------
2020-02-16 20:45:00
(1 row)

I believe the following should error out, but doesn't.

# SELECT date_trunc_interval('1 year 1 ms', TIMESTAMP '2001-02-16 20:38:40');
date_trunc_interval
═════════════════════
2001-01-01 00:00:00
(1 row)

With this addition, it might be possible to turn the existing
date_trunc() functions into wrappers. I haven't done that here because
it didn't seem practical at this point. For one, the existing
functions have special treatment for weeks, centuries, and millennia.

I agree that turning it into a wrapper would be separate work.

Note: I've only written the implementation for the type timestamp
without timezone. Adding timezone support would be pretty simple,
but I wanted to get feedback on the basic idea first before making
it complete. I've also written tests and very basic documentation.

Please find attached an update that I believe fixes the bug I found in
a principled way.

Best,
David.
--
David Fetter <david(at)fetter(dot)org> http://fetter.org/
Phone: +1 415 235 3778

Remember to vote!
Consider donating to Postgres: http://www.postgresql.org/about/donate

Attachments:

v2-0001-Add-date_trunc_interval-interval-timestamp.patchtext/x-diff; charset=iso-8859-1Download
From 5e36c4c888c65e358d2f87d84b64bc14d52f2b39 Mon Sep 17 00:00:00 2001
From: David Fetter <david@fetter.org>
Date: Tue, 25 Feb 2020 23:49:35 -0800
Subject: [PATCH v2] Add date_trunc_interval(interval, timestamp)
To: hackers
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="------------2.24.1"

This is a multi-part message in MIME format.
--------------2.24.1
Content-Type: text/plain; charset=UTF-8; format=fixed
Content-Transfer-Encoding: 8bit


per John Naylor

diff --git a/doc/src/sgml/func.sgml b/doc/src/sgml/func.sgml
index ceda48e0fc..3863c222a2 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -6949,6 +6949,15 @@ SELECT regexp_match('abc01234xyz', '(?:(.*?)(\d+)(.*)){1,1}');
         <entry><literal>2 days 03:00:00</literal></entry>
        </row>
 
+       <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp</type>)</function></literal></entry>
+        <entry><type>timestamp</type></entry>
+        <entry>Truncate to specified precision; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40')</literal></entry>
+        <entry><literal>2001-02-16 20:30:00</literal></entry>
+       </row>
+
        <row>
         <entry>
          <indexterm>
@@ -7818,7 +7827,7 @@ SELECT date_part('hour', INTERVAL '4 hours 3 minutes');
   </sect2>
 
   <sect2 id="functions-datetime-trunc">
-   <title><function>date_trunc</function></title>
+   <title><function>date_trunc</function>, <function>date_trunc_interval</function></title>
 
    <indexterm>
     <primary>date_trunc</primary>
@@ -7902,6 +7911,21 @@ SELECT date_trunc('hour', INTERVAL '3 days 02:47:33');
 <lineannotation>Result: </lineannotation><computeroutput>3 days 02:00:00</computeroutput>
 </screen>
    </para>
+
+   <para>
+    The function <function>date_trunc_interval</function> is 
+    similar to the <function>date_trunc</function>, except that it
+    truncates to an arbitrary interval.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 minutes', TIMESTAMP '2001-02-16 20:38:40');
+<lineannotation>Result: </lineannotation><computeroutput>2001-02-16 20:35:00</computeroutput>
+</screen>
+   </para>
+
   </sect2>
 
   <sect2 id="functions-datetime-zoneconvert">
diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index 0b6c9d5ea8..ed742592af 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -30,6 +30,7 @@
 #include "nodes/nodeFuncs.h"
 #include "nodes/supportnodes.h"
 #include "parser/scansup.h"
+#include "port/pg_bitutils.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/datetime.h"
@@ -3804,6 +3805,144 @@ timestamptz_age(PG_FUNCTION_ARGS)
  *---------------------------------------------------------*/
 
 
+/* timestamp_trunc_interval()
+ * Truncate timestamp to specified interval.
+ */
+Datum
+timestamp_trunc_interval(PG_FUNCTION_ARGS)
+{
+	Interval   *interval = PG_GETARG_INTERVAL_P(0);
+	Timestamp	timestamp = PG_GETARG_TIMESTAMP(1);
+	Timestamp	result;
+	fsec_t		ifsec,
+				tfsec;
+	uint32_t	unit = 0,
+				popcount = 0;
+	enum		TimeUnit {
+					us     = 1 << 0,
+					ms     = 1 << 1,
+					second = 1 << 2,
+					minute = 1 << 3,
+					hour   = 1 << 4,
+					day    = 1 << 5,
+					month  = 1 << 6,
+					year   = 1 << 7
+				};
+
+	struct pg_tm it;
+	struct pg_tm tt;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMP(timestamp);
+
+	if (interval2tm(*interval, &it, &ifsec) != 0)
+		elog(ERROR, "could not convert interval to tm");
+
+	if (timestamp2tm(timestamp, NULL, &tt, &tfsec, NULL, NULL) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	if (it.tm_year != 0)
+	{
+		tt.tm_year = it.tm_year * (tt.tm_year / it.tm_year);
+		unit |= year;
+	}
+	if (it.tm_mon != 0)
+	{
+		tt.tm_mon = it.tm_mon * (tt.tm_mon / it.tm_mon);
+		unit |= month;
+	}
+	if (it.tm_mday != 0)
+	{
+		tt.tm_mday = it.tm_mday * (tt.tm_mday / it.tm_mday);
+		unit |= day;
+	}
+	if (it.tm_hour != 0)
+	{
+		tt.tm_hour = it.tm_hour * (tt.tm_hour / it.tm_hour);
+		unit |= hour;
+	}
+	if (it.tm_min != 0)
+	{
+		tt.tm_min = it.tm_min * (tt.tm_min / it.tm_min);
+		unit |= minute;
+	}
+	if (it.tm_sec != 0)
+	{
+		tt.tm_sec = it.tm_sec * (tt.tm_sec / it.tm_sec);
+		unit |= second;
+	}
+	if (ifsec > 0)
+	{
+		tfsec = ifsec * (tfsec / ifsec);
+
+		if (ifsec >= 1000)
+			unit |= ms;
+		else
+			unit |= us;
+	}
+	if (unit == 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("interval not initialized")));
+
+	popcount = pg_popcount32(unit);
+
+	if ( popcount > 1 )
+		goto error;
+	/*
+	 * Justify all lower timestamp units and throw an error if any
+	 * of the lower interval units are non-zero.
+	 */
+	switch (unit)
+	{
+			case year:
+				tt.tm_mon = 1;
+				if (it.tm_mon != 0)
+					goto error;
+			case month:
+				tt.tm_mday = 1;
+				if (it.tm_mday != 0)
+					goto error;
+			case day:
+				tt.tm_hour = 0;
+				if (it.tm_hour != 0)
+					goto error;
+			case hour:
+				tt.tm_min = 0;
+				if (it.tm_min != 0)
+					goto error;
+			case minute:
+				tt.tm_sec = 0;
+				if (it.tm_sec != 0)
+					goto error;
+			case second:
+				tfsec = 0;
+			case ms:
+			case us:
+				break;
+			default:
+				ereport(ERROR,
+						(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+						 errmsg("interval unit not supported")));
+
+	}
+
+	if (tm2timestamp(&tt, tfsec, NULL, &result) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	PG_RETURN_TIMESTAMP(result);
+
+error:
+	ereport(ERROR,
+			// WIP is there a better errcode?
+			(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+			 errmsg("only one interval unit allowed for truncation")));
+}
+
 /* timestamp_trunc()
  * Truncate timestamp to specified units.
  */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index eb3c1a88d1..0cec6c6799 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -5660,6 +5660,9 @@
 { 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 => '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..174790e872 100644
--- a/src/test/regress/expected/timestamp.out
+++ b/src/test/regress/expected/timestamp.out
@@ -545,6 +545,35 @@ SELECT '' AS date_trunc_week, date_trunc( 'week', timestamp '2004-02-29 15:44:17
                  | Mon Feb 23 00:00:00 2004
 (1 row)
 
+SELECT
+  interval,
+  date_trunc_interval(interval::interval, ts)
+FROM (
+  VALUES
+  ('5 years'),
+  ('1 month'),
+  ('7 days'),
+  ('2 hours'),
+  ('15 minutes'),
+  ('10 seconds'),
+  ('100 millisecond'),
+  ('250 microseconds')
+) intervals (interval),
+(SELECT TIMESTAMP '2004-02-29 15:44:17.71393') ts (ts);
+     interval     |      date_trunc_interval       
+------------------+--------------------------------
+ 5 years          | Sat Jan 01 00:00:00 2000
+ 1 month          | Sun Feb 01 00:00:00 2004
+ 7 days           | Sat Feb 28 00:00:00 2004
+ 2 hours          | Sun Feb 29 14:00:00 2004
+ 15 minutes       | Sun Feb 29 15:30:00 2004
+ 10 seconds       | Sun Feb 29 15:44:10 2004
+ 100 millisecond  | Sun Feb 29 15:44:17.7 2004
+ 250 microseconds | Sun Feb 29 15:44:17.71375 2004
+(8 rows)
+
+SELECT date_trunc_interval('1 year 1 ms'::interval, '2004-02-29 15:44:17.71393');
+ERROR:  only one interval unit allowed for truncation
 -- 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..f46c229f6a 100644
--- a/src/test/regress/sql/timestamp.sql
+++ b/src/test/regress/sql/timestamp.sql
@@ -166,6 +166,24 @@ 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;
 
+SELECT
+  interval,
+  date_trunc_interval(interval::interval, ts)
+FROM (
+  VALUES
+  ('5 years'),
+  ('1 month'),
+  ('7 days'),
+  ('2 hours'),
+  ('15 minutes'),
+  ('10 seconds'),
+  ('100 millisecond'),
+  ('250 microseconds')
+) intervals (interval),
+(SELECT TIMESTAMP '2004-02-29 15:44:17.71393') ts (ts);
+
+SELECT date_trunc_interval('1 year 1 ms'::interval, '2004-02-29 15:44:17.71393');
+
 -- Test casting within a BETWEEN qualifier
 SELECT '' AS "54", d1 - timestamp without time zone '1997-01-02' AS diff
   FROM TIMESTAMP_TBL

--------------2.24.1--


#3John Naylor
john.naylor@2ndquadrant.com
In reply to: David Fetter (#2)
1 attachment(s)
Re: truncating timestamps on arbitrary intervals

On Wed, Feb 26, 2020 at 3:51 PM David Fetter <david@fetter.org> wrote:

I believe the following should error out, but doesn't.

# SELECT date_trunc_interval('1 year 1 ms', TIMESTAMP '2001-02-16 20:38:40');
date_trunc_interval
═════════════════════
2001-01-01 00:00:00
(1 row)

You're quite right. I forgot to add error checking for
second-and-below units. I've added your example to the tests. (I
neglected to mention in my first email that because I chose to convert
the interval to the pg_tm struct (seemed easiest), it's not
straightforward how to allow multiple unit types, and I imagine the
use case is small, so I had it throw an error.)

Please find attached an update that I believe fixes the bug I found in
a principled way.

Thanks for that! I made a couple adjustments and incorporated your fix
into v3: While working on v1, I noticed the DTK_FOO macros already had
an idiom for bitmasking (see utils/datetime.h), so I used that instead
of a bespoke enum. Also, since the bitmask is checked once, I removed
the individual member checks, allowing me to remove all the gotos.

There's another small wrinkle: Since we store microseconds internally,
it's neither convenient nor useful to try to error out for things like
'2 ms 500 us', since that is just as well written as '2500 us', and
stored exactly the same. I'm inclined to just skip the millisecond
check and just use microseconds, but I haven't done that yet.

Also, I noticed this bug in v1:

SELECT date_trunc_interval('17 days', TIMESTAMP '2001-02-16 20:38:40.123456');
date_trunc_interval
---------------------
2001-01-31 00:00:00
(1 row)

This is another consequence of month and day being 1-based. Fixed,
with new tests.

--
John Naylor https://www.2ndQuadrant.com/
PostgreSQL Development, 24x7 Support, Remote DBA, Training & Services

Attachments:

v3-datetrunc_interval.patchapplication/octet-stream; name=v3-datetrunc_interval.patchDownload
 doc/src/sgml/func.sgml                  |  26 ++++++-
 src/backend/utils/adt/timestamp.c       | 126 ++++++++++++++++++++++++++++++++
 src/include/catalog/pg_proc.dat         |   3 +
 src/test/regress/expected/timestamp.out |  35 +++++++++
 src/test/regress/sql/timestamp.sql      |  22 ++++++
 5 files changed, 211 insertions(+), 1 deletion(-)

diff --git a/doc/src/sgml/func.sgml b/doc/src/sgml/func.sgml
index ceda48e0fc..3863c222a2 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -6949,6 +6949,15 @@ SELECT regexp_match('abc01234xyz', '(?:(.*?)(\d+)(.*)){1,1}');
         <entry><literal>2 days 03:00:00</literal></entry>
        </row>
 
+       <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp</type>)</function></literal></entry>
+        <entry><type>timestamp</type></entry>
+        <entry>Truncate to specified precision; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40')</literal></entry>
+        <entry><literal>2001-02-16 20:30:00</literal></entry>
+       </row>
+
        <row>
         <entry>
          <indexterm>
@@ -7818,7 +7827,7 @@ SELECT date_part('hour', INTERVAL '4 hours 3 minutes');
   </sect2>
 
   <sect2 id="functions-datetime-trunc">
-   <title><function>date_trunc</function></title>
+   <title><function>date_trunc</function>, <function>date_trunc_interval</function></title>
 
    <indexterm>
     <primary>date_trunc</primary>
@@ -7902,6 +7911,21 @@ SELECT date_trunc('hour', INTERVAL '3 days 02:47:33');
 <lineannotation>Result: </lineannotation><computeroutput>3 days 02:00:00</computeroutput>
 </screen>
    </para>
+
+   <para>
+    The function <function>date_trunc_interval</function> is 
+    similar to the <function>date_trunc</function>, except that it
+    truncates to an arbitrary interval.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 minutes', TIMESTAMP '2001-02-16 20:38:40');
+<lineannotation>Result: </lineannotation><computeroutput>2001-02-16 20:35:00</computeroutput>
+</screen>
+   </para>
+
   </sect2>
 
   <sect2 id="functions-datetime-zoneconvert">
diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index 0b6c9d5ea8..9613f86355 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -30,6 +30,7 @@
 #include "nodes/nodeFuncs.h"
 #include "nodes/supportnodes.h"
 #include "parser/scansup.h"
+#include "port/pg_bitutils.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/datetime.h"
@@ -3804,6 +3805,131 @@ timestamptz_age(PG_FUNCTION_ARGS)
  *---------------------------------------------------------*/
 
 
+/* timestamp_trunc_interval()
+ * Truncate timestamp to specified interval.
+ */
+Datum
+timestamp_trunc_interval(PG_FUNCTION_ARGS)
+{
+	Interval   *interval = PG_GETARG_INTERVAL_P(0);
+	Timestamp	timestamp = PG_GETARG_TIMESTAMP(1);
+	Timestamp	result;
+	fsec_t		ifsec,
+				tfsec;
+	uint32		unit = 0;
+
+	struct pg_tm it;
+	struct pg_tm tt;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMP(timestamp);
+
+	if (interval2tm(*interval, &it, &ifsec) != 0)
+		elog(ERROR, "could not convert interval to tm");
+
+	if (timestamp2tm(timestamp, NULL, &tt, &tfsec, NULL, NULL) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	if (it.tm_year != 0)
+	{
+		tt.tm_year = it.tm_year * (tt.tm_year / it.tm_year);
+		unit |= DTK_M(DTK_YEAR);
+	}
+	if (it.tm_mon != 0)
+	{
+		if (it.tm_mon > tt.tm_mon)
+			tt.tm_mon = 1;
+		else
+			tt.tm_mon = it.tm_mon * (tt.tm_mon / it.tm_mon);
+
+		unit |= DTK_M(DTK_MONTH);
+	}
+	if (it.tm_mday != 0)
+	{
+		if (it.tm_mday > tt.tm_mday)
+			tt.tm_mday = 1;
+		else
+			tt.tm_mday = it.tm_mday * (tt.tm_mday / it.tm_mday);
+
+		unit |= DTK_M(DTK_DAY);
+	}
+	if (it.tm_hour != 0)
+	{
+		tt.tm_hour = it.tm_hour * (tt.tm_hour / it.tm_hour);
+		unit |= DTK_M(DTK_HOUR);
+	}
+	if (it.tm_min != 0)
+	{
+		tt.tm_min = it.tm_min * (tt.tm_min / it.tm_min);
+		unit |= DTK_M(DTK_MINUTE);
+	}
+	if (it.tm_sec != 0)
+	{
+		tt.tm_sec = it.tm_sec * (tt.tm_sec / it.tm_sec);
+		unit |= DTK_M(DTK_SECOND);
+	}
+	if (ifsec != 0)
+	{
+		tfsec = ifsec * (tfsec / ifsec);
+
+		if (ifsec >= 1000)
+			unit |= DTK_M(DTK_MILLISEC);
+		else
+			unit |= DTK_M(DTK_MICROSEC);
+	}
+
+	if (unit == 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("interval not initialized")));
+	else
+	{
+		int popcount = pg_popcount32(unit);
+
+		if (popcount > 1)
+			ereport(ERROR,
+					// WIP is there a better errcode?
+					(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+					 errmsg("only one interval unit allowed for truncation")));
+	}
+
+	/*
+	 * Justify all lower timestamp units and throw an error if any
+	 * of the lower interval units are non-zero.
+	 */
+	switch (unit)
+	{
+			case DTK_M(DTK_YEAR):
+				tt.tm_mon = 1;
+			case DTK_M(DTK_MONTH):
+				tt.tm_mday = 1;
+			case DTK_M(DTK_DAY):
+				tt.tm_hour = 0;
+			case DTK_M(DTK_HOUR):
+				tt.tm_min = 0;
+			case DTK_M(DTK_MINUTE):
+				tt.tm_sec = 0;
+			case DTK_M(DTK_SECOND):
+				tfsec = 0;
+			case DTK_M(DTK_MILLISEC):
+			case DTK_M(DTK_MICROSEC):
+				break;
+			default:
+				ereport(ERROR,
+						(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+						 errmsg("interval unit not supported")));
+	}
+
+	if (tm2timestamp(&tt, tfsec, NULL, &result) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	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 0345118cdb..ddb1b84b52 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -5660,6 +5660,9 @@
 { 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 => '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..1acb2e78a8 100644
--- a/src/test/regress/expected/timestamp.out
+++ b/src/test/regress/expected/timestamp.out
@@ -545,6 +545,41 @@ SELECT '' AS date_trunc_week, date_trunc( 'week', timestamp '2004-02-29 15:44:17
                  | Mon Feb 23 00:00:00 2004
 (1 row)
 
+-- truncate on intervals
+SELECT
+  interval,
+  date_trunc_interval(interval::interval, ts)
+FROM (
+  VALUES
+  ('50 years'),
+  ('1 month'),
+  ('6 months'),
+  ('7 days'),
+  ('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         | Sat Jan 01 00:00:00 2000
+ 1 month          | Sat Feb 01 00:00:00 2020
+ 6 months         | Wed Jan 01 00:00:00 2020
+ 7 days           | Fri Feb 07 00:00:00 2020
+ 15 days          | Sat Feb 01 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)
+
+-- disallow multiple units (ms + us is an exception)
+SELECT date_trunc_interval('1 year 1 microsecond', TIMESTAMP '2001-02-16 20:38:40.123456');
+ERROR:  only one interval unit allowed for truncation
 -- 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..41485f6aa7 100644
--- a/src/test/regress/sql/timestamp.sql
+++ b/src/test/regress/sql/timestamp.sql
@@ -166,6 +166,28 @@ 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;
 
+-- truncate on intervals
+SELECT
+  interval,
+  date_trunc_interval(interval::interval, ts)
+FROM (
+  VALUES
+  ('50 years'),
+  ('1 month'),
+  ('6 months'),
+  ('7 days'),
+  ('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);
+
+-- disallow multiple units (ms + us is an exception)
+SELECT date_trunc_interval('1 year 1 microsecond', 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
#4Tom Lane
tgl@sss.pgh.pa.us
In reply to: John Naylor (#3)
Re: truncating timestamps on arbitrary intervals

John Naylor <john.naylor@2ndquadrant.com> writes:

[ v3-datetrunc_interval.patch ]

A few thoughts:

* In general, binning involves both an origin and a stride. When
working with plain numbers it's almost always OK to set the origin
to zero, but it's less clear to me whether that's all right for
timestamps. Do we need another optional argument? Even if we
don't, "zero" for tm_year is 1900, which is going to give results
that surprise somebody.

* I'm still not convinced that the code does the right thing for
1-based months or days. Shouldn't you need to subtract 1, then
do the modulus, then add back 1?

* Speaking of modulus, would it be clearer to express the
calculations like
timestamp -= timestamp % interval;
(That's just a question, I'm not sure.)

* Code doesn't look to have thought carefully about what to do with
negative intervals, or BC timestamps.

* The comment
* Justify all lower timestamp units and throw an error if any
* of the lower interval units are non-zero.
doesn't seem to have a lot to do with what the code after it actually
does. Also, you need explicit /* FALLTHRU */-type comments in that
switch, or pickier buildfarm members will complain.

* Seems like you could jam all the unit-related error checking into
that switch's default: case, where it will cost nothing if the
call is valid:

switch (unit)
{
...
default:
if (unit == 0)
// complain about zero interval
else
// complain about interval with multiple components
}

* I'd use ERRCODE_INVALID_PARAMETER_VALUE for any case of disallowed
contents of the interval.

regards, tom lane

#5David Fetter
david@fetter.org
In reply to: John Naylor (#3)
Re: truncating timestamps on arbitrary intervals

On Wed, Feb 26, 2020 at 06:38:57PM +0800, John Naylor wrote:

On Wed, Feb 26, 2020 at 3:51 PM David Fetter <david@fetter.org> wrote:

I believe the following should error out, but doesn't.

# SELECT date_trunc_interval('1 year 1 ms', TIMESTAMP '2001-02-16 20:38:40');
date_trunc_interval
═════════════════════
2001-01-01 00:00:00
(1 row)

You're quite right. I forgot to add error checking for
second-and-below units. I've added your example to the tests. (I
neglected to mention in my first email that because I chose to convert
the interval to the pg_tm struct (seemed easiest), it's not
straightforward how to allow multiple unit types, and I imagine the
use case is small, so I had it throw an error.)

I suspect that this could be sanely expanded to span some sets of
adjacent types in a future patch, e.g. year + month or hour + minute.

Please find attached an update that I believe fixes the bug I found in
a principled way.

Thanks for that! I made a couple adjustments and incorporated your fix
into v3: While working on v1, I noticed the DTK_FOO macros already had
an idiom for bitmasking (see utils/datetime.h),

Oops! Sorry I missed that.

Best,
David.
--
David Fetter <david(at)fetter(dot)org> http://fetter.org/
Phone: +1 415 235 3778

Remember to vote!
Consider donating to Postgres: http://www.postgresql.org/about/donate

#6John Naylor
john.naylor@2ndquadrant.com
In reply to: Tom Lane (#4)
1 attachment(s)
Re: truncating timestamps on arbitrary intervals

On Wed, Feb 26, 2020 at 11:36 PM Tom Lane <tgl@sss.pgh.pa.us> wrote:

John Naylor <john.naylor@2ndquadrant.com> writes:

[ v3-datetrunc_interval.patch ]

A few thoughts:

* In general, binning involves both an origin and a stride. When
working with plain numbers it's almost always OK to set the origin
to zero, but it's less clear to me whether that's all right for
timestamps. Do we need another optional argument? Even if we
don't, "zero" for tm_year is 1900, which is going to give results
that surprise somebody.

Not sure.

A surprise I foresee in general might be: '1 week' is just '7 days',
and not aligned on "WOY". Since the function is passed an interval and
not text, we can't raise a warning. But date_trunc() already covers
that, so probably not a big deal.

* I'm still not convinced that the code does the right thing for
1-based months or days. Shouldn't you need to subtract 1, then
do the modulus, then add back 1?

Yes, brain fade on my part. Fixed in the attached v4.

* Speaking of modulus, would it be clearer to express the
calculations like
timestamp -= timestamp % interval;
(That's just a question, I'm not sure.)

Seems nicer, so done that way.

* Code doesn't look to have thought carefully about what to do with
negative intervals, or BC timestamps.

By accident, negative intervals currently behave the same as positive
ones. We could make negative intervals round up rather than truncate
(or vice versa). I don't know the best thing to do here.

As for BC, changed so it goes in the correct direction at least, and added test.

* The comment
* Justify all lower timestamp units and throw an error if any
* of the lower interval units are non-zero.
doesn't seem to have a lot to do with what the code after it actually
does. Also, you need explicit /* FALLTHRU */-type comments in that
switch, or pickier buildfarm members will complain.

Stale comment from an earlier version, fixed. Not sure if "justify" is
the right term, but "zero" is a bit misleading. Added fall thru's.

* Seems like you could jam all the unit-related error checking into
that switch's default: case, where it will cost nothing if the
call is valid:

switch (unit)
{
...
default:
if (unit == 0)
// complain about zero interval
else
// complain about interval with multiple components
}

Done.

* I'd use ERRCODE_INVALID_PARAMETER_VALUE for any case of disallowed
contents of the interval.

Done.

Also removed the millisecond case, since it's impossible, or at least
not worth it, to distinguish from the microsecond case.

Note: I haven't done any additional docs changes in v4.

TODO: with timezone

possible TODO: origin parameter

--
John Naylor https://www.2ndQuadrant.com/
PostgreSQL Development, 24x7 Support, Remote DBA, Training & Services

Attachments:

v4-datetrunc_interval.patchapplication/octet-stream; name=v4-datetrunc_interval.patchDownload
 doc/src/sgml/func.sgml                  |  26 ++++++-
 src/backend/utils/adt/timestamp.c       | 118 ++++++++++++++++++++++++++++++++
 src/include/catalog/pg_proc.dat         |   3 +
 src/test/regress/expected/timestamp.out |  44 ++++++++++++
 src/test/regress/sql/timestamp.sql      |  26 +++++++
 5 files changed, 216 insertions(+), 1 deletion(-)

diff --git a/doc/src/sgml/func.sgml b/doc/src/sgml/func.sgml
index 28035f1635..752cb13c0a 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -6949,6 +6949,15 @@ SELECT regexp_match('abc01234xyz', '(?:(.*?)(\d+)(.*)){1,1}');
         <entry><literal>2 days 03:00:00</literal></entry>
        </row>
 
+       <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp</type>)</function></literal></entry>
+        <entry><type>timestamp</type></entry>
+        <entry>Truncate to specified precision; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40')</literal></entry>
+        <entry><literal>2001-02-16 20:30:00</literal></entry>
+       </row>
+
        <row>
         <entry>
          <indexterm>
@@ -7818,7 +7827,7 @@ SELECT date_part('hour', INTERVAL '4 hours 3 minutes');
   </sect2>
 
   <sect2 id="functions-datetime-trunc">
-   <title><function>date_trunc</function></title>
+   <title><function>date_trunc</function>, <function>date_trunc_interval</function></title>
 
    <indexterm>
     <primary>date_trunc</primary>
@@ -7902,6 +7911,21 @@ SELECT date_trunc('hour', INTERVAL '3 days 02:47:33');
 <lineannotation>Result: </lineannotation><computeroutput>3 days 02:00:00</computeroutput>
 </screen>
    </para>
+
+   <para>
+    The function <function>date_trunc_interval</function> is 
+    similar to the <function>date_trunc</function>, except that it
+    truncates to an arbitrary interval.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 minutes', TIMESTAMP '2001-02-16 20:38:40');
+<lineannotation>Result: </lineannotation><computeroutput>2001-02-16 20:35:00</computeroutput>
+</screen>
+   </para>
+
   </sect2>
 
   <sect2 id="functions-datetime-zoneconvert">
diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index 0b6c9d5ea8..26678613e2 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -30,6 +30,7 @@
 #include "nodes/nodeFuncs.h"
 #include "nodes/supportnodes.h"
 #include "parser/scansup.h"
+#include "port/pg_bitutils.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/datetime.h"
@@ -3804,6 +3805,123 @@ timestamptz_age(PG_FUNCTION_ARGS)
  *---------------------------------------------------------*/
 
 
+/* timestamp_trunc_interval()
+ * Truncate timestamp to specified interval.
+ */
+Datum
+timestamp_trunc_interval(PG_FUNCTION_ARGS)
+{
+	Interval   *interval = PG_GETARG_INTERVAL_P(0);
+	Timestamp	timestamp = PG_GETARG_TIMESTAMP(1);
+	Timestamp	result;
+	fsec_t		ifsec,
+				tfsec;
+	uint32		unit = 0;
+
+	struct pg_tm it;
+	struct pg_tm tt;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMP(timestamp);
+
+	if (interval2tm(*interval, &it, &ifsec) != 0)
+		elog(ERROR, "could not convert interval to tm");
+
+	if (timestamp2tm(timestamp, NULL, &tt, &tfsec, NULL, NULL) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	if (it.tm_year != 0)
+	{
+		if (tt.tm_year < 0)
+			tt.tm_year -= tt.tm_year % it.tm_year + it.tm_year;
+		else
+			tt.tm_year -= tt.tm_year % it.tm_year;
+		unit |= DTK_M(DTK_YEAR);
+	}
+	if (it.tm_mon != 0)
+	{
+		tt.tm_mon -= (tt.tm_mon - 1) % it.tm_mon;
+		unit |= DTK_M(DTK_MONTH);
+	}
+	if (it.tm_mday != 0)
+	{
+		tt.tm_mday -= (tt.tm_mday - 1) % it.tm_mday;
+		unit |= DTK_M(DTK_DAY);
+	}
+	if (it.tm_hour != 0)
+	{
+		tt.tm_hour -= tt.tm_hour % it.tm_hour;
+		unit |= DTK_M(DTK_HOUR);
+	}
+	if (it.tm_min != 0)
+	{
+		tt.tm_min -= tt.tm_min % it.tm_min;
+		unit |= DTK_M(DTK_MINUTE);
+	}
+	if (it.tm_sec != 0)
+	{
+		tt.tm_sec -= tt.tm_sec % it.tm_sec;
+		unit |= DTK_M(DTK_SECOND);
+	}
+	if (ifsec != 0)
+	{
+		tfsec -= tfsec % ifsec;
+		unit |= DTK_M(DTK_MICROSEC);
+	}
+
+	/* Justify all smaller timestamp units */
+	switch (unit)
+	{
+		case DTK_M(DTK_YEAR):
+			tt.tm_mon = 1;
+			/* FALL THRU */
+		case DTK_M(DTK_MONTH):
+			tt.tm_mday = 1;
+			/* FALL THRU */
+		case DTK_M(DTK_DAY):
+			tt.tm_hour = 0;
+			/* FALL THRU */
+		case DTK_M(DTK_HOUR):
+			tt.tm_min = 0;
+			/* FALL THRU */
+		case DTK_M(DTK_MINUTE):
+			tt.tm_sec = 0;
+			/* FALL THRU */
+		case DTK_M(DTK_SECOND):
+			tfsec = 0;
+			/* FALL THRU */
+		case DTK_M(DTK_MICROSEC):
+			break;
+		default:
+			if (unit == 0)
+				ereport(ERROR,
+						(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						 errmsg("interval not initialized")));
+			else
+			{
+				int popcount = pg_popcount32(unit);
+
+				if (popcount > 1)
+					ereport(ERROR,
+							(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+							 errmsg("only one interval unit allowed for truncation")));
+				else
+					ereport(ERROR,
+							(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+							 errmsg("interval unit not supported")));
+			}
+	}
+
+	if (tm2timestamp(&tt, tfsec, NULL, &result) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	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..01524fb15a 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -5663,6 +5663,9 @@
 { 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 => '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..acc6c4b75f 100644
--- a/src/test/regress/expected/timestamp.out
+++ b/src/test/regress/expected/timestamp.out
@@ -545,6 +545,50 @@ SELECT '' AS date_trunc_week, date_trunc( 'week', timestamp '2004-02-29 15:44:17
                  | Mon Feb 23 00:00:00 2004
 (1 row)
 
+-- truncate timestamps on intervals
+SELECT
+  interval,
+  date_trunc_interval(interval::interval, ts)
+FROM (
+  VALUES
+  ('50 years'),
+  ('1 month'),
+  ('2 months'),
+  ('6 months'),
+  ('7 days'),
+  ('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         | Sat Jan 01 00:00:00 2000
+ 1 month          | Sat Feb 01 00:00:00 2020
+ 2 months         | Wed Jan 01 00:00:00 2020
+ 6 months         | Wed Jan 01 00:00:00 2020
+ 7 days           | Sat Feb 08 00:00:00 2020
+ 15 days          | Sat Feb 01 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
+(11 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     
+-----------------------------
+ Mon Jan 01 00:00:00 0101 BC
+(1 row)
+
+-- disallow multiple units (ms + us is an exception)
+SELECT date_trunc_interval('1 year 1 microsecond', TIMESTAMP '2001-02-16 20:38:40.123456');
+ERROR:  only one interval unit allowed for truncation
 -- 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..9e5d72de84 100644
--- a/src/test/regress/sql/timestamp.sql
+++ b/src/test/regress/sql/timestamp.sql
@@ -166,6 +166,32 @@ 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;
 
+-- truncate timestamps on intervals
+SELECT
+  interval,
+  date_trunc_interval(interval::interval, ts)
+FROM (
+  VALUES
+  ('50 years'),
+  ('1 month'),
+  ('2 months'),
+  ('6 months'),
+  ('7 days'),
+  ('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');
+
+-- disallow multiple units (ms + us is an exception)
+SELECT date_trunc_interval('1 year 1 microsecond', 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
#7John Naylor
john.naylor@2ndquadrant.com
In reply to: Tom Lane (#4)
1 attachment(s)
Re: truncating timestamps on arbitrary intervals

On Wed, Feb 26, 2020 at 11:36 PM Tom Lane <tgl@sss.pgh.pa.us> wrote:

* In general, binning involves both an origin and a stride. When
working with plain numbers it's almost always OK to set the origin
to zero, but it's less clear to me whether that's all right for
timestamps. Do we need another optional argument? Even if we
don't, "zero" for tm_year is 1900, which is going to give results
that surprise somebody.

I tried the simplest way in the attached v5. Examples (third param is origin):

-- same result as no origin:
select date_trunc_interval('5 min'::interval, TIMESTAMP '2020-02-01
01:01:01', TIMESTAMP '2020-02-01');
date_trunc_interval
---------------------
2020-02-01 01:00:00
(1 row)

-- shift bins by 2.5 min:
select date_trunc_interval('5 min'::interval, TIMESTAMP '2020-02-1
01:01:01', TIMESTAMP '2020-02-01 00:02:30');
date_trunc_interval
---------------------
2020-02-01 00:57:30
(1 row)

-- align weeks to start on Sunday
select date_trunc_interval('7 days'::interval, TIMESTAMP '2020-02-11
01:01:01.0', TIMESTAMP '1900-01-02');
date_trunc_interval
---------------------
2020-02-09 00:00:00
(1 row)

I've put off adding documentation on the origin piece pending comments
about the approach.

I haven't thought seriously about timezone yet, but hopefully it's
just work and nothing to think too hard about.

--
John Naylor https://www.2ndQuadrant.com/
PostgreSQL Development, 24x7 Support, Remote DBA, Training & Services

Attachments:

v5-datetrunc_interval.patchapplication/octet-stream; name=v5-datetrunc_interval.patchDownload
 doc/src/sgml/func.sgml                  |  26 +++++-
 src/backend/utils/adt/timestamp.c       | 151 ++++++++++++++++++++++++++++++++
 src/include/catalog/pg_proc.dat         |   8 ++
 src/test/regress/expected/timestamp.out |  44 ++++++++++
 src/test/regress/sql/timestamp.sql      |  26 ++++++
 5 files changed, 254 insertions(+), 1 deletion(-)

diff --git a/doc/src/sgml/func.sgml b/doc/src/sgml/func.sgml
index 323366feb6..d518e85411 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -6960,6 +6960,15 @@ SELECT regexp_match('abc01234xyz', '(?:(.*?)(\d+)(.*)){1,1}');
         <entry><literal>2 days 03:00:00</literal></entry>
        </row>
 
+       <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp</type>)</function></literal></entry>
+        <entry><type>timestamp</type></entry>
+        <entry>Truncate to specified precision; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40')</literal></entry>
+        <entry><literal>2001-02-16 20:30:00</literal></entry>
+       </row>
+
        <row>
         <entry>
          <indexterm>
@@ -7829,7 +7838,7 @@ SELECT date_part('hour', INTERVAL '4 hours 3 minutes');
   </sect2>
 
   <sect2 id="functions-datetime-trunc">
-   <title><function>date_trunc</function></title>
+   <title><function>date_trunc</function>, <function>date_trunc_interval</function></title>
 
    <indexterm>
     <primary>date_trunc</primary>
@@ -7913,6 +7922,21 @@ SELECT date_trunc('hour', INTERVAL '3 days 02:47:33');
 <lineannotation>Result: </lineannotation><computeroutput>3 days 02:00:00</computeroutput>
 </screen>
    </para>
+
+   <para>
+    The function <function>date_trunc_interval</function> is 
+    similar to the <function>date_trunc</function>, except that it
+    truncates to an arbitrary interval.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 minutes', TIMESTAMP '2001-02-16 20:38:40');
+<lineannotation>Result: </lineannotation><computeroutput>2001-02-16 20:35:00</computeroutput>
+</screen>
+   </para>
+
   </sect2>
 
   <sect2 id="functions-datetime-zoneconvert">
diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index 0b6c9d5ea8..2b76ca2c57 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -30,6 +30,7 @@
 #include "nodes/nodeFuncs.h"
 #include "nodes/supportnodes.h"
 #include "parser/scansup.h"
+#include "port/pg_bitutils.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/datetime.h"
@@ -3804,6 +3805,156 @@ timestamptz_age(PG_FUNCTION_ARGS)
  *---------------------------------------------------------*/
 
 
+static Timestamp
+timestamp_trunc_interval_internal(Interval *stride,
+								  Timestamp timestamp,
+								  Timestamp origin)
+{
+	Timestamp	result;
+	fsec_t		ifsec,
+				tfsec;
+	uint32		unit = 0;
+
+	struct pg_tm it;
+	struct pg_tm tt;
+
+	if (interval2tm(*stride, &it, &ifsec) != 0)
+		elog(ERROR, "could not convert interval to tm");
+
+	if (timestamp2tm(timestamp - origin, NULL, &tt, &tfsec, NULL, NULL) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	if (it.tm_year != 0)
+	{
+		if (tt.tm_year < 0)
+			tt.tm_year -= tt.tm_year % it.tm_year + it.tm_year;
+		else
+			tt.tm_year -= tt.tm_year % it.tm_year;
+		unit |= DTK_M(DTK_YEAR);
+	}
+	if (it.tm_mon != 0)
+	{
+		tt.tm_mon -= (tt.tm_mon - 1) % it.tm_mon;
+		unit |= DTK_M(DTK_MONTH);
+	}
+	if (it.tm_mday != 0)
+	{
+		tt.tm_mday -= (tt.tm_mday - 1) % it.tm_mday;
+		unit |= DTK_M(DTK_DAY);
+	}
+	if (it.tm_hour != 0)
+	{
+		tt.tm_hour -= tt.tm_hour % it.tm_hour;
+		unit |= DTK_M(DTK_HOUR);
+	}
+	if (it.tm_min != 0)
+	{
+		tt.tm_min -= tt.tm_min % it.tm_min;
+		unit |= DTK_M(DTK_MINUTE);
+	}
+	if (it.tm_sec != 0)
+	{
+		tt.tm_sec -= tt.tm_sec % it.tm_sec;
+		unit |= DTK_M(DTK_SECOND);
+	}
+	if (ifsec != 0)
+	{
+		tfsec -= tfsec % ifsec;
+		unit |= DTK_M(DTK_MICROSEC);
+	}
+
+	/* Justify all smaller timestamp units */
+	switch (unit)
+	{
+		case DTK_M(DTK_YEAR):
+			tt.tm_mon = 1;
+			/* FALL THRU */
+		case DTK_M(DTK_MONTH):
+			tt.tm_mday = 1;
+			/* FALL THRU */
+		case DTK_M(DTK_DAY):
+			tt.tm_hour = 0;
+			/* FALL THRU */
+		case DTK_M(DTK_HOUR):
+			tt.tm_min = 0;
+			/* FALL THRU */
+		case DTK_M(DTK_MINUTE):
+			tt.tm_sec = 0;
+			/* FALL THRU */
+		case DTK_M(DTK_SECOND):
+			tfsec = 0;
+			/* FALL THRU */
+		case DTK_M(DTK_MICROSEC):
+			break;
+		default:
+			if (unit == 0)
+				ereport(ERROR,
+						(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						 errmsg("interval not initialized")));
+			else
+			{
+				int popcount = pg_popcount32(unit);
+
+				if (popcount > 1)
+					ereport(ERROR,
+							(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+							 errmsg("only one interval unit allowed for truncation")));
+				else
+					ereport(ERROR,
+							(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+							 errmsg("interval unit not supported")));
+			}
+	}
+
+	if (tm2timestamp(&tt, tfsec, NULL, &result) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	return result + origin;
+}
+
+/* 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);
+
+	result = timestamp_trunc_interval_internal(stride, timestamp, 0);
+
+	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("origin 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..acc6c4b75f 100644
--- a/src/test/regress/expected/timestamp.out
+++ b/src/test/regress/expected/timestamp.out
@@ -545,6 +545,50 @@ SELECT '' AS date_trunc_week, date_trunc( 'week', timestamp '2004-02-29 15:44:17
                  | Mon Feb 23 00:00:00 2004
 (1 row)
 
+-- truncate timestamps on intervals
+SELECT
+  interval,
+  date_trunc_interval(interval::interval, ts)
+FROM (
+  VALUES
+  ('50 years'),
+  ('1 month'),
+  ('2 months'),
+  ('6 months'),
+  ('7 days'),
+  ('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         | Sat Jan 01 00:00:00 2000
+ 1 month          | Sat Feb 01 00:00:00 2020
+ 2 months         | Wed Jan 01 00:00:00 2020
+ 6 months         | Wed Jan 01 00:00:00 2020
+ 7 days           | Sat Feb 08 00:00:00 2020
+ 15 days          | Sat Feb 01 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
+(11 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     
+-----------------------------
+ Mon Jan 01 00:00:00 0101 BC
+(1 row)
+
+-- disallow multiple units (ms + us is an exception)
+SELECT date_trunc_interval('1 year 1 microsecond', TIMESTAMP '2001-02-16 20:38:40.123456');
+ERROR:  only one interval unit allowed for truncation
 -- 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..9e5d72de84 100644
--- a/src/test/regress/sql/timestamp.sql
+++ b/src/test/regress/sql/timestamp.sql
@@ -166,6 +166,32 @@ 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;
 
+-- truncate timestamps on intervals
+SELECT
+  interval,
+  date_trunc_interval(interval::interval, ts)
+FROM (
+  VALUES
+  ('50 years'),
+  ('1 month'),
+  ('2 months'),
+  ('6 months'),
+  ('7 days'),
+  ('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');
+
+-- disallow multiple units (ms + us is an exception)
+SELECT date_trunc_interval('1 year 1 microsecond', 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
#8Isaac Morland
isaac.morland@gmail.com
In reply to: John Naylor (#7)
Re: truncating timestamps on arbitrary intervals

On Fri, 13 Mar 2020 at 03:13, John Naylor <john.naylor@2ndquadrant.com>
wrote:

On Wed, Feb 26, 2020 at 11:36 PM Tom Lane <tgl@sss.pgh.pa.us> wrote:

* In general, binning involves both an origin and a stride. When
working with plain numbers it's almost always OK to set the origin
to zero, but it's less clear to me whether that's all right for
timestamps. Do we need another optional argument? Even if we
don't, "zero" for tm_year is 1900, which is going to give results
that surprise somebody.

- align weeks to start on Sunday

select date_trunc_interval('7 days'::interval, TIMESTAMP '2020-02-11
01:01:01.0', TIMESTAMP '1900-01-02');
date_trunc_interval
---------------------
2020-02-09 00:00:00
(1 row)

I'm confused by this. If my calendars are correct, both 1900-01-02
and 2020-02-11 are Tuesdays. So if the date being adjusted and the origin
are both Tuesday, shouldn't the day part be left alone when truncating to 7
days? Also, I'd like to confirm that the default starting point for 7 day
periods (weeks) is Monday, per ISO. I know it's very fashionable in North
America to split the weekend in half but it's not the international
standard.

Perhaps the starting point for dates should be either 0001-01-01 (the
proleptic beginning of the CE calendar) or 2001-01-01 (the beginning of the
current 400-year repeating cycle of leap years and weeks, and a Monday,
giving the appropriate ISO result for truncating to 7 day periods).

#9John Naylor
john.naylor@2ndquadrant.com
In reply to: Isaac Morland (#8)
Re: truncating timestamps on arbitrary intervals

On Fri, Mar 13, 2020 at 7:48 PM Isaac Morland <isaac.morland@gmail.com> wrote:

On Fri, 13 Mar 2020 at 03:13, John Naylor <john.naylor@2ndquadrant.com> wrote:

- align weeks to start on Sunday
select date_trunc_interval('7 days'::interval, TIMESTAMP '2020-02-11
01:01:01.0', TIMESTAMP '1900-01-02');
date_trunc_interval
---------------------
2020-02-09 00:00:00
(1 row)

I'm confused by this. If my calendars are correct, both 1900-01-02 and 2020-02-11 are Tuesdays. So if the date being adjusted and the origin are both Tuesday, shouldn't the day part be left alone when truncating to 7 days?

Thanks for taking a look! The non-intuitive behavior you found is
because the patch shifts the timestamp before converting to the
internal pg_tm type. The pg_tm type stores day of the month, which is
used for the calculation. It's not counting the days since the origin.
Then the result is shifted back.

To get more logical behavior, perhaps the optional parameter is better
as an offset instead of an origin. Alternatively (or additionally),
the function could do the math on int64 timestamps directly.

Also, I'd like to confirm that the default starting point for 7 day periods (weeks) is Monday, per ISO.

That's currently the behavior in the existing date_trunc function,
when passed the string 'week'. Given that keyword, it calculates the
week of the year.

When using the proposed function with arbitrary intervals, it uses day
of the month, as found in the pg_tm struct. It doesn't treat 7 days
differently then 5 or 10 without user input (origin or offset), since
there is nothing special about 7 day intervals as such internally. To
show the difference between date_trunc, and date_trunc_interval as
implemented in v5 with no origin:

select date_trunc('week', d), count(*) from generate_series(
'2020-02-01'::timestamp, '2020-03-31', '1 day') d group by 1 order by
1;
date_trunc | count
---------------------+-------
2020-01-27 00:00:00 | 2
2020-02-03 00:00:00 | 7
2020-02-10 00:00:00 | 7
2020-02-17 00:00:00 | 7
2020-02-24 00:00:00 | 7
2020-03-02 00:00:00 | 7
2020-03-09 00:00:00 | 7
2020-03-16 00:00:00 | 7
2020-03-23 00:00:00 | 7
2020-03-30 00:00:00 | 2
(10 rows)

select date_trunc_interval('7 days'::interval, d), count(*) from
generate_series( '2020-02-01'::timestamp, '2020-03-31', '1 day') d
group by 1 order by 1;
date_trunc_interval | count
---------------------+-------
2020-02-01 00:00:00 | 7
2020-02-08 00:00:00 | 7
2020-02-15 00:00:00 | 7
2020-02-22 00:00:00 | 7
2020-02-29 00:00:00 | 1
2020-03-01 00:00:00 | 7
2020-03-08 00:00:00 | 7
2020-03-15 00:00:00 | 7
2020-03-22 00:00:00 | 7
2020-03-29 00:00:00 | 3
(10 rows)

Resetting the day every month is counterintuitive if not broken, and
as I mentioned it might make more sense to use the int64 timestamp
directly, at least for intervals less than one month. I'll go look
into doing that.

--
John Naylor https://www.2ndQuadrant.com/
PostgreSQL Development, 24x7 Support, Remote DBA, Training & Services

#10Artur Zakirov
zaartur@gmail.com
In reply to: John Naylor (#7)
Re: truncating timestamps on arbitrary intervals

Hello,

On 3/13/2020 4:13 PM, John Naylor wrote:

I've put off adding documentation on the origin piece pending comments
about the approach.

I haven't thought seriously about timezone yet, but hopefully it's
just work and nothing to think too hard about.

Thank you for the patch. I looked it and tested a bit.

There is one interesting case which might be mentioned in the
documentation or in the tests is the following. The function has
interesting behaviour with real numbers:

=# select date_trunc_interval('0.1 year'::interval, TIMESTAMP
'2020-02-01 01:21:01');
date_trunc_interval
---------------------
2020-02-01 00:00:00

=# select date_trunc_interval('1.1 year'::interval, TIMESTAMP
'2020-02-01 01:21:01');
ERROR: only one interval unit allowed for truncation

It is because the second interval has two interval units:

=# select '0.1 year'::interval;
interval
----------
1 mon

=# select '1.1 year'::interval;
interval
--------------
1 year 1 mon

--
Artur

#11John Naylor
john.naylor@2ndquadrant.com
In reply to: Artur Zakirov (#10)
1 attachment(s)
Re: truncating timestamps on arbitrary intervals

On Sun, Mar 15, 2020 at 2:26 PM I wrote:

To get more logical behavior, perhaps the optional parameter is better
as an offset instead of an origin. Alternatively (or additionally),
the function could do the math on int64 timestamps directly.

For v6, I changed the algorithm to use pg_tm for months and years, and
int64 for all smaller units. Despite the split, I think it's easier to
read now, and certainly shorter. This has the advantage that now
mixing units is allowed, as long as you don't mix months/years with
days or smaller, which often doesn't make sense and is not very
practical. (not yet documented) One consequence of this is that when
operating on months/years, and the origin contains smaller units, the
smaller units are ignored. Example:

select date_trunc_interval('12 months'::interval, timestamp
'2012-03-01 01:21:01', timestamp '2011-03-22');
date_trunc_interval
---------------------
2012-03-01 00:00:00
(1 row)

Even though not quite a full year has passed, it ignores the days in
the origin time and detects a difference in 12 calendar months. That
might be fine, although we could also throw an error and say origins
must be in the form of 'YYYY-01-01 00:00:00' when truncating on months
and/or years.

I added a sketch of documentation for the origin parameter and more tests.

On Fri, Mar 13, 2020 at 7:48 PM Isaac Morland <isaac.morland@gmail.com> wrote:

I'm confused by this. If my calendars are correct, both 1900-01-02 and 2020-02-11 are Tuesdays. So if the date being adjusted and the origin are both Tuesday, shouldn't the day part be left alone when truncating to 7 days? Also, I'd like to confirm that the default starting point for 7 day periods (weeks) is Monday, per ISO.

This is fixed.

select date_trunc_interval('7 days'::interval, timestamp '2020-02-11
01:01:01.0', TIMESTAMP '1900-01-02');
date_trunc_interval
---------------------
2020-02-11 00:00:00
(1 row)

select date_trunc_interval('7 days'::interval, d), count(*) from
generate_series( '2020-02-01'::timestamp, '2020-03-31', '1 day') d
group by 1 order by 1;
date_trunc_interval | count
---------------------+-------
2020-01-27 00:00:00 | 2
2020-02-03 00:00:00 | 7
2020-02-10 00:00:00 | 7
2020-02-17 00:00:00 | 7
2020-02-24 00:00:00 | 7
2020-03-02 00:00:00 | 7
2020-03-09 00:00:00 | 7
2020-03-16 00:00:00 | 7
2020-03-23 00:00:00 | 7
2020-03-30 00:00:00 | 2
(10 rows)

Perhaps the starting point for dates should be either 0001-01-01 (the proleptic beginning of the CE calendar) or 2001-01-01 (the beginning of the current 400-year repeating cycle of leap years and weeks, and a Monday, giving the appropriate ISO result for truncating to 7 day periods).

I went ahead with 2001-01-01 for the time being.

On Thu, Mar 19, 2020 at 4:20 PM Artur Zakirov <zaartur@gmail.com> wrote:

=# select date_trunc_interval('1.1 year'::interval, TIMESTAMP
'2020-02-01 01:21:01');
ERROR: only one interval unit allowed for truncation

For any lingering cases like this (i don't see any), maybe an error
hint is in order. The following works now, as expected for 1 year 1
month:

select date_trunc_interval('1.1 year'::interval, timestamp '2002-05-01
01:21:01');
date_trunc_interval
---------------------
2002-02-01 00:00:0

I'm going to look into implementing timezone while awaiting comments on v6.

--
John Naylor https://www.2ndQuadrant.com/
PostgreSQL Development, 24x7 Support, Remote DBA, Training & Services

Attachments:

v6-datetrunc_interval.patchapplication/octet-stream; name=v6-datetrunc_interval.patchDownload
 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}');
         <entry><literal>2 days 03:00:00</literal></entry>
        </row>
 
+       <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp</type> <optional>, <type>timestamp</type></optional>)</function></literal></entry>
+        <entry><type>timestamp</type></entry>
+        <entry>Truncate to specified precision; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40')</literal></entry>
+        <entry><literal>2001-02-16 20:30:00</literal></entry>
+       </row>
+
        <row>
         <entry>
          <indexterm>
@@ -7829,7 +7838,7 @@ SELECT date_part('hour', INTERVAL '4 hours 3 minutes');
   </sect2>
 
   <sect2 id="functions-datetime-trunc">
-   <title><function>date_trunc</function></title>
+   <title><function>date_trunc</function>, <function>date_trunc_interval</function></title>
 
    <indexterm>
     <primary>date_trunc</primary>
@@ -7913,6 +7922,33 @@ SELECT date_trunc('hour', INTERVAL '3 days 02:47:33');
 <lineannotation>Result: </lineannotation><computeroutput>3 days 02:00:00</computeroutput>
 </screen>
    </para>
+
+   <para>
+    The function <function>date_trunc_interval</function> is 
+    similar to the <function>date_trunc</function>, except that it
+    truncates to an arbitrary interval.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 minutes', TIMESTAMP '2001-02-16 20:38:40');
+<lineannotation>Result: </lineannotation><computeroutput>2001-02-16 20:35:00</computeroutput>
+</screen>
+   </para>
+
+   <para>
+    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.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 years'::interval, TIMESTAMP '2020-02-01', TIMESTAMP '2012-01-01');
+<lineannotation>Result: </lineannotation><computeroutput>2017-01-01 00:00:00</computeroutput>
+</screen>
+   </para>
+
   </sect2>
 
   <sect2 id="functions-datetime-zoneconvert">
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
#12Tels
nospam-pg-abuse@bloodgate.com
In reply to: John Naylor (#11)
Re: truncating timestamps on arbitrary intervals

Hello John,

this looks like a nice feature. I'm wondering how it relates to the
following use-case:

When drawing charts, the user can select pre-defined widths on times
(like "15 min", "1 hour").

The data for these slots is fitted always to intervalls that start in 0
in the slot, e.g. if the user selects "15 min", the interval always
starts at xx:00, xx:15, xx:30 or xx:45. This is to aid caching of the
resulting data, and so that requesting the same chart at xx:00 and xx:01
actually draws the same chart, and not some bar with only one minute
data at at the end.

In PSQL, this works out to using this as GROUP BY and then summing up
all values:

SELECT FLOOR(EXTRACT(EPOCH from thetime) / 3600) * 3600, SUM(events)
FROM mytable ... GROUP BY 1;

whereas here 3600 means "hourly".

It is of course easy for things like "1 hour", but for yearly I had to
come up with things like:

EXTRAC(YEAR FROM thetime) * 12 + EXTRACT(MONTH FROM thetime)

and its gets even more involved for weeks, weekdays or odd things like
fortnights.

So would that mean one could replace this by:

date_trunc_interval('3600 seconds'::interval, thetime)

and it would be easier, and (hopefully) faster?

Best regards,

Tels

#13John Naylor
john.naylor@2ndquadrant.com
In reply to: Tels (#12)
Re: truncating timestamps on arbitrary intervals

On Tue, Mar 24, 2020 at 9:34 PM Tels <nospam-pg-abuse@bloodgate.com> wrote:

Hello John,

this looks like a nice feature. I'm wondering how it relates to the
following use-case:

When drawing charts, the user can select pre-defined widths on times
(like "15 min", "1 hour").

The data for these slots is fitted always to intervalls that start in 0
in the slot, e.g. if the user selects "15 min", the interval always
starts at xx:00, xx:15, xx:30 or xx:45. This is to aid caching of the
resulting data, and so that requesting the same chart at xx:00 and xx:01
actually draws the same chart, and not some bar with only one minute
data at at the end.

Hi Tels, thanks for your interest! Sounds like the exact use case I had in mind.

It is of course easy for things like "1 hour", but for yearly I had to
come up with things like:

EXTRAC(YEAR FROM thetime) * 12 + EXTRACT(MONTH FROM thetime)

and its gets even more involved for weeks, weekdays or odd things like
fortnights.

To be clear, this particular case was already handled by the existing
date_trunc, but only single units and a few other special cases. I
understand if you have to write code to handle 15 minutes, you need to
use that structure for all cases.

Fortnight would be trivial:

date_trunc_interval('14 days'::interval, thetime [, optional start of
the fortnight])

...but weekdays would still need something extra.

So would that mean one could replace this by:

date_trunc_interval('3600 seconds'::interval, thetime)

and it would be easier, and (hopefully) faster?

Certainly easier and more flexible. It's hard to make guesses about
performance, but with your example above where you have two SQL
function calls plus some expression evaluation, I think a single
function would be faster in many cases.

--
John Naylor https://www.2ndQuadrant.com/
PostgreSQL Development, 24x7 Support, Remote DBA, Training & Services

#14John Naylor
john.naylor@2ndquadrant.com
In reply to: John Naylor (#11)
1 attachment(s)
Re: truncating timestamps on arbitrary intervals

I wrote:

I'm going to look into implementing timezone while awaiting comments on v6.

I attempted this in the attached v7. There are 4 new functions for
truncating timestamptz on an interval -- with and without origin, and
with and without time zone.

Parts of it are hackish, and need more work, but I think it's in
passable enough shape to get feedback on. The origin parameter logic
was designed with timestamps-without-time-zone in mind, and
retrofitting time zone on top of that was a bit messy. There might be
bugs.

--
John Naylor https://www.2ndQuadrant.com/
PostgreSQL Development, 24x7 Support, Remote DBA, Training & Services

Attachments:

v7-datetrunc_interval.patchapplication/octet-stream; name=v7-datetrunc_interval.patchDownload
 doc/src/sgml/func.sgml                  |  65 +++++-
 src/backend/utils/adt/timestamp.c       | 390 ++++++++++++++++++++++++++++++++
 src/include/catalog/pg_proc.dat         |  30 +++
 src/test/regress/expected/timestamp.out | 113 +++++++++
 src/test/regress/sql/timestamp.sql      |  60 +++++
 5 files changed, 657 insertions(+), 1 deletion(-)

diff --git a/doc/src/sgml/func.sgml b/doc/src/sgml/func.sgml
index 7a0bb0c70a..4e7cfe243e 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -6976,6 +6976,42 @@ SELECT regexp_match('abc01234xyz', '(?:(.*?)(\d+)(.*)){1,1}');
         <entry><literal>2 days 03:00:00</literal></entry>
        </row>
 
+       <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp</type>)</function></literal></entry>
+        <entry><type>timestamp</type></entry>
+        <entry>Truncate to specified interval; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40')</literal></entry>
+        <entry><literal>2001-02-16 20:30:00</literal></entry>
+       </row>
+
+      <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp</type>, <type>timestamp</type>)</function></literal></entry>
+        <entry><type>timestamp</type></entry>
+        <entry>Truncate to specified interval aligned with specified origin; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40', timestamp '2001-02-16 20:05:00')</literal></entry>
+        <entry><literal>2001-02-16 20:35:00</literal></entry>
+       </row>
+
+       <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamptz</type>, <type>text</type>)</function></literal></entry>
+        <entry><type>timestamptz  </type></entry>
+        <entry>Truncate to specified interval in the specified time zone; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('6 hours', timestamptz '2001-02-16 20:38:40+00', 'Australia/Sydney');</literal></entry>
+        <entry><literal>2001-02-16 19:00:00+00</literal></entry>
+       </row>
+
+      <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamptz</type>, <type>timestamptz</type>, <type>text</type>)</function></literal></entry>
+        <entry><type>timestamptz</type></entry>
+        <entry>Truncate to specified interval aligned with specified origin in the specified time zone; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('6 hours', timestamptz '2001-02-16 20:38:40+00', timestamptz '2001-02-16 01:00:00+00', 'Australia/Sydney');</literal></entry>
+        <entry><literal>2001-02-16 20:00:00+00</literal></entry>
+       </row>
+
        <row>
         <entry>
          <indexterm>
@@ -7845,7 +7881,7 @@ SELECT date_part('hour', INTERVAL '4 hours 3 minutes');
   </sect2>
 
   <sect2 id="functions-datetime-trunc">
-   <title><function>date_trunc</function></title>
+   <title><function>date_trunc</function>, <function>date_trunc_interval</function></title>
 
    <indexterm>
     <primary>date_trunc</primary>
@@ -7929,6 +7965,33 @@ SELECT date_trunc('hour', INTERVAL '3 days 02:47:33');
 <lineannotation>Result: </lineannotation><computeroutput>3 days 02:00:00</computeroutput>
 </screen>
    </para>
+
+   <para>
+    The function <function>date_trunc_interval</function> is 
+    similar to the <function>date_trunc</function>, except that it
+    truncates to an arbitrary interval.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 minutes', TIMESTAMP '2001-02-16 20:38:40');
+<lineannotation>Result: </lineannotation><computeroutput>2001-02-16 20:35:00</computeroutput>
+</screen>
+   </para>
+
+   <para>
+    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.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 years'::interval, TIMESTAMP '2020-02-01', TIMESTAMP '2012-01-01');
+<lineannotation>Result: </lineannotation><computeroutput>2017-01-01 00:00:00</computeroutput>
+</screen>
+   </para>
+
   </sect2>
 
   <sect2 id="functions-datetime-zoneconvert">
diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index 4caffb5804..9d1f3a0e13 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -45,6 +45,13 @@
 
 #define SAMESIGN(a,b)	(((a) < 0) == ((b) < 0))
 
+/*
+ * 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);
+ */
+#define DATE_TRUNC_DEFAULT_ORIGIN 31622400000000
+
 /* Set at postmaster start */
 TimestampTz PgStartTime;
 
@@ -3804,6 +3811,122 @@ 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);
+
+	result = timestamp_trunc_interval_internal(stride, timestamp,
+											   DATE_TRUNC_DEFAULT_ORIGIN);
+
+	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.
  */
@@ -3938,6 +4061,106 @@ timestamp_trunc(PG_FUNCTION_ARGS)
 	PG_RETURN_TIMESTAMP(result);
 }
 
+/*
+ * Common code for timestamptz_trunc_interval*()
+ *
+ * tzp identifies the zone to truncate with respect to.  We assume
+ * infinite timestamps have already been rejected.
+ */
+static TimestampTz
+timestamptz_trunc_interval_internal(Interval *stride,
+									TimestampTz timestamp,
+									pg_tz *tzp,
+									TimestampTz origin)
+{
+	TimestampTz	result,
+				tm_diff;
+	fsec_t		ofsec,
+				tfsec;
+	int 		origin_months,
+				tm_months,
+				month_diff,
+				tz;
+
+	struct pg_tm ot;
+	struct pg_tm tt;
+
+	/* Convert input and origin to pg_tm with timezone. */
+	if (timestamp2tm(timestamp, &tz, &tt, &tfsec, NULL, tzp) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	// XXX it seems like we should pass TZ here, but it doesn't work that way
+	if (timestamp2tm(origin, NULL, &ot, &ofsec, NULL, NULL) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	/*
+	 * For strides measured in days or smaller units, do one simple calculation
+	 * on the time in microseconds.
+	 */
+	if (stride->month == 0)
+	{
+		// XXX the origin is exactly as passed in
+
+		// got the idea from timestamp_zone() to just recovert to timestamp
+		// with null tz.
+		if (tm2timestamp(&tt, tfsec, NULL, &timestamp) != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+					 errmsg("timestamp out of range")));
+
+		tm_diff = timestamp - origin;
+		tm_diff -= tm_diff % (stride->day * USECS_PER_DAY + stride->time);
+
+		// XXX this seems mysterious, hopefully there's a more principled way
+		result = dt2local(origin + tm_diff, -tz);
+	}
+	else
+	{
+		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")));
+
+		/*
+		 * For strides measured in years and/or months, convert origin and
+		 * input to purely months.
+		 */
+		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, &tz, &result) != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+					 errmsg("timestamp out of range")));
+	}
+
+	return result;
+}
+
 /*
  * Common code for timestamptz_trunc() and timestamptz_trunc_zone().
  *
@@ -4085,6 +4308,52 @@ timestamptz_trunc_internal(text *units, TimestampTz timestamp, pg_tz *tzp)
 	return result;
 }
 
+/* timestamptz_trunc_interval()
+ * Truncate timestamptz to specified interval in session timezone.
+ */
+Datum
+timestamptz_trunc_interval(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	TimestampTz result;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMPTZ(timestamp);
+
+	result = timestamptz_trunc_interval_internal(stride, timestamp,
+												 session_timezone,
+												 DATE_TRUNC_DEFAULT_ORIGIN);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
+/* timestamptz_trunc_interval_origin()
+ * Truncate timestamptz to specified interval in session timezone.
+ */
+Datum
+timestamptz_trunc_interval_origin(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	TimestampTz result;
+	TimestampTz	origin = PG_GETARG_TIMESTAMPTZ(2);
+
+	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 = timestamptz_trunc_interval_internal(stride, timestamp,
+												 session_timezone,
+												 origin);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
 /* timestamptz_trunc()
  * Truncate timestamptz to specified units in session timezone.
  */
@@ -4103,6 +4372,127 @@ timestamptz_trunc(PG_FUNCTION_ARGS)
 	PG_RETURN_TIMESTAMPTZ(result);
 }
 
+/* timestamptz_trunc_interval_zone()
+ * Truncate timestamptz to specified interval in specified timezone.
+ */
+Datum
+timestamptz_trunc_interval_zone(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	text	   *zone = PG_GETARG_TEXT_PP(2);
+	TimestampTz result;
+	char		tzname[TZ_STRLEN_MAX + 1];
+	char	   *lowzone;
+	int			type,
+				val;
+	pg_tz	   *tzp;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMPTZ(timestamp);
+
+	/*
+	 * Look up the requested timezone (see notes in timestamptz_zone()).
+	 */
+	text_to_cstring_buffer(zone, tzname, sizeof(tzname));
+
+	/* DecodeTimezoneAbbrev requires lowercase input */
+	lowzone = downcase_truncate_identifier(tzname,
+										   strlen(tzname),
+										   false);
+
+	type = DecodeTimezoneAbbrev(0, lowzone, &val, &tzp);
+
+	if (type == TZ || type == DTZ)
+	{
+		/* fixed-offset abbreviation, get a pg_tz descriptor for that */
+		tzp = pg_tzset_offset(-val);
+	}
+	else if (type == DYNTZ)
+	{
+		/* dynamic-offset abbreviation, use its referenced timezone */
+	}
+	else
+	{
+		/* try it as a full zone name */
+		tzp = pg_tzset(tzname);
+		if (!tzp)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("time zone \"%s\" not recognized", tzname)));
+	}
+
+	result = timestamptz_trunc_interval_internal(stride, timestamp,
+												 tzp,
+												 DATE_TRUNC_DEFAULT_ORIGIN);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
+/* timestamptz_trunc_interval_origin_zone()
+ * Truncate timestamptz to specified interval in specified timezone,
+ * aligned to the specified origin.
+ */
+Datum
+timestamptz_trunc_interval_origin_zone(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	TimestampTz	origin = PG_GETARG_TIMESTAMPTZ(2);
+	text	   *zone = PG_GETARG_TEXT_PP(3);
+	TimestampTz result;
+	char		tzname[TZ_STRLEN_MAX + 1];
+	char	   *lowzone;
+	int			type,
+				val;
+	pg_tz	   *tzp;
+
+	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")));
+
+	/*
+	 * Look up the requested timezone (see notes in timestamptz_zone()).
+	 */
+	text_to_cstring_buffer(zone, tzname, sizeof(tzname));
+
+	/* DecodeTimezoneAbbrev requires lowercase input */
+	lowzone = downcase_truncate_identifier(tzname,
+										   strlen(tzname),
+										   false);
+
+	type = DecodeTimezoneAbbrev(0, lowzone, &val, &tzp);
+
+	if (type == TZ || type == DTZ)
+	{
+		/* fixed-offset abbreviation, get a pg_tz descriptor for that */
+		tzp = pg_tzset_offset(-val);
+	}
+	else if (type == DYNTZ)
+	{
+		/* dynamic-offset abbreviation, use its referenced timezone */
+	}
+	else
+	{
+		/* try it as a full zone name */
+		tzp = pg_tzset(tzname);
+		if (!tzp)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("time zone \"%s\" not recognized", tzname)));
+	}
+
+	result = timestamptz_trunc_interval_internal(stride, timestamp,
+												 tzp,
+												 origin);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
 /* timestamptz_trunc_zone()
  * Truncate timestamptz to specified units in specified timezone.
  */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 87d25d4a4b..e8aeb4801c 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -5663,6 +5663,36 @@
 { 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 => '8991',
+  descr => 'truncate timestamp with time zone to specified interval',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz', prosrc => 'timestamptz_trunc_interval' },
+{ oid => '8992',
+  descr => 'truncate timestamp with time zone to specified interval, in specified time zone',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz text',
+  prosrc => 'timestamptz_trunc_interval_zone' },
+
+{ oid => '8993',
+  descr => 'truncate timestamp with time zone to specified interval and origin',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz timestamptz', prosrc => 'timestamptz_trunc_interval_origin' },
+{ oid => '8994',
+  descr => 'truncate timestamp with time zone to specified interval and origin, in specified time zone',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz timestamptz text',
+  prosrc => 'timestamptz_trunc_interval_origin_zone' },
+
 { 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..4a0577a38b 100644
--- a/src/test/regress/expected/timestamp.out
+++ b/src/test/regress/expected/timestamp.out
@@ -545,6 +545,119 @@ 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)
+
+SELECT date_trunc('hour', timestamp with time zone '2020-02-01 01:21:01+02', 'Australia/Sydney') =
+date_trunc_interval('1 hour', timestamp with time zone '2020-02-01 01:21:01+02', 'Australia/Sydney');
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT date_trunc('day', timestamp with time zone '2020-02-01 01:21:01+02', 'Australia/Sydney') =
+date_trunc_interval('1 day', timestamp with time zone '2020-02-01 01:21:01+02', 'Australia/Sydney');
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT date_trunc('month', timestamp with time zone '2020-02-01 01:21:01+02', 'Australia/Sydney') =
+date_trunc_interval('1 month', timestamp with time zone '2020-02-01 01:21:01+02', 'Australia/Sydney');
+ ?column? 
+----------
+ t
+(1 row)
+
+-- 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..27be53112d 100644
--- a/src/test/regress/sql/timestamp.sql
+++ b/src/test/regress/sql/timestamp.sql
@@ -166,6 +166,66 @@ 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);
+
+SELECT date_trunc('hour', timestamp with time zone '2020-02-01 01:21:01+02', 'Australia/Sydney') =
+date_trunc_interval('1 hour', timestamp with time zone '2020-02-01 01:21:01+02', 'Australia/Sydney');
+
+SELECT date_trunc('day', timestamp with time zone '2020-02-01 01:21:01+02', 'Australia/Sydney') =
+date_trunc_interval('1 day', timestamp with time zone '2020-02-01 01:21:01+02', 'Australia/Sydney');
+
+SELECT date_trunc('month', timestamp with time zone '2020-02-01 01:21:01+02', 'Australia/Sydney') =
+date_trunc_interval('1 month', timestamp with time zone '2020-02-01 01:21:01+02', 'Australia/Sydney');
+
+-- 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
#15Artur Zakirov
zaartur@gmail.com
In reply to: John Naylor (#14)
Re: truncating timestamps on arbitrary intervals

On 3/30/2020 9:30 PM, John Naylor wrote:

I attempted this in the attached v7. There are 4 new functions for
truncating timestamptz on an interval -- with and without origin, and
with and without time zone.

Thank you for new version of the patch.

I'm not sure that I fully understand the 'origin' parameter. Is it valid
to have a value of 'origin' which is greater than a value of 'timestamp'
parameter?

I get some different results in such case:

=# select date_trunc_interval('2 year', timestamp '2020-01-16 20:38:40',
timestamp '2022-01-17 00:00:00');
date_trunc_interval
---------------------
2020-01-01 00:00:00

=# select date_trunc_interval('3 year', timestamp '2020-01-16 20:38:40',
timestamp '2022-01-17 00:00:00');
date_trunc_interval
---------------------
2022-01-01 00:00:00

So here I'm not sure which result is correct.

It seems that the patch is still in progress, but I have some nitpicking.

+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamptz</type>, <type>text</type>)</function></literal></entry>
+        <entry><type>timestamptz  </type></entry>

It seems that 'timestamptz' in both argument and result descriptions
should be replaced by 'timestamp with time zone' (see other functions
descriptions). Though it is okay to use 'timestamptz' in SQL examples.

timestamp_trunc_interval_internal() and
timestamptz_trunc_interval_internal() have similar code. I think they
can be rewritten to avoid code duplication.

--
Artur

#16John Naylor
john.naylor@2ndquadrant.com
In reply to: Artur Zakirov (#15)
1 attachment(s)
Re: truncating timestamps on arbitrary intervals

On Tue, Mar 31, 2020 at 4:34 PM Artur Zakirov <zaartur@gmail.com> wrote:

Thank you for new version of the patch.

Thanks for taking a look! Attached is v8, which addresses your points,
adds tests and fixes some bugs. There are still some WIP detritus in
the timezone code, so I'm not claiming it's ready, but it's much
closer. I'm fairly confident in the implementation of timestamp
without time zone, however.

I'm not sure that I fully understand the 'origin' parameter. Is it valid
to have a value of 'origin' which is greater than a value of 'timestamp'
parameter?

That is the intention. The returned values should be

origin +/- (n * interval)

where n is an integer.

I get some different results in such case:

=# select date_trunc_interval('2 year', timestamp '2020-01-16 20:38:40',
timestamp '2022-01-17 00:00:00');
date_trunc_interval
---------------------
2020-01-01 00:00:00

This was correct per how I coded it, but I have rethought where to
draw the bins for user-specified origins. I have decided that the
above is inconsistent with units smaller than a month. We shouldn't
"cross" the bin until the input has reached Jan. 17, in this case. In
v8, the answer to the above is

date_trunc_interval
---------------------
2018-01-17 00:00:00
(1 row)

(This could probably be better documented)

=# select date_trunc_interval('3 year', timestamp '2020-01-16 20:38:40',

timestamp '2022-01-17 00:00:00');

date_trunc_interval
---------------------
2022-01-01 00:00:00

So here I'm not sure which result is correct.

This one is just plain broken. The result should always be equal or
earlier than the input. In v8 the result is now:

date_trunc_interval
---------------------
2019-01-17 00:00:00
(1 row)

It seems that the patch is still in progress, but I have some nitpicking.

+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamptz</type>, <type>text</type>)</function></literal></entry>
+        <entry><type>timestamptz  </type></entry>

It seems that 'timestamptz' in both argument and result descriptions
should be replaced by 'timestamp with time zone' (see other functions
descriptions). Though it is okay to use 'timestamptz' in SQL examples.

Any and all nitpicks welcome! I have made these match the existing
date_trunc documentation more closely.

timestamp_trunc_interval_internal() and
timestamptz_trunc_interval_internal() have similar code. I think they
can be rewritten to avoid code duplication.

I thought so too (and noticed the same about the existing date_trunc),
but it's more difficult than it looks.

Note: I copied some tests from timestamp to timestamptz with a few
tweaks. A few tz tests still don't pass. I'm not yet sure if the
problem is in the test, or my code. Some detailed review of the tests
and their results would be helpful.

--
John Naylor https://www.2ndQuadrant.com/
PostgreSQL Development, 24x7 Support, Remote DBA, Training & Services

Attachments:

v8-datetrunc_interval.patchapplication/octet-stream; name=v8-datetrunc_interval.patchDownload
 doc/src/sgml/func.sgml                    |  65 ++++-
 src/backend/utils/adt/timestamp.c         | 440 ++++++++++++++++++++++++++++++
 src/include/catalog/pg_proc.dat           |  30 ++
 src/include/datatype/timestamp.h          |   8 +
 src/test/regress/expected/timestamp.out   | 209 ++++++++++++++
 src/test/regress/expected/timestamptz.out | 149 ++++++++++
 src/test/regress/sql/timestamp.sql        | 118 ++++++++
 src/test/regress/sql/timestamptz.sql      |  90 ++++++
 8 files changed, 1108 insertions(+), 1 deletion(-)

diff --git a/doc/src/sgml/func.sgml b/doc/src/sgml/func.sgml
index 7a0bb0c70a..68afdb9ee4 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -6976,6 +6976,42 @@ SELECT regexp_match('abc01234xyz', '(?:(.*?)(\d+)(.*)){1,1}');
         <entry><literal>2 days 03:00:00</literal></entry>
        </row>
 
+       <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp</type>)</function></literal></entry>
+        <entry><type>timestamp</type></entry>
+        <entry>Truncate to specified interval; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40')</literal></entry>
+        <entry><literal>2001-02-16 20:30:00</literal></entry>
+       </row>
+
+      <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp</type>, <type>timestamp</type>)</function></literal></entry>
+        <entry><type>timestamp</type></entry>
+        <entry>Truncate to specified interval aligned with specified origin; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40', timestamp '2001-02-16 20:05:00')</literal></entry>
+        <entry><literal>2001-02-16 20:35:00</literal></entry>
+       </row>
+
+       <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp with time zone</type>, <optional><type>text</type></optional>)</function></literal></entry>
+        <entry><type>timestamp with time zone</type></entry>
+        <entry>Truncate to specified interval in the specified time zone; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('6 hours', timestamptz '2001-02-16 20:38:40+00', 'Australia/Sydney')</literal></entry>
+        <entry><literal>2001-02-16 19:00:00+00</literal></entry>
+       </row>
+
+      <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp with time zone</type>, <type>timestamp with time zone</type>, <optional><type>text</type></optional>)</function></literal></entry>
+        <entry><type>timestamp with time zone</type></entry>
+        <entry>Truncate to specified interval aligned with specified origin in the specified time zone; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('6 hours', timestamptz '2001-02-16 20:38:40+00', timestamptz '2001-02-16 01:00:00+00', 'Australia/Sydney')</literal></entry>
+        <entry><literal>2001-02-16 20:00:00+00</literal></entry>
+       </row>
+
        <row>
         <entry>
          <indexterm>
@@ -7845,7 +7881,7 @@ SELECT date_part('hour', INTERVAL '4 hours 3 minutes');
   </sect2>
 
   <sect2 id="functions-datetime-trunc">
-   <title><function>date_trunc</function></title>
+   <title><function>date_trunc</function>, <function>date_trunc_interval</function></title>
 
    <indexterm>
     <primary>date_trunc</primary>
@@ -7929,6 +7965,33 @@ SELECT date_trunc('hour', INTERVAL '3 days 02:47:33');
 <lineannotation>Result: </lineannotation><computeroutput>3 days 02:00:00</computeroutput>
 </screen>
    </para>
+
+   <para>
+    The function <function>date_trunc_interval</function> is 
+    similar to the <function>date_trunc</function>, except that it
+    truncates to an arbitrary interval.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 minutes', TIMESTAMP '2001-02-16 20:38:40');
+<lineannotation>Result: </lineannotation><computeroutput>2001-02-16 20:35:00</computeroutput>
+</screen>
+   </para>
+
+   <para>
+    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.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 years'::interval, TIMESTAMP '2020-02-01', TIMESTAMP '2012-01-01');
+<lineannotation>Result: </lineannotation><computeroutput>2017-01-01 00:00:00</computeroutput>
+</screen>
+   </para>
+
   </sect2>
 
   <sect2 id="functions-datetime-zoneconvert">
diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index 4caffb5804..a6af9217d5 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -45,6 +45,13 @@
 
 #define SAMESIGN(a,b)	(((a) < 0) == ((b) < 0))
 
+/*
+ * 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);
+ */
+#define DATE_TRUNC_DEFAULT_ORIGIN 31622400000000
+
 /* Set at postmaster start */
 TimestampTz PgStartTime;
 
@@ -3804,6 +3811,146 @@ timestamptz_age(PG_FUNCTION_ARGS)
  *---------------------------------------------------------*/
 
 
+static Timestamp
+timestamp_trunc_interval_internal(Interval *stride,
+								  Timestamp timestamp,
+								  Timestamp origin)
+{
+	Timestamp	result,
+				tm_diff,
+				tm_usecs,
+				tm_delta;
+	fsec_t		ofsec,
+				tfsec;
+	int 		origin_months,
+				tm_months,
+				result_months,
+				month_diff,
+				month_delta = 0;
+
+	struct pg_tm ot, /* origin */
+				 tt, /* input */
+				 rt; /* result */
+
+	tm_diff = timestamp - origin;
+
+	/*
+	 * For strides measured in days or smaller units, do one simple calculation
+	 * on the time in microseconds.
+	 */
+	if (stride->month == 0)
+	{
+		tm_usecs = stride->day * USECS_PER_DAY + stride->time;
+
+		/* trivial case of 1 usec */
+		if (tm_usecs == 1)
+			return timestamp;
+
+		tm_delta = tm_diff - tm_diff % tm_usecs;;
+		if (tm_diff < 0)
+			tm_delta -= tm_usecs;
+
+		result = origin + tm_delta;
+	}
+	else
+	{
+		/*
+		 * 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;
+		month_diff = tm_months - origin_months;
+
+		/* take the origin's smaller units into account */
+		if (Minor_Units(tt, tfsec) < Minor_Units(ot, ofsec))
+			month_diff--;
+
+		month_delta = month_diff - month_diff % stride->month;
+
+		/*
+		 * Make sure truncation happens in the right direction.
+		 * XXX This is a bit of a hack.
+		 */
+		if (month_diff < 0 && stride->month != 1)
+			month_delta -= stride->month;
+
+		result_months = origin_months + month_delta;
+
+		/* compute result fields of pg_tm struct */
+		rt.tm_year = result_months / 12;
+		rt.tm_mon = (result_months % 12) + 1;
+		/* align on origin's smaller units */
+		rt.tm_mday = ot.tm_mday;
+		rt.tm_hour = ot.tm_hour;
+		rt.tm_min = ot.tm_min;
+		rt.tm_sec = ot.tm_sec;
+
+		/* Note using the origin's fsec directly */
+		if (tm2timestamp(&rt, ofsec, 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);
+
+	result = timestamp_trunc_interval_internal(stride, timestamp,
+											   DATE_TRUNC_DEFAULT_ORIGIN);
+
+	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.
  */
@@ -3938,6 +4085,132 @@ timestamp_trunc(PG_FUNCTION_ARGS)
 	PG_RETURN_TIMESTAMP(result);
 }
 
+/*
+ * Common code for timestamptz_trunc_interval*()
+ *
+ * tzp identifies the zone to truncate with respect to.  We assume
+ * infinite timestamps have already been rejected.
+ */
+static TimestampTz
+timestamptz_trunc_interval_internal(Interval *stride,
+									TimestampTz timestamp,
+									pg_tz *tzp,
+									TimestampTz origin)
+{
+	TimestampTz	result,
+				tm_diff,
+				tm_usecs,
+				tm_delta;
+	fsec_t		ofsec,
+				tfsec;
+	int 		origin_months,
+				tm_months,
+				result_months,
+				month_diff,
+				month_delta = 0,
+				tz;
+
+	struct pg_tm ot, /* origin */
+				 tt, /* input */
+				 rt; /* result */
+
+	/* Convert input to pg_tm with timezone. */
+	if (timestamp2tm(timestamp, &tz, &tt, &tfsec, NULL, tzp) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	// XXX it seems like we should pass TZ here, but it seems to break
+	// things
+	if (timestamp2tm(origin, NULL, &ot, &ofsec, NULL, tzp) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	/*
+	 * For strides measured in days or smaller units, do one simple calculation
+	 * on the time in microseconds.
+	 */
+	if (stride->month == 0)
+	{
+		tm_usecs = stride->day * USECS_PER_DAY + stride->time;
+
+		/* trivial case of 1 usec */
+		if (tm_usecs == 1)
+			return timestamp;
+
+		// got the idea from timestamp_zone() to just recovert to timestamp
+		// with null tz.
+		if (tm2timestamp(&tt, tfsec, NULL, &timestamp) != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+					 errmsg("timestamp out of range")));
+
+		// WIP
+		// if (tm2timestamp(&ot, ofsec, NULL, &origin) != 0)
+		// 	ereport(ERROR,
+		// 			(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+		// 			 errmsg("timestamp out of range")));
+
+		tm_diff = timestamp - origin;
+
+		tm_delta = tm_diff - tm_diff % tm_usecs;;
+		if (tm_diff < 0)
+			tm_delta -= tm_usecs;
+
+
+		// XXX this seems mysterious, hopefully there's a more principled way
+		result = dt2local(origin + tm_delta, -tz);
+	}
+	else
+	{
+		/*
+		 * 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")));
+
+		origin_months = ot.tm_year * 12 + ot.tm_mon - 1;
+		tm_months = tt.tm_year * 12 + tt.tm_mon - 1;
+		month_diff = tm_months - origin_months;
+
+		/* take the origin's smaller units into account */
+		if (Minor_Units(tt, tfsec) < Minor_Units(ot, ofsec))
+			month_diff--;
+
+		month_delta = month_diff - month_diff % stride->month;
+
+		/*
+		 * Make sure truncation happens in the right direction.
+		 * XXX This is a bit of a hack.
+		 */
+		if (month_diff < 0 && stride->month != 1)
+			month_delta -= stride->month;
+
+		result_months = origin_months + month_delta;
+
+		/* compute result fields of pg_tm struct */
+		rt.tm_year = result_months / 12;
+		rt.tm_mon = (result_months % 12) + 1;
+		/* align on origin's smaller units */
+		rt.tm_mday = ot.tm_mday;
+		rt.tm_hour = ot.tm_hour;
+		rt.tm_min = ot.tm_min;
+		rt.tm_sec = ot.tm_sec;
+
+		/* Note using the origin's fsec directly */
+		if (tm2timestamp(&rt, ofsec, &tz, &result) != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+					 errmsg("timestamp out of range")));
+	}
+
+	return result;
+}
+
 /*
  * Common code for timestamptz_trunc() and timestamptz_trunc_zone().
  *
@@ -4085,6 +4358,52 @@ timestamptz_trunc_internal(text *units, TimestampTz timestamp, pg_tz *tzp)
 	return result;
 }
 
+/* timestamptz_trunc_interval()
+ * Truncate timestamptz to specified interval in session timezone.
+ */
+Datum
+timestamptz_trunc_interval(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	TimestampTz result;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMPTZ(timestamp);
+
+	result = timestamptz_trunc_interval_internal(stride, timestamp,
+												 session_timezone,
+												 DATE_TRUNC_DEFAULT_ORIGIN);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
+/* timestamptz_trunc_interval_origin()
+ * Truncate timestamptz to specified interval in session timezone.
+ */
+Datum
+timestamptz_trunc_interval_origin(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	TimestampTz result;
+	TimestampTz	origin = PG_GETARG_TIMESTAMPTZ(2);
+
+	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 = timestamptz_trunc_interval_internal(stride, timestamp,
+												 session_timezone,
+												 origin);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
 /* timestamptz_trunc()
  * Truncate timestamptz to specified units in session timezone.
  */
@@ -4103,6 +4422,127 @@ timestamptz_trunc(PG_FUNCTION_ARGS)
 	PG_RETURN_TIMESTAMPTZ(result);
 }
 
+/* timestamptz_trunc_interval_zone()
+ * Truncate timestamptz to specified interval in specified timezone.
+ */
+Datum
+timestamptz_trunc_interval_zone(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	text	   *zone = PG_GETARG_TEXT_PP(2);
+	TimestampTz result;
+	char		tzname[TZ_STRLEN_MAX + 1];
+	char	   *lowzone;
+	int			type,
+				val;
+	pg_tz	   *tzp;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMPTZ(timestamp);
+
+	/*
+	 * Look up the requested timezone (see notes in timestamptz_zone()).
+	 */
+	text_to_cstring_buffer(zone, tzname, sizeof(tzname));
+
+	/* DecodeTimezoneAbbrev requires lowercase input */
+	lowzone = downcase_truncate_identifier(tzname,
+										   strlen(tzname),
+										   false);
+
+	type = DecodeTimezoneAbbrev(0, lowzone, &val, &tzp);
+
+	if (type == TZ || type == DTZ)
+	{
+		/* fixed-offset abbreviation, get a pg_tz descriptor for that */
+		tzp = pg_tzset_offset(-val);
+	}
+	else if (type == DYNTZ)
+	{
+		/* dynamic-offset abbreviation, use its referenced timezone */
+	}
+	else
+	{
+		/* try it as a full zone name */
+		tzp = pg_tzset(tzname);
+		if (!tzp)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("time zone \"%s\" not recognized", tzname)));
+	}
+
+	result = timestamptz_trunc_interval_internal(stride, timestamp,
+												 tzp,
+												 DATE_TRUNC_DEFAULT_ORIGIN);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
+/* timestamptz_trunc_interval_origin_zone()
+ * Truncate timestamptz to specified interval in specified timezone,
+ * aligned to the specified origin.
+ */
+Datum
+timestamptz_trunc_interval_origin_zone(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	TimestampTz	origin = PG_GETARG_TIMESTAMPTZ(2);
+	text	   *zone = PG_GETARG_TEXT_PP(3);
+	TimestampTz result;
+	char		tzname[TZ_STRLEN_MAX + 1];
+	char	   *lowzone;
+	int			type,
+				val;
+	pg_tz	   *tzp;
+
+	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")));
+
+	/*
+	 * Look up the requested timezone (see notes in timestamptz_zone()).
+	 */
+	text_to_cstring_buffer(zone, tzname, sizeof(tzname));
+
+	/* DecodeTimezoneAbbrev requires lowercase input */
+	lowzone = downcase_truncate_identifier(tzname,
+										   strlen(tzname),
+										   false);
+
+	type = DecodeTimezoneAbbrev(0, lowzone, &val, &tzp);
+
+	if (type == TZ || type == DTZ)
+	{
+		/* fixed-offset abbreviation, get a pg_tz descriptor for that */
+		tzp = pg_tzset_offset(-val);
+	}
+	else if (type == DYNTZ)
+	{
+		/* dynamic-offset abbreviation, use its referenced timezone */
+	}
+	else
+	{
+		/* try it as a full zone name */
+		tzp = pg_tzset(tzname);
+		if (!tzp)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("time zone \"%s\" not recognized", tzname)));
+	}
+
+	result = timestamptz_trunc_interval_internal(stride, timestamp,
+												 tzp,
+												 origin);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
 /* timestamptz_trunc_zone()
  * Truncate timestamptz to specified units in specified timezone.
  */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 87d25d4a4b..e8aeb4801c 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -5663,6 +5663,36 @@
 { 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 => '8991',
+  descr => 'truncate timestamp with time zone to specified interval',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz', prosrc => 'timestamptz_trunc_interval' },
+{ oid => '8992',
+  descr => 'truncate timestamp with time zone to specified interval, in specified time zone',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz text',
+  prosrc => 'timestamptz_trunc_interval_zone' },
+
+{ oid => '8993',
+  descr => 'truncate timestamp with time zone to specified interval and origin',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz timestamptz', prosrc => 'timestamptz_trunc_interval_origin' },
+{ oid => '8994',
+  descr => 'truncate timestamp with time zone to specified interval and origin, in specified time zone',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz timestamptz text',
+  prosrc => 'timestamptz_trunc_interval_origin_zone' },
+
 { oid => '2021', descr => 'extract field from timestamp',
   proname => 'date_part', prorettype => 'float8',
   proargtypes => 'text timestamp', prosrc => 'timestamp_part' },
diff --git a/src/include/datatype/timestamp.h b/src/include/datatype/timestamp.h
index 6be6d35d1e..2f897b7575 100644
--- a/src/include/datatype/timestamp.h
+++ b/src/include/datatype/timestamp.h
@@ -93,6 +93,14 @@ typedef struct
 #define USECS_PER_MINUTE INT64CONST(60000000)
 #define USECS_PER_SEC	INT64CONST(1000000)
 
+/* compute total of non-month, non-year units in a pg_tm struct + fsec */
+#define Minor_Units(tm, usec) \
+	(tm.tm_mday - 1) * USECS_PER_DAY + \
+	tm.tm_hour * USECS_PER_HOUR + \
+	tm.tm_min * USECS_PER_MINUTE + \
+	tm.tm_sec * USECS_PER_SEC + \
+	usec
+
 /*
  * We allow numeric timezone offsets up to 15:59:59 either way from Greenwich.
  * Currently, the record holders for wackiest offsets in actual use are zones
diff --git a/src/test/regress/expected/timestamp.out b/src/test/regress/expected/timestamp.out
index 5f97505a30..2fd9deae62 100644
--- a/src/test/regress/expected/timestamp.out
+++ b/src/test/regress/expected/timestamp.out
@@ -545,6 +545,215 @@ 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)
+-- case 1: AD dates, origin < input
+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 '2020-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)
+
+-- case 2: BC dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_trunc_interval(interval::interval, ts, timestamp '2000-01-01 BC') 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 '0055-6-10 15:44:17.71393 BC') 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)
+
+-- case 3: AD dates, origin > input
+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 '1999-12-31 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)
+
+-- case 4: BC dates, origin > input
+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 '0055-6-07 15:44:17.71393 BC') 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)
+
+-- 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)
+
+SELECT date_trunc_interval('3 year', timestamp '2015-01-14 20:38:40', timestamp '2012-01-15 01:01:01.123');
+     date_trunc_interval      
+------------------------------
+ Sun Jan 15 01:01:01.123 2012
+(1 row)
+
+SELECT date_trunc_interval('3 year', timestamp '2015-01-15 20:38:40', timestamp '2012-01-15 01:01:01.123');
+     date_trunc_interval      
+------------------------------
+ Thu Jan 15 01:01:01.123 2015
+(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/expected/timestamptz.out b/src/test/regress/expected/timestamptz.out
index 639b50308e..c2f3a55e62 100644
--- a/src/test/regress/expected/timestamptz.out
+++ b/src/test/regress/expected/timestamptz.out
@@ -663,6 +663,155 @@ SELECT '' AS date_trunc_at_tz, date_trunc('day', timestamp with time zone '2001-
                   | Thu Feb 15 20:00:00 2001 PST
 (1 row)
 
+-- verify date_trunc_interval behaves the same as date_trunc (excluding decade)
+-- case 1: AD dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, 'Australia/Sydney') 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 timestamptz '2020-02-29 15:44:17.71393+00') 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)
+
+-- case 2: BC dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, timestamptz '2000-01-01+00 BC', 'Australia/Sydney') 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 timestamptz '0055-6-10 15:44:17.71393+00 BC') 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)
+
+-- case 3: AD dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, 'Australia/Sydney') 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 timestamptz '1999-12-31 15:44:17.71393+00') 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)
+
+-- case 4: BC dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, 'Australia/Sydney') 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 timestamptz '0055-6-07 15:44:17.71393+00 BC') 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)
+
 -- Test casting within a BETWEEN qualifier
 SELECT '' AS "54", d1 - timestamp with time zone '1997-01-02' AS diff
   FROM TIMESTAMPTZ_TBL
diff --git a/src/test/regress/sql/timestamp.sql b/src/test/regress/sql/timestamp.sql
index 7b58c3cfa5..d171bf95ec 100644
--- a/src/test/regress/sql/timestamp.sql
+++ b/src/test/regress/sql/timestamp.sql
@@ -166,6 +166,124 @@ 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)
+
+-- case 1: AD dates, origin < input
+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 '2020-02-29 15:44:17.71393') ts (ts);
+
+-- case 2: BC dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_trunc_interval(interval::interval, ts, timestamp '2000-01-01 BC') 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 '0055-6-10 15:44:17.71393 BC') ts (ts);
+
+-- case 3: AD dates, origin > input
+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 '1999-12-31 15:44:17.71393') ts (ts);
+
+-- case 4: BC dates, origin > input
+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 '0055-6-07 15:44:17.71393 BC') 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);
+
+-- 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');
+SELECT date_trunc_interval('3 year', timestamp '2015-01-14 20:38:40', timestamp '2012-01-15 01:01:01.123');
+SELECT date_trunc_interval('3 year', timestamp '2015-01-15 20:38:40', timestamp '2012-01-15 01:01:01.123');
+
+-- 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
diff --git a/src/test/regress/sql/timestamptz.sql b/src/test/regress/sql/timestamptz.sql
index 300302dafd..f25451fd0f 100644
--- a/src/test/regress/sql/timestamptz.sql
+++ b/src/test/regress/sql/timestamptz.sql
@@ -193,6 +193,96 @@ SELECT '' AS date_trunc_at_tz, date_trunc('day', timestamp with time zone '2001-
 SELECT '' AS date_trunc_at_tz, date_trunc('day', timestamp with time zone '2001-02-16 20:38:40+00', 'GMT') as gmt_trunc;  -- fixed-offset abbreviation
 SELECT '' AS date_trunc_at_tz, date_trunc('day', timestamp with time zone '2001-02-16 20:38:40+00', 'VET') as vet_trunc;  -- variable-offset abbreviation
 
+-- verify date_trunc_interval behaves the same as date_trunc (excluding decade)
+
+-- case 1: AD dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, 'Australia/Sydney') 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 timestamptz '2020-02-29 15:44:17.71393+00') ts (ts);
+
+-- case 2: BC dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, timestamptz '2000-01-01+00 BC', 'Australia/Sydney') 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 timestamptz '0055-6-10 15:44:17.71393+00 BC') ts (ts);
+
+-- case 3: AD dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, 'Australia/Sydney') 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 timestamptz '1999-12-31 15:44:17.71393+00') ts (ts);
+
+-- case 4: BC dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, 'Australia/Sydney') 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 timestamptz '0055-6-07 15:44:17.71393+00 BC') ts (ts);
+
 -- Test casting within a BETWEEN qualifier
 SELECT '' AS "54", d1 - timestamp with time zone '1997-01-02' AS diff
   FROM TIMESTAMPTZ_TBL
#17John Naylor
john.naylor@2ndquadrant.com
In reply to: John Naylor (#16)
1 attachment(s)
Re: truncating timestamps on arbitrary intervals

In v9, I've simplified the patch somewhat to make it easier for future
work to build on.

- When truncating on month-or-greater intervals, require the origin to
align on month. This removes the need to handle weird corner cases
that have no straightforward behavior.
- Remove hackish and possibly broken code to allow origin to be after
the input timestamp. The default origin is Jan 1, 1 AD, so only AD
dates will behave correctly by default. This is not enforced for now,
since it may be desirable to find a way to get this to work in a nicer
way.
- Rebase docs over PG13 formatting changes.

--
John Naylor https://www.2ndQuadrant.com/
PostgreSQL Development, 24x7 Support, Remote DBA, Training & Services

Attachments:

v9-datetrunc-interval.patchapplication/octet-stream; name=v9-datetrunc-interval.patchDownload
 doc/src/sgml/func.sgml                    |  85 ++++++-
 src/backend/utils/adt/timestamp.c         | 403 ++++++++++++++++++++++++++++++
 src/include/catalog/pg_proc.dat           |  30 +++
 src/test/regress/expected/timestamp.out   | 129 ++++++++++
 src/test/regress/expected/timestamptz.out |  38 +++
 src/test/regress/sql/timestamp.sql        |  73 ++++++
 src/test/regress/sql/timestamptz.sql      |  23 ++
 7 files changed, 780 insertions(+), 1 deletion(-)

diff --git a/doc/src/sgml/func.sgml b/doc/src/sgml/func.sgml
index 7119f0b2ca..b65921cc4b 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -8690,6 +8690,62 @@ SELECT regexp_match('abc01234xyz', '(?:(.*?)(\d+)(.*)){1,1}');
         </para></entry>
        </row>
 
+       <row>
+        <entry role="func_table_entry"><para role="func_signature">
+         <function>date_trunc_interval</function> ( <type>interval</type>, <type>timestamp</type> )
+         <returnvalue>timestamp</returnvalue>
+        </para>
+        <para>
+         Truncate to specified interval; see <xref linkend="functions-datetime-trunc"/>
+        </para>
+        <para>
+         <literal>date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40')</literal>
+         <returnvalue>2001-02-16 20:30:00</returnvalue>
+        </para></entry>
+       </row>
+
+       <row>
+        <entry role="func_table_entry"><para role="func_signature">
+         <function>date_trunc_interval</function> ( <type>interval</type>, <type>timestamp</type>, <type>timestamp</type> )
+         <returnvalue>timestamp</returnvalue>
+        </para>
+        <para>
+         Truncate to specified interval aligned with specified origin; see <xref linkend="functions-datetime-trunc"/>
+        </para>
+        <para>
+         <literal>date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40', timestamp '2001-02-16 20:05:00')</literal>
+         <returnvalue>2001-02-16 20:35:00</returnvalue>
+        </para></entry>
+       </row>
+
+       <row>
+        <entry role="func_table_entry"><para role="func_signature">
+         <function>date_trunc_interval</function> ( <type>interval</type>, <type>timestamp with time zone</type> <optional>, <type>text</type></optional> )
+         <returnvalue>timestamp with time zone</returnvalue>
+        </para>
+        <para>
+         Truncate to specified interval in the specified time zone; see <xref linkend="functions-datetime-trunc"/>
+        </para>
+        <para>
+         <literal>date_trunc_interval('6 hours', timestamptz '2001-02-16 20:38:40+00', 'Australia/Sydney')</literal>
+         <returnvalue>2001-02-16 19:00:00+00</returnvalue>
+        </para></entry>
+       </row>
+
+       <row>
+        <entry role="func_table_entry"><para role="func_signature">
+         <function>date_trunc_interval</function> ( <type>interval</type>, <type>timestamp with time zone</type>, <type>timestamp with time zone</type> <optional>, <type>text</type></optional> )
+         <returnvalue>timestamp with time zone</returnvalue>
+        </para>
+        <para>
+         Truncate to specified interval aligned with specified origin in the specified time zone; see <xref linkend="functions-datetime-trunc"/>
+        </para>
+        <para>
+         <literal>date_trunc_interval('6 hours', timestamptz '2001-02-16 20:38:40+00', timestamptz '2001-02-16 01:00:00+00', 'Australia/Sydney')</literal>
+         <returnvalue>2001-02-16 20:00:00+00</returnvalue>
+        </para></entry>
+       </row>
+
        <row>
         <entry role="func_table_entry"><para role="func_signature">
          <indexterm>
@@ -9641,7 +9697,7 @@ SELECT date_part('hour', INTERVAL '4 hours 3 minutes');
   </sect2>
 
   <sect2 id="functions-datetime-trunc">
-   <title><function>date_trunc</function></title>
+   <title><function>date_trunc</function>, <function>date_trunc_interval</function></title>
 
    <indexterm>
     <primary>date_trunc</primary>
@@ -9725,6 +9781,33 @@ SELECT date_trunc('hour', INTERVAL '3 days 02:47:33');
 <lineannotation>Result: </lineannotation><computeroutput>3 days 02:00:00</computeroutput>
 </screen>
    </para>
+
+   <para>
+    The function <function>date_trunc_interval</function> is 
+    similar to the <function>date_trunc</function>, except that it
+    truncates to an arbitrary interval.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 minutes', TIMESTAMP '2001-02-16 20:38:40');
+<lineannotation>Result: </lineannotation><computeroutput>2001-02-16 20:35:00</computeroutput>
+</screen>
+   </para>
+
+   <para>
+    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.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 years'::interval, TIMESTAMP '2020-02-01', TIMESTAMP '2012-01-01');
+<lineannotation>Result: </lineannotation><computeroutput>2017-01-01 00:00:00</computeroutput>
+</screen>
+   </para>
+
   </sect2>
 
   <sect2 id="functions-datetime-zoneconvert">
diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index 5fe304cea7..f83ffe0b2a 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -46,6 +46,13 @@
 
 #define SAMESIGN(a,b)	(((a) < 0) == ((b) < 0))
 
+/*
+ * The default origin for date_trunc_origin, which matches date_trunc as
+ * far as aligning on ISO weeks, centuries, and millenia.  The value is
+ * computed from the number of days before POSTGRES EPOCH DATE zero.
+ */
+#define DATE_0001_01_01 (-730119 * USECS_PER_DAY)
+
 /* Set at postmaster start */
 TimestampTz PgStartTime;
 
@@ -3797,6 +3804,133 @@ timestamptz_age(PG_FUNCTION_ARGS)
  *---------------------------------------------------------*/
 
 
+static Timestamp
+timestamp_trunc_interval_internal(Interval *stride,
+								  Timestamp timestamp,
+								  Timestamp origin)
+{
+	Timestamp	result,
+				tm_diff,
+				stride_usecs,
+				tm_delta;
+	fsec_t		ofsec,
+				tfsec;
+	int			yymm_result,
+				month_diff,
+				yymm_delta;
+
+	struct pg_tm ot, /* origin */
+				 tt, /* input */
+				 rt = {0}; /* result */
+
+	/*
+	 * For strides measured in days or smaller units, do one simple calculation
+	 * on the time in microseconds.
+	 */
+	if (stride->month == 0)
+	{
+		stride_usecs = stride->day * USECS_PER_DAY + stride->time;
+
+		/* trivial case of 1 usec */
+		if (stride_usecs == 1)
+			return timestamp;
+
+		tm_diff = timestamp - origin;
+		tm_delta = tm_diff - tm_diff % stride_usecs;;
+
+		result = origin + tm_delta;
+	}
+	else
+	{
+		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")));
+
+		/*
+		 * For strides measured in years and/or months, convert origin and
+		 * input timestamps to pg_tm.
+		 */
+		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")));
+
+		/* require origin to align on month */
+		if (ot.tm_mday != 1 ||
+			ot.tm_hour != 0 ||
+			ot.tm_min != 0 ||
+			ot.tm_sec != 0 ||
+			ofsec != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("when truncating on months or years, origin must be aligned on the month")));
+
+		/* compute difference in months */
+		month_diff = tt.tm_mon - ot.tm_mon;
+		yymm_delta = 12 * (tt.tm_year - ot.tm_year) + month_diff;
+		yymm_delta -= yymm_delta % stride->month;
+
+		/* compute fields of result pg_tm struct */
+		yymm_result = ot.tm_year * 12 + ot.tm_mon - 1 + yymm_delta;
+		rt.tm_year = yymm_result / 12;
+		rt.tm_mon = (yymm_result % 12) + 1;
+		rt.tm_mday = 1;
+
+		if (tm2timestamp(&rt, 0, 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);
+
+	result = timestamp_trunc_interval_internal(stride, timestamp,
+											   DATE_0001_01_01);
+
+	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.
  */
@@ -3931,6 +4065,108 @@ timestamp_trunc(PG_FUNCTION_ARGS)
 	PG_RETURN_TIMESTAMP(result);
 }
 
+/*
+ * Common code for timestamptz_trunc_interval*()
+ *
+ * tzp identifies the zone to truncate with respect to.  We assume
+ * infinite timestamps have already been rejected.
+ */
+static TimestampTz
+timestamptz_trunc_interval_internal(Interval *stride,
+									TimestampTz timestamp,
+									pg_tz *tzp,
+									TimestampTz origin)
+{
+	TimestampTz	result,
+				tm_diff,
+				stride_usecs,
+				tm_delta;
+	fsec_t		ofsec,
+				tfsec;
+	int			yymm_result,
+				month_diff,
+				yymm_delta,
+				tz;
+
+	struct pg_tm ot, /* origin */
+				 tt, /* input */
+				 rt = {0}; /* result */
+
+	/* Convert input to pg_tm with timezone. */
+	if (timestamp2tm(timestamp, &tz, &tt, &tfsec, NULL, tzp) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	/*
+	 * For strides measured in days or smaller units, do one simple calculation
+	 * on the time in microseconds.
+	 */
+	if (stride->month == 0)
+	{
+		stride_usecs = stride->day * USECS_PER_DAY + stride->time;
+
+		/* trivial case of 1 usec */
+		if (stride_usecs == 1)
+			return timestamp;
+
+		// Recovert to timestamp with null tz. See timestamp_zone().
+		if (tm2timestamp(&tt, tfsec, NULL, &timestamp) != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+					 errmsg("timestamp out of range")));
+
+		tm_diff = timestamp - origin;
+		tm_delta = tm_diff - tm_diff % stride_usecs;;
+
+		result = dt2local(origin + tm_delta, -tz);
+	}
+	else
+	{
+		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")));
+
+		/* don't apply timezone to the origin */
+		if (timestamp2tm(origin, NULL, &ot, &ofsec, NULL, NULL) != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+					 errmsg("timestamp out of range")));
+
+		/* require origin to align on month */
+		if (ot.tm_mday != 1 ||
+			ot.tm_hour != 0 ||
+			ot.tm_min != 0 ||
+			ot.tm_sec != 0 ||
+			ofsec != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("when truncating on months or years, origin must be aligned on the month")));
+
+		/* compute difference in months */
+		month_diff = tt.tm_mon - ot.tm_mon;
+		yymm_delta = 12 * (tt.tm_year - ot.tm_year) + month_diff;
+		yymm_delta -= yymm_delta % stride->month;
+
+		/* compute fields of result pg_tm struct */
+		yymm_result = ot.tm_year * 12 + ot.tm_mon - 1 + yymm_delta;
+		rt.tm_year = yymm_result / 12;
+		rt.tm_mon = (yymm_result % 12) + 1;
+		rt.tm_mday = 1;
+
+		/* write tz and dst to result struct */
+		tz = DetermineTimeZoneOffset(&rt, tzp);
+
+		if (tm2timestamp(&rt, 0, &tz, &result) != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+					 errmsg("timestamp out of range")));
+	}
+
+	return result;
+}
+
 /*
  * Common code for timestamptz_trunc() and timestamptz_trunc_zone().
  *
@@ -4078,6 +4314,52 @@ timestamptz_trunc_internal(text *units, TimestampTz timestamp, pg_tz *tzp)
 	return result;
 }
 
+/* timestamptz_trunc_interval()
+ * Truncate timestamptz to specified interval in session timezone.
+ */
+Datum
+timestamptz_trunc_interval(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	TimestampTz result;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMPTZ(timestamp);
+
+	result = timestamptz_trunc_interval_internal(stride, timestamp,
+												 session_timezone,
+												 DATE_0001_01_01);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
+/* timestamptz_trunc_interval_origin()
+ * Truncate timestamptz to specified interval in session timezone.
+ */
+Datum
+timestamptz_trunc_interval_origin(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	TimestampTz result;
+	TimestampTz	origin = PG_GETARG_TIMESTAMPTZ(2);
+
+	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 = timestamptz_trunc_interval_internal(stride, timestamp,
+												 session_timezone,
+												 origin);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
 /* timestamptz_trunc()
  * Truncate timestamptz to specified units in session timezone.
  */
@@ -4096,6 +4378,127 @@ timestamptz_trunc(PG_FUNCTION_ARGS)
 	PG_RETURN_TIMESTAMPTZ(result);
 }
 
+/* timestamptz_trunc_interval_zone()
+ * Truncate timestamptz to specified interval in specified timezone.
+ */
+Datum
+timestamptz_trunc_interval_zone(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	text	   *zone = PG_GETARG_TEXT_PP(2);
+	TimestampTz result;
+	char		tzname[TZ_STRLEN_MAX + 1];
+	char	   *lowzone;
+	int			type,
+				val;
+	pg_tz	   *tzp;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMPTZ(timestamp);
+
+	/*
+	 * Look up the requested timezone (see notes in timestamptz_zone()).
+	 */
+	text_to_cstring_buffer(zone, tzname, sizeof(tzname));
+
+	/* DecodeTimezoneAbbrev requires lowercase input */
+	lowzone = downcase_truncate_identifier(tzname,
+										   strlen(tzname),
+										   false);
+
+	type = DecodeTimezoneAbbrev(0, lowzone, &val, &tzp);
+
+	if (type == TZ || type == DTZ)
+	{
+		/* fixed-offset abbreviation, get a pg_tz descriptor for that */
+		tzp = pg_tzset_offset(-val);
+	}
+	else if (type == DYNTZ)
+	{
+		/* dynamic-offset abbreviation, use its referenced timezone */
+	}
+	else
+	{
+		/* try it as a full zone name */
+		tzp = pg_tzset(tzname);
+		if (!tzp)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("time zone \"%s\" not recognized", tzname)));
+	}
+
+	result = timestamptz_trunc_interval_internal(stride, timestamp,
+												 tzp,
+												 DATE_0001_01_01);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
+/* timestamptz_trunc_interval_origin_zone()
+ * Truncate timestamptz to specified interval in specified timezone,
+ * aligned to the specified origin.
+ */
+Datum
+timestamptz_trunc_interval_origin_zone(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	TimestampTz	origin = PG_GETARG_TIMESTAMPTZ(2);
+	text	   *zone = PG_GETARG_TEXT_PP(3);
+	TimestampTz result;
+	char		tzname[TZ_STRLEN_MAX + 1];
+	char	   *lowzone;
+	int			type,
+				val;
+	pg_tz	   *tzp;
+
+	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")));
+
+	/*
+	 * Look up the requested timezone (see notes in timestamptz_zone()).
+	 */
+	text_to_cstring_buffer(zone, tzname, sizeof(tzname));
+
+	/* DecodeTimezoneAbbrev requires lowercase input */
+	lowzone = downcase_truncate_identifier(tzname,
+										   strlen(tzname),
+										   false);
+
+	type = DecodeTimezoneAbbrev(0, lowzone, &val, &tzp);
+
+	if (type == TZ || type == DTZ)
+	{
+		/* fixed-offset abbreviation, get a pg_tz descriptor for that */
+		tzp = pg_tzset_offset(-val);
+	}
+	else if (type == DYNTZ)
+	{
+		/* dynamic-offset abbreviation, use its referenced timezone */
+	}
+	else
+	{
+		/* try it as a full zone name */
+		tzp = pg_tzset(tzname);
+		if (!tzp)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("time zone \"%s\" not recognized", tzname)));
+	}
+
+	result = timestamptz_trunc_interval_internal(stride, timestamp,
+												 tzp,
+												 origin);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
 /* timestamptz_trunc_zone()
  * Truncate timestamptz to specified units in specified timezone.
  */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 61f2c2f5b4..e59a3cefad 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -5717,6 +5717,36 @@
 { 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 => '8991',
+  descr => 'truncate timestamp with time zone to specified interval',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz', prosrc => 'timestamptz_trunc_interval' },
+{ oid => '8992',
+  descr => 'truncate timestamp with time zone to specified interval, in specified time zone',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz text',
+  prosrc => 'timestamptz_trunc_interval_zone' },
+
+{ oid => '8993',
+  descr => 'truncate timestamp with time zone to specified interval and origin',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz timestamptz', prosrc => 'timestamptz_trunc_interval_origin' },
+{ oid => '8994',
+  descr => 'truncate timestamp with time zone to specified interval and origin, in specified time zone',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz timestamptz text',
+  prosrc => 'timestamptz_trunc_interval_origin_zone' },
+
 { 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..d8a1952582 100644
--- a/src/test/regress/expected/timestamp.out
+++ b/src/test/regress/expected/timestamp.out
@@ -545,6 +545,135 @@ 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)
+-- case 1: AD dates, origin < input
+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 '2020-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)
+
+-- case 2: BC dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_trunc_interval(interval::interval, ts, timestamp '2000-01-01 BC') 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 '0055-6-10 15:44:17.71393 BC') 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        | Wed Jan 01 00:00:00 2020
+ 18 months        | Wed Jan 01 00:00:00 2020
+ 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)
+
+-- 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)
+
+SELECT date_trunc_interval('3 year', timestamp '2015-01-14 20:38:40', timestamp '2012-01-01');
+   date_trunc_interval    
+--------------------------
+ Thu Jan 01 00:00:00 2015
+(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/expected/timestamptz.out b/src/test/regress/expected/timestamptz.out
index 639b50308e..6a3bd5745d 100644
--- a/src/test/regress/expected/timestamptz.out
+++ b/src/test/regress/expected/timestamptz.out
@@ -663,6 +663,44 @@ SELECT '' AS date_trunc_at_tz, date_trunc('day', timestamp with time zone '2001-
                   | Thu Feb 15 20:00:00 2001 PST
 (1 row)
 
+-- verify date_trunc_interval behaves the same as date_trunc for relevant
+-- inputs (excluding decade)
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, 'Australia/Sydney') 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 timestamptz '2020-02-29 15:44:17.71393+00') 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)
+
 -- Test casting within a BETWEEN qualifier
 SELECT '' AS "54", d1 - timestamp with time zone '1997-01-02' AS diff
   FROM TIMESTAMPTZ_TBL
diff --git a/src/test/regress/sql/timestamp.sql b/src/test/regress/sql/timestamp.sql
index 7b58c3cfa5..294ed85949 100644
--- a/src/test/regress/sql/timestamp.sql
+++ b/src/test/regress/sql/timestamp.sql
@@ -166,6 +166,79 @@ 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)
+
+-- case 1: AD dates, origin < input
+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 '2020-02-29 15:44:17.71393') ts (ts);
+
+-- case 2: BC dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_trunc_interval(interval::interval, ts, timestamp '2000-01-01 BC') 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 '0055-6-10 15:44:17.71393 BC') 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);
+
+-- 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');
+SELECT date_trunc_interval('3 year', timestamp '2015-01-14 20:38:40', 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
diff --git a/src/test/regress/sql/timestamptz.sql b/src/test/regress/sql/timestamptz.sql
index 300302dafd..3fa95eea8f 100644
--- a/src/test/regress/sql/timestamptz.sql
+++ b/src/test/regress/sql/timestamptz.sql
@@ -193,6 +193,29 @@ SELECT '' AS date_trunc_at_tz, date_trunc('day', timestamp with time zone '2001-
 SELECT '' AS date_trunc_at_tz, date_trunc('day', timestamp with time zone '2001-02-16 20:38:40+00', 'GMT') as gmt_trunc;  -- fixed-offset abbreviation
 SELECT '' AS date_trunc_at_tz, date_trunc('day', timestamp with time zone '2001-02-16 20:38:40+00', 'VET') as vet_trunc;  -- variable-offset abbreviation
 
+-- verify date_trunc_interval behaves the same as date_trunc for relevant
+-- inputs (excluding decade)
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, 'Australia/Sydney') 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 timestamptz '2020-02-29 15:44:17.71393+00') ts (ts);
+
 -- Test casting within a BETWEEN qualifier
 SELECT '' AS "54", d1 - timestamp with time zone '1997-01-02' AS diff
   FROM TIMESTAMPTZ_TBL
#18Peter Eisentraut
peter.eisentraut@enterprisedb.com
In reply to: John Naylor (#17)
Re: truncating timestamps on arbitrary intervals

On 2020-06-30 06:34, John Naylor wrote:

In v9, I've simplified the patch somewhat to make it easier for future
work to build on.

- When truncating on month-or-greater intervals, require the origin to
align on month. This removes the need to handle weird corner cases
that have no straightforward behavior.
- Remove hackish and possibly broken code to allow origin to be after
the input timestamp. The default origin is Jan 1, 1 AD, so only AD
dates will behave correctly by default. This is not enforced for now,
since it may be desirable to find a way to get this to work in a nicer
way.
- Rebase docs over PG13 formatting changes.

This looks pretty solid now. Are there any more corner cases and other
areas with unclear behavior that you are aware of?

A couple of thoughts:

- After reading the discussion a few times, I'm not so sure anymore
whether making this a cousin of date_trunc is the right way to go. As
you mentioned, there are some behaviors specific to date_trunc that
don't really make sense in date_trunc_interval, and maybe we'll have
more of those. Also, date_trunc_interval isn't exactly a handy name.
Maybe something to think about. It's obviously fairly straightforward
to change it.

- There were various issues with the stride interval having months and
years. I'm not sure we even need that. It could be omitted unless you
are confident that your implementation is now sufficient.

- Also, negative intervals could be prohibited, but I suppose that
matters less.

- I'm curious about the origin being set to 0001-01-01. This seems to
work correctly in that it sets the origin to a Monday, which is what we
wanted, but according to Google that day was a Saturday. Something to
do with Julian vs. Gregorian calendar? Maybe we should choose a date
that is a bit more recent and easier to reason with.

- Then again, I'm thinking that maybe we should make the origin
mandatory. Otherwise, the default answers when having strides larger
than a day are entirely arbitrary, e.g.,

=> select date_trunc_interval('10 year', '0196-05-20 BC'::timestamp);
0190-01-01 00:00:00 BC

=> select date_trunc_interval('10 year', '0196-05-20 AD'::timestamp);
0191-01-01 00:00:00

Perhaps the origin could be defaulted if the interval is less than a day
or something like that.

- I'm wondering whether we need the date_trunc_interval(interval,
timestamptz, timezone) variant. Isn't that the same as
date_trunc_interval(foo AT ZONE xyz, value)?

#19John Naylor
john.naylor@enterprisedb.com
In reply to: Peter Eisentraut (#18)
Re: truncating timestamps on arbitrary intervals

On Thu, Nov 12, 2020 at 9:56 AM Peter Eisentraut <
peter.eisentraut@enterprisedb.com> wrote:

On 2020-06-30 06:34, John Naylor wrote:

In v9, I've simplified the patch somewhat to make it easier for future
work to build on.

- When truncating on month-or-greater intervals, require the origin to
align on month. This removes the need to handle weird corner cases
that have no straightforward behavior.
- Remove hackish and possibly broken code to allow origin to be after
the input timestamp. The default origin is Jan 1, 1 AD, so only AD
dates will behave correctly by default. This is not enforced for now,
since it may be desirable to find a way to get this to work in a nicer
way.
- Rebase docs over PG13 formatting changes.

This looks pretty solid now. Are there any more corner cases and other
areas with unclear behavior that you are aware of?

Hi Peter,

Thanks for taking a look!

I believe there are no known corner cases aside from not throwing an error
if origin > input, but I'll revisit that when we are more firm on what
features we want support.

A couple of thoughts:

- After reading the discussion a few times, I'm not so sure anymore
whether making this a cousin of date_trunc is the right way to go. As
you mentioned, there are some behaviors specific to date_trunc that
don't really make sense in date_trunc_interval, and maybe we'll have
more of those.

As far as the behaviors, I'm not sure exactly what you what you were
thinking of, but here are two issues off the top of my head:

- If the new functions are considered variants of date_trunc(), there is
the expectation that the options work the same way, in particular the
timezone parameter. You asked specifically about that below, so I'll
address that separately.
- In the "week" case, the boundary position depends on the origin, since a
week-long interval is just 7 days.

Also, date_trunc_interval isn't exactly a handy name.
Maybe something to think about. It's obviously fairly straightforward
to change it.

Effectively, it puts timestamps into bins, so maybe date_bin() or something
like that?

- There were various issues with the stride interval having months and
years. I'm not sure we even need that. It could be omitted unless you
are confident that your implementation is now sufficient.

Months and years were a bit tricky, so I'd be happy to leave that out if
there is not much demand for it. date_trunc() already has quarters,
decades, centuries, and millenia.

- Also, negative intervals could be prohibited, but I suppose that
matters less.

Good for the sake of completeness. I think they happen to work in v9 by
accident, but it would be better not to expose that.

- I'm curious about the origin being set to 0001-01-01. This seems to
work correctly in that it sets the origin to a Monday, which is what we
wanted, but according to Google that day was a Saturday. Something to
do with Julian vs. Gregorian calendar?

Right, working backwards from our calendar today, it's Monday, but at the
time it would theoretically be Saturday, barring leap year miscalculations.

Maybe we should choose a date
that is a bit more recent and easier to reason with.

2001-01-01 would also be a Monday aligned with centuries and millenia, so
that would be my next suggestion. If we don't care to match with
date_trunc() on those larger units, we could also use 1900-01-01, so the
vast majority of dates in databases are after the origin.

- Then again, I'm thinking that maybe we should make the origin
mandatory. Otherwise, the default answers when having strides larger
than a day are entirely arbitrary, e.g.,

=> select date_trunc_interval('10 year', '0196-05-20 BC'::timestamp);
0190-01-01 00:00:00 BC

=> select date_trunc_interval('10 year', '0196-05-20 AD'::timestamp);
0191-01-01 00:00:00

Right. In the first case, the default origin is also after the input, and
crosses the AD/BC boundary. Tricky to get right.

Perhaps the origin could be defaulted if the interval is less than a day
or something like that.

If we didn't allow months and years to be units, it seems the default would
always make sense?

- I'm wondering whether we need the date_trunc_interval(interval,
timestamptz, timezone) variant. Isn't that the same as
date_trunc_interval(foo AT ZONE xyz, value)?

I based this on 600b04d6b5ef6 for date_trunc(), whose message states:

date_trunc(field, timestamptz, zone_name)

is the same as

date_trunc(field, timestamptz at time zone zone_name) at time zone zone_name

so without the shorthand, you need to specify the timezone twice, once for
the calculation, and once for the output.

--
John Naylor
EnterpriseDB: http://www.enterprisedb.com
The Enterprise PostgreSQL Company

#20John Naylor
john.naylor@enterprisedb.com
In reply to: John Naylor (#19)
1 attachment(s)
Re: truncating timestamps on arbitrary intervals

On Mon, Nov 23, 2020 at 1:44 PM John Naylor <john.naylor@enterprisedb.com>
wrote:

On Thu, Nov 12, 2020 at 9:56 AM Peter Eisentraut <

peter.eisentraut@enterprisedb.com> wrote:

- After reading the discussion a few times, I'm not so sure anymore
whether making this a cousin of date_trunc is the right way to go. As
you mentioned, there are some behaviors specific to date_trunc that
don't really make sense in date_trunc_interval, and maybe we'll have
more of those.

For v10, I simplified the behavior by decoupling the behavior from
date_trunc() and putting in some restrictions as discussed earlier. It's
much simpler now. It could be argued that it goes too far in that
direction, but it's easy to reason about and we can put back some features
as we see fit.

Also, date_trunc_interval isn't exactly a handy name.
Maybe something to think about. It's obviously fairly straightforward
to change it.

Effectively, it puts timestamps into bins, so maybe date_bin() or

something like that?

For v10 I went with date_bin() so we can see how that looks.

- There were various issues with the stride interval having months and
years. I'm not sure we even need that. It could be omitted unless you
are confident that your implementation is now sufficient.

Months and years were a bit tricky, so I'd be happy to leave that out if

there is not much demand for it. date_trunc() already has quarters,
decades, centuries, and millenia.

I removed months and years for this version, but that can be reconsidered
of course. The logic is really simple now.

- Also, negative intervals could be prohibited, but I suppose that
matters less.

I didn't go this far, but probably should before long.

- Then again, I'm thinking that maybe we should make the origin
mandatory. Otherwise, the default answers when having strides larger
than a day are entirely arbitrary, e.g.,

I've tried this and like the resulting simplification.

- I'm wondering whether we need the date_trunc_interval(interval,
timestamptz, timezone) variant. Isn't that the same as
date_trunc_interval(foo AT ZONE xyz, value)?

I based this on 600b04d6b5ef6 for date_trunc(), whose message states:

date_trunc(field, timestamptz, zone_name)

is the same as

date_trunc(field, timestamptz at time zone zone_name) at time zone

zone_name

so without the shorthand, you need to specify the timezone twice, once

for the calculation, and once for the output.

In light of making the origin mandatory, it no longer makes sense to have a
time zone parameter, since we can specify the time zone on the origin; and
if desired on the output as well.

--
John Naylor
EDB: http://www.enterprisedb.com

Attachments:

v10-datetrunc-interval.patchapplication/octet-stream; name=v10-datetrunc-interval.patchDownload
 doc/src/sgml/func.sgml                    | 14 +++++
 src/backend/utils/adt/timestamp.c         | 82 ++++++++++++++++++++++++++++
 src/include/catalog/pg_proc.dat           | 11 ++++
 src/test/regress/expected/timestamp.out   | 91 +++++++++++++++++++++++++++++++
 src/test/regress/expected/timestamptz.out | 30 ++++++++++
 src/test/regress/sql/timestamp.sql        | 58 ++++++++++++++++++++
 src/test/regress/sql/timestamptz.sql      | 20 +++++++
 7 files changed, 306 insertions(+)

diff --git a/doc/src/sgml/func.sgml b/doc/src/sgml/func.sgml
index 02a37658ad..ff3fae9acd 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -8715,6 +8715,20 @@ SELECT regexp_match('abc01234xyz', '(?:(.*?)(\d+)(.*)){1,1}');
         </para></entry>
        </row>
 
+       <row>
+        <entry role="func_table_entry"><para role="func_signature">
+         <function>date_bin</function> ( <type>interval</type>, <type>timestamp</type>, <type>timestamp</type> )
+         <returnvalue>timestamp</returnvalue>
+        </para>
+        <para>
+         Bin input into specified interval aligned with specified origin; see <xref linkend="functions-datetime-trunc"/>
+        </para>
+        <para>
+         <literal>date_bin('15 minutes', timestamp '2001-02-16 20:38:40', timestamp '2001-02-16 20:05:00')</literal>
+         <returnvalue>2001-02-16 20:35:00</returnvalue>
+        </para></entry>
+       </row>
+
        <row>
         <entry role="func_table_entry"><para role="func_signature">
          <indexterm>
diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index 0b1f95a5b4..19458b1859 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -3812,6 +3812,47 @@ timestamptz_age(PG_FUNCTION_ARGS)
  *---------------------------------------------------------*/
 
 
+/* timestamp_bin()
+ * Bin timestamp into specified interval.
+ */
+Datum
+timestamp_bin(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	Timestamp	timestamp = PG_GETARG_TIMESTAMP(1);
+	Timestamp	origin = PG_GETARG_TIMESTAMP(2);
+	Timestamp	result,
+				tm_diff,
+				stride_usecs,
+				tm_delta;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMP(timestamp);
+
+	if (TIMESTAMP_NOT_FINITE(origin))
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("origin out of range")));
+
+	if (stride->month != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("timestamps cannot be binned into intervals containing months or years")));
+
+	stride_usecs = stride->day * USECS_PER_DAY + stride->time;
+
+	/* trivial case of 1 usec */
+	if (stride_usecs == 1)
+		PG_RETURN_TIMESTAMP(timestamp);
+
+	tm_diff = timestamp - origin;
+	tm_delta = tm_diff - tm_diff % stride_usecs;;
+
+	result = origin + tm_delta;
+
+	PG_RETURN_TIMESTAMP(result);
+}
+
 /* timestamp_trunc()
  * Truncate timestamp to specified units.
  */
@@ -3946,6 +3987,47 @@ timestamp_trunc(PG_FUNCTION_ARGS)
 	PG_RETURN_TIMESTAMP(result);
 }
 
+/* timestamptz_bin()
+ * Bin timestamptz into specified interval using specified origin.
+ */
+Datum
+timestamptz_bin(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	TimestampTz	origin = PG_GETARG_TIMESTAMPTZ(2);
+	TimestampTz	result,
+				stride_usecs,
+				tm_diff,
+				tm_delta;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		return timestamp;
+
+	if (TIMESTAMP_NOT_FINITE(origin))
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("origin out of range")));
+
+	if (stride->month != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("timestamps cannot be binned into intervals containing months or years")));
+
+	stride_usecs = stride->day * USECS_PER_DAY + stride->time;
+
+	/* trivial case of 1 usec */
+	if (stride_usecs == 1)
+		return timestamp;
+
+	tm_diff = timestamp - origin;
+	tm_delta = tm_diff - tm_diff % stride_usecs;;
+
+	result = origin + tm_delta;
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
 /*
  * Common code for timestamptz_trunc() and timestamptz_trunc_zone().
  *
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index d7b55f57ea..522a56e0a4 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -5755,6 +5755,17 @@
 { oid => '2020', descr => 'truncate timestamp to specified units',
   proname => 'date_trunc', prorettype => 'timestamp',
   proargtypes => 'text timestamp', prosrc => 'timestamp_trunc' },
+
+{ oid => '8990',
+  descr => 'bin timestamp into specified interval',
+  proname => 'date_bin', prorettype => 'timestamp',
+  proargtypes => 'interval timestamp timestamp',
+  prosrc => 'timestamp_bin' },
+{ oid => '8993',
+  descr => 'bin timestamp with time zone into specified interval',
+  proname => 'date_bin', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz timestamptz', prosrc => 'timestamptz_bin' },
+
 { 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 ebaf286201..2efec548e6 100644
--- a/src/test/regress/expected/timestamp.out
+++ b/src/test/regress/expected/timestamp.out
@@ -545,6 +545,97 @@ SELECT date_trunc( 'week', timestamp '2004-02-29 15:44:17.71393' ) AS week_trunc
  Mon Feb 23 00:00:00 2004
 (1 row)
 
+-- verify date_bin behaves the same as date_trunc for relevant intervals
+-- case 1: AD dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_bin(interval::interval, ts, timestamp '2001-01-01') AS equal
+FROM (
+  VALUES
+  ('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 '2020-02-29 15:44:17.71393') ts (ts);
+     str     | interval | equal 
+-------------+----------+-------
+ 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
+(7 rows)
+
+-- case 2: BC dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_bin(interval::interval, ts, timestamp '2000-01-01 BC') AS equal
+FROM (
+  VALUES
+  ('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 '0055-6-10 15:44:17.71393 BC') ts (ts);
+     str     | interval | equal 
+-------------+----------+-------
+ 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
+(7 rows)
+
+-- bin timestamps into arbitrary intervals
+SELECT
+  interval,
+  date_bin(interval::interval, ts, timestamp '2001-01-01')
+FROM (
+  VALUES
+  ('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_bin            
+------------------+--------------------------------
+ 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
+(6 rows)
+
+-- shift bins using the origin parameter:
+SELECT date_bin('5 min'::interval, timestamp '2020-02-01 01:01:01', timestamp '2020-02-01 00:02:30');
+         date_bin         
+--------------------------
+ Sat Feb 01 00:57:30 2020
+(1 row)
+
+-- disallow intervals with months or years
+SELECT date_bin('5 months'::interval, timestamp '2020-02-01 01:01:01', timestamp '2001-01-01');
+ERROR:  timestamps cannot be binned into intervals containing months or years
+SELECT date_bin('5 years'::interval,  timestamp '2020-02-01 01:01:01', timestamp '2001-01-01');
+ERROR:  timestamps cannot be binned into intervals containing months or years
 -- Test casting within a BETWEEN qualifier
 SELECT d1 - timestamp without time zone '1997-01-02' AS diff
   FROM TIMESTAMP_TBL
diff --git a/src/test/regress/expected/timestamptz.out b/src/test/regress/expected/timestamptz.out
index 47f658511d..44f77907df 100644
--- a/src/test/regress/expected/timestamptz.out
+++ b/src/test/regress/expected/timestamptz.out
@@ -663,6 +663,36 @@ SELECT date_trunc('day', timestamp with time zone '2001-02-16 20:38:40+00', 'VET
  Thu Feb 15 20:00:00 2001 PST
 (1 row)
 
+-- verify date_bin behaves the same as date_trunc for relevant intervals
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_bin(interval::interval, ts, timestamp with time zone '2001-01-01+11') AS equal
+FROM (
+  VALUES
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamptz '2020-02-29 15:44:17.71393+00') ts (ts);
+     str     | interval | equal 
+-------------+----------+-------
+ day         | 1 d      | t
+ hour        | 1 h      | t
+ minute      | 1 m      | t
+ second      | 1 s      | t
+ millisecond | 1 ms     | t
+ microsecond | 1 us     | t
+(6 rows)
+
+-- disallow intervals with months or years
+SELECT date_bin('5 months'::interval, timestamp with time zone '2020-02-01 01:01:01+00', timestamp with time zone '2001-01-01+00');
+ERROR:  timestamps cannot be binned into intervals containing months or years
+SELECT date_bin('5 years'::interval,  timestamp with time zone '2020-02-01 01:01:01+00', timestamp with time zone '2001-01-01+00');
+ERROR:  timestamps cannot be binned into intervals containing months or years
 -- Test casting within a BETWEEN qualifier
 SELECT d1 - timestamp with time zone '1997-01-02' AS diff
   FROM TIMESTAMPTZ_TBL
diff --git a/src/test/regress/sql/timestamp.sql b/src/test/regress/sql/timestamp.sql
index 07f984389d..08bff3613b 100644
--- a/src/test/regress/sql/timestamp.sql
+++ b/src/test/regress/sql/timestamp.sql
@@ -166,6 +166,64 @@ SELECT d1 - timestamp without time zone '1997-01-02' AS diff
 
 SELECT date_trunc( 'week', timestamp '2004-02-29 15:44:17.71393' ) AS week_trunc;
 
+-- verify date_bin behaves the same as date_trunc for relevant intervals
+
+-- case 1: AD dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_bin(interval::interval, ts, timestamp '2001-01-01') AS equal
+FROM (
+  VALUES
+  ('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 '2020-02-29 15:44:17.71393') ts (ts);
+
+-- case 2: BC dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_bin(interval::interval, ts, timestamp '2000-01-01 BC') AS equal
+FROM (
+  VALUES
+  ('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 '0055-6-10 15:44:17.71393 BC') ts (ts);
+
+-- bin timestamps into arbitrary intervals
+SELECT
+  interval,
+  date_bin(interval::interval, ts, timestamp '2001-01-01')
+FROM (
+  VALUES
+  ('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);
+
+-- shift bins using the origin parameter:
+SELECT date_bin('5 min'::interval, timestamp '2020-02-01 01:01:01', timestamp '2020-02-01 00:02:30');
+
+-- disallow intervals with months or years
+SELECT date_bin('5 months'::interval, timestamp '2020-02-01 01:01:01', timestamp '2001-01-01');
+SELECT date_bin('5 years'::interval,  timestamp '2020-02-01 01:01:01', timestamp '2001-01-01');
+
 -- Test casting within a BETWEEN qualifier
 SELECT d1 - timestamp without time zone '1997-01-02' AS diff
   FROM TIMESTAMP_TBL
diff --git a/src/test/regress/sql/timestamptz.sql b/src/test/regress/sql/timestamptz.sql
index 2231495e21..fd982c3e25 100644
--- a/src/test/regress/sql/timestamptz.sql
+++ b/src/test/regress/sql/timestamptz.sql
@@ -193,6 +193,26 @@ SELECT date_trunc('day', timestamp with time zone '2001-02-16 20:38:40+00', 'Aus
 SELECT date_trunc('day', timestamp with time zone '2001-02-16 20:38:40+00', 'GMT') as gmt_trunc;  -- fixed-offset abbreviation
 SELECT date_trunc('day', timestamp with time zone '2001-02-16 20:38:40+00', 'VET') as vet_trunc;  -- variable-offset abbreviation
 
+-- verify date_bin behaves the same as date_trunc for relevant intervals
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_bin(interval::interval, ts, timestamp with time zone '2001-01-01+11') AS equal
+FROM (
+  VALUES
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamptz '2020-02-29 15:44:17.71393+00') ts (ts);
+
+-- disallow intervals with months or years
+SELECT date_bin('5 months'::interval, timestamp with time zone '2020-02-01 01:01:01+00', timestamp with time zone '2001-01-01+00');
+SELECT date_bin('5 years'::interval,  timestamp with time zone '2020-02-01 01:01:01+00', timestamp with time zone '2001-01-01+00');
+
 -- Test casting within a BETWEEN qualifier
 SELECT d1 - timestamp with time zone '1997-01-02' AS diff
   FROM TIMESTAMPTZ_TBL
#21David Steele
david@pgmasters.net
In reply to: John Naylor (#20)
Re: truncating timestamps on arbitrary intervals

On 1/18/21 3:54 PM, John Naylor wrote:

On Mon, Nov 23, 2020 at 1:44 PM John Naylor
<john.naylor@enterprisedb.com <mailto:john.naylor@enterprisedb.com>> wrote:

On Thu, Nov 12, 2020 at 9:56 AM Peter Eisentraut

<peter.eisentraut@enterprisedb.com
<mailto:peter.eisentraut@enterprisedb.com>> wrote:

- After reading the discussion a few times, I'm not so sure anymore
whether making this a cousin of date_trunc is the right way to go.  As
you mentioned, there are some behaviors specific to date_trunc that
don't really make sense in date_trunc_interval, and maybe we'll have
more of those.

For v10, I simplified the behavior by decoupling the behavior from
date_trunc() and putting in some restrictions as discussed earlier. It's
much simpler now. It could be argued that it goes too far in that
direction, but it's easy to reason about and we can put back some
features as we see fit.

Peter, thoughts on the new patch?

Regards,
--
-David
david@pgmasters.net

#22Peter Eisentraut
peter.eisentraut@enterprisedb.com
In reply to: John Naylor (#20)
Re: truncating timestamps on arbitrary intervals

On 18.01.21 21:54, John Naylor wrote:

On Mon, Nov 23, 2020 at 1:44 PM John Naylor
<john.naylor@enterprisedb.com <mailto:john.naylor@enterprisedb.com>> wrote:

On Thu, Nov 12, 2020 at 9:56 AM Peter Eisentraut

<peter.eisentraut@enterprisedb.com
<mailto:peter.eisentraut@enterprisedb.com>> wrote:

- After reading the discussion a few times, I'm not so sure anymore
whether making this a cousin of date_trunc is the right way to go.  As
you mentioned, there are some behaviors specific to date_trunc that
don't really make sense in date_trunc_interval, and maybe we'll have
more of those.

For v10, I simplified the behavior by decoupling the behavior from
date_trunc() and putting in some restrictions as discussed earlier. It's
much simpler now. It could be argued that it goes too far in that
direction, but it's easy to reason about and we can put back some
features as we see fit.

Committed.

I noticed that some of the documentation disappeared between v9 and v10.
So I put that back and updated it appropriately. I also added a few
more test cases to cover some things that have been discussed during the
course of this thread.

As a potential follow-up, should we perhaps add named arguments? That
might make the invocations easier to read, depending on taste.

#23Erik Rijkers
er@xs4all.nl
In reply to: Peter Eisentraut (#22)
1 attachment(s)
Re: truncating timestamps on arbitrary intervals

On 2021.03.24. 16:38 Peter Eisentraut <peter.eisentraut@enterprisedb.com> wrote:

Committed.

'In cases full units' seems strange.

Not a native speaker but maybe the attached changes are improvements?

Erik Rijkers

Attachments:

func.sgml.20210324.difftext/x-patch; charset=ISO-8859-1; name=func.sgml.20210324.diffDownload
--- ./doc/src/sgml/func.sgml.orig	2021-03-24 18:16:01.269515354 +0100
+++ ./doc/src/sgml/func.sgml	2021-03-24 18:18:31.695819520 +0100
@@ -9907,13 +9907,13 @@
    </para>
 
    <para>
-    In cases full units (1 minute, 1 hour, etc.), it gives the same result as
+    In case of full units (1 minute, 1 hour, etc.), it gives the same result as
     the analogous <function>date_trunc</function> call, but the difference is
     that <function>date_bin</function> can truncate to an arbitrary interval.
    </para>
 
    <para>
-    The <parameter>stride</parameter> interval cannot contain units of month
+    The <parameter>stride</parameter> interval cannot contain units of a month
     or larger.
    </para>
   </sect2>
#24John Naylor
john.naylor@enterprisedb.com
In reply to: Peter Eisentraut (#22)
Re: truncating timestamps on arbitrary intervals

On Wed, Mar 24, 2021 at 11:38 AM Peter Eisentraut <
peter.eisentraut@enterprisedb.com> wrote:

Committed.

I noticed that some of the documentation disappeared between v9 and v10.
So I put that back and updated it appropriately. I also added a few
more test cases to cover some things that have been discussed during the
course of this thread.

Thanks! I put off updating the documentation in case the latest approach
was not feature-rich enough.

As a potential follow-up, should we perhaps add named arguments? That
might make the invocations easier to read, depending on taste.

I think it's quite possible some users will prefer that. All we need is to
add something like

proargnames => '{bin_width,input,origin}'

to the catalog, right?

Also, I noticed that I put in double semicolons in the new functions
somehow. I'll fix that as well.

--
John Naylor
EDB: http://www.enterprisedb.com

#25John Naylor
john.naylor@enterprisedb.com
In reply to: Erik Rijkers (#23)
Re: truncating timestamps on arbitrary intervals

On Wed, Mar 24, 2021 at 1:25 PM Erik Rijkers <er@xs4all.nl> wrote:

'In cases full units' seems strange.

Not a native speaker but maybe the attached changes are improvements?

-    In cases full units (1 minute, 1 hour, etc.), it gives the same result
as
+    In case of full units (1 minute, 1 hour, etc.), it gives the same
result as
     the analogous <function>date_trunc</function> call, but the difference
is
     that <function>date_bin</function> can truncate to an arbitrary
interval.
    </para>

I would say "In the case of"

    <para>
-    The <parameter>stride</parameter> interval cannot contain units of
month
+    The <parameter>stride</parameter> interval cannot contain units of a
month
     or larger.

The original seems fine to me here.

--
John Naylor
EDB: http://www.enterprisedb.com

#26Peter Eisentraut
peter.eisentraut@enterprisedb.com
In reply to: Erik Rijkers (#23)
Re: truncating timestamps on arbitrary intervals

On 24.03.21 18:25, Erik Rijkers wrote:

On 2021.03.24. 16:38 Peter Eisentraut <peter.eisentraut@enterprisedb.com> wrote:

Committed.

'In cases full units' seems strange.

fixed, thanks

#27Peter Eisentraut
peter.eisentraut@enterprisedb.com
In reply to: John Naylor (#24)
Re: truncating timestamps on arbitrary intervals

On 24.03.21 18:58, John Naylor wrote:

As a potential follow-up, should we perhaps add named arguments?  That
might make the invocations easier to read, depending on taste.

I think it's quite possible some users will prefer that. All we need is
to add something like

proargnames => '{bin_width,input,origin}'

to the catalog, right?

right, plus some documentation adjustments perhaps

Also, I noticed that I put in double semicolons in the new functions
somehow. I'll fix that as well.

I have fixed that.

#28Justin Pryzby
pryzby@telsasoft.com
In reply to: Peter Eisentraut (#27)
Re: truncating timestamps on arbitrary intervals

On Wed, Mar 24, 2021 at 08:50:59PM +0100, Peter Eisentraut wrote:

On 24.03.21 18:58, John Naylor wrote:

As a potential follow-up, should we perhaps add named arguments?� That
might make the invocations easier to read, depending on taste.

I think it's quite possible some users will prefer that. All we need is
to add something like

proargnames => '{bin_width,input,origin}'

to the catalog, right?

right, plus some documentation adjustments perhaps

+1

The current docs seem to be missing a "synopsis", like

+<synopsis>
+date_trunc(<replaceable>stride</replaceable>, <replaceable>timestamp</replaceable>, <replaceable>origin</replaceable>)
+</synopsis>

--
Justin

#29John Naylor
john.naylor@enterprisedb.com
In reply to: Peter Eisentraut (#27)
1 attachment(s)
Re: truncating timestamps on arbitrary intervals

Currently, when the origin is after the input, the result is the timestamp
at the end of the bin, rather than the beginning as expected. The attached
puts the result consistently at the beginning of the bin.

--
John Naylor
EDB: http://www.enterprisedb.com

Attachments:

rationalize-future-origin.patchapplication/octet-stream; name=rationalize-future-origin.patchDownload
 src/backend/utils/adt/timestamp.c       | 14 +++++++++
 src/test/regress/expected/timestamp.out | 54 +++++++++++++++++++++++++++++++++
 src/test/regress/sql/timestamp.sql      | 34 +++++++++++++++++++++
 3 files changed, 102 insertions(+)

diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index f619b56d6f..0fc468404f 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -3844,6 +3844,13 @@ timestamp_bin(PG_FUNCTION_ARGS)
 	tm_diff = timestamp - origin;
 	tm_delta = tm_diff - tm_diff % stride_usecs;
 
+	/*
+	 * Make sure the returned timestamp is at the start of the bin,
+	 * even if the origin is in the future.
+	 */
+	if (origin > timestamp && stride_usecs > 1)
+		tm_delta -= stride_usecs;
+
 	result = origin + tm_delta;
 
 	PG_RETURN_TIMESTAMP(result);
@@ -4015,6 +4022,13 @@ timestamptz_bin(PG_FUNCTION_ARGS)
 	tm_diff = timestamp - origin;
 	tm_delta = tm_diff - tm_diff % stride_usecs;
 
+	/*
+	 * Make sure the returned timestamp is at the start of the bin,
+	 * even if the origin is in the future.
+	 */
+	if (origin > timestamp && stride_usecs > 1)
+		tm_delta -= stride_usecs;
+
 	result = origin + tm_delta;
 
 	PG_RETURN_TIMESTAMPTZ(result);
diff --git a/src/test/regress/expected/timestamp.out b/src/test/regress/expected/timestamp.out
index 89a856bac9..2550e7d136 100644
--- a/src/test/regress/expected/timestamp.out
+++ b/src/test/regress/expected/timestamp.out
@@ -600,6 +600,60 @@ FROM (
  microsecond | 1 us     | t
 (7 rows)
 
+-- case 3: AD dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_bin(interval::interval, ts, timestamp '2020-03-02') AS equal
+FROM (
+  VALUES
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(VALUES (timestamp '2020-02-29 15:44:17.71393')) ts (ts);
+     str     | interval | equal 
+-------------+----------+-------
+ 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
+(7 rows)
+
+-- case 4: BC dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_bin(interval::interval, ts, timestamp '0055-06-17 BC') AS equal
+FROM (
+  VALUES
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(VALUES (timestamp '0055-6-10 15:44:17.71393 BC')) ts (ts);
+     str     | interval | equal 
+-------------+----------+-------
+ 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
+(7 rows)
+
 -- bin timestamps into arbitrary intervals
 SELECT
   interval,
diff --git a/src/test/regress/sql/timestamp.sql b/src/test/regress/sql/timestamp.sql
index 256b96163d..2fd954c20b 100644
--- a/src/test/regress/sql/timestamp.sql
+++ b/src/test/regress/sql/timestamp.sql
@@ -202,6 +202,40 @@ FROM (
 ) intervals (str, interval),
 (VALUES (timestamp '0055-6-10 15:44:17.71393 BC')) ts (ts);
 
+-- case 3: AD dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_bin(interval::interval, ts, timestamp '2020-03-02') AS equal
+FROM (
+  VALUES
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(VALUES (timestamp '2020-02-29 15:44:17.71393')) ts (ts);
+
+-- case 4: BC dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_bin(interval::interval, ts, timestamp '0055-06-17 BC') AS equal
+FROM (
+  VALUES
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(VALUES (timestamp '0055-6-10 15:44:17.71393 BC')) ts (ts);
+
 -- bin timestamps into arbitrary intervals
 SELECT
   interval,
#30John Naylor
john.naylor@enterprisedb.com
In reply to: Justin Pryzby (#28)
1 attachment(s)
Re: truncating timestamps on arbitrary intervals

On Sat, Mar 27, 2021 at 1:06 PM Justin Pryzby <pryzby@telsasoft.com> wrote:

The current docs seem to be missing a "synopsis", like

+<synopsis>
+date_trunc(<replaceable>stride</replaceable>,

<replaceable>timestamp</replaceable>, <replaceable>origin</replaceable>)

+</synopsis>

The attached
- adds a synopsis
- adds a bit more description to the parameters similar to those in
date_trunc
- documents that negative intervals are treated the same as positive ones

Note on the last point: This just falls out of the math, so was not
deliberate, but it seems fine to me. We could ban negative intervals, but
that would possibly just inconvenience some people unnecessarily. We could
also treat negative strides differently somehow, but I don't immediately
see a useful and/or intuitive change in behavior to come of that.

--
John Naylor
EDB: http://www.enterprisedb.com

Attachments:

synopsis-and-add-to-description.patchapplication/octet-stream; name=synopsis-and-add-to-description.patchDownload
 doc/src/sgml/func.sgml | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

diff --git a/doc/src/sgml/func.sgml b/doc/src/sgml/func.sgml
index 19285ae136..beb44d365d 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -9895,6 +9895,22 @@ SELECT date_trunc('hour', INTERVAL '3 days 02:47:33');
     aligned with a specified origin.
    </para>
 
+   <para>
+<synopsis>
+date_trunc(<replaceable>stride</replaceable>, <replaceable>source</replaceable>, <replaceable>origin</replaceable>)
+</synopsis>
+    <replaceable>source</replaceable> is a value expression of type
+    <type>timestamp</type> or <type>timestamp with time zone</type>.
+    (Values of type <type>date</type> are cast automatically to
+    <type>timestamp</type>.)
+    <replaceable>stride</replaceable> is a value expression of type <type>
+    interval</type>. Negative intervals are not treated differently from
+    positive intervals.
+    The return value is likewise of type <type>timestamp</type> or
+    <type>timestamp with time zone</type>, and it marks the beginning
+    of the bin into which the <replaceable>source</replaceable> is placed.
+   </para>
+
    <para>
     Examples:
 <screen>
#31Salek Talangi
salek.talangi@googlemail.com
In reply to: John Naylor (#30)
Re: truncating timestamps on arbitrary intervals

Hi all,

it might be a bit late now, but do you know that TimescaleDB already has a
similar feature, named time_bucket?
https://docs.timescale.com/latest/api#time_bucket
Perhaps that can help with some design decisions.
I saw your feature on Depesz' "Waiting for PostgreSQL 14" and remembered
reading about it just two days ago.

Best regards
Salek Talangi

Am Do., 1. Apr. 2021 um 13:31 Uhr schrieb John Naylor <
john.naylor@enterprisedb.com>:

Show quoted text

On Sat, Mar 27, 2021 at 1:06 PM Justin Pryzby <pryzby@telsasoft.com>
wrote:

The current docs seem to be missing a "synopsis", like

+<synopsis>
+date_trunc(<replaceable>stride</replaceable>,

<replaceable>timestamp</replaceable>, <replaceable>origin</replaceable>)

+</synopsis>

The attached
- adds a synopsis
- adds a bit more description to the parameters similar to those in
date_trunc
- documents that negative intervals are treated the same as positive ones

Note on the last point: This just falls out of the math, so was not
deliberate, but it seems fine to me. We could ban negative intervals, but
that would possibly just inconvenience some people unnecessarily. We could
also treat negative strides differently somehow, but I don't immediately
see a useful and/or intuitive change in behavior to come of that.

--
John Naylor
EDB: http://www.enterprisedb.com

#32John Naylor
john.naylor@enterprisedb.com
In reply to: Salek Talangi (#31)
Re: truncating timestamps on arbitrary intervals

On Thu, Apr 1, 2021 at 9:11 AM Salek Talangi <salek.talangi@googlemail.com>
wrote:

Hi all,

it might be a bit late now, but do you know that TimescaleDB already has

a similar feature, named time_bucket?

https://docs.timescale.com/latest/api#time_bucket
Perhaps that can help with some design decisions.

Yes, thanks I'm aware of it. It's a bit more feature-rich, and I wanted to
have something basic that users can have available without installing an
extension.

--
John Naylor
EDB: http://www.enterprisedb.com

#33Peter Eisentraut
peter.eisentraut@enterprisedb.com
In reply to: John Naylor (#30)
Re: truncating timestamps on arbitrary intervals

On 30.03.21 18:50, John Naylor wrote:

On Sat, Mar 27, 2021 at 1:06 PM Justin Pryzby <pryzby@telsasoft.com
<mailto:pryzby@telsasoft.com>> wrote:

The current docs seem to be missing a "synopsis", like

+<synopsis>
+date_trunc(<replaceable>stride</replaceable>, 

<replaceable>timestamp</replaceable>, <replaceable>origin</replaceable>)

+</synopsis>

The attached
- adds a synopsis
- adds a bit more description to the parameters similar to those in
date_trunc
- documents that negative intervals are treated the same as positive ones

Note on the last point: This just falls out of the math, so was not
deliberate, but it seems fine to me. We could ban negative intervals,
but that would possibly just inconvenience some people unnecessarily. We
could also treat negative strides differently somehow, but I don't
immediately see a useful and/or intuitive change in behavior to come of
that.

committed

#34Peter Eisentraut
peter.eisentraut@enterprisedb.com
In reply to: John Naylor (#29)
Re: truncating timestamps on arbitrary intervals

On 30.03.21 18:06, John Naylor wrote:

Currently, when the origin is after the input, the result is the
timestamp at the end of the bin, rather than the beginning as expected.
The attached puts the result consistently at the beginning of the bin.

In the patch

+   if (origin > timestamp && stride_usecs > 1)
+       tm_delta -= stride_usecs;

is the condition stride_usecs > 1 really necessary? My assessment is
that it's not, in which case it would be better to omit it.

#35John Naylor
john.naylor@enterprisedb.com
In reply to: Peter Eisentraut (#34)
Re: truncating timestamps on arbitrary intervals

On Sat, Apr 10, 2021 at 7:43 AM Peter Eisentraut <
peter.eisentraut@enterprisedb.com> wrote:

On 30.03.21 18:06, John Naylor wrote:

Currently, when the origin is after the input, the result is the
timestamp at the end of the bin, rather than the beginning as expected.
The attached puts the result consistently at the beginning of the bin.

In the patch

+   if (origin > timestamp && stride_usecs > 1)
+       tm_delta -= stride_usecs;

is the condition stride_usecs > 1 really necessary? My assessment is
that it's not, in which case it would be better to omit it.

Without the condition, the case of 1 microsecond will fail to be a no-op.
This case has no practical use, but it still must work correctly, just as
date_trunc('microsecond', input) does.

--
John Naylor
EDB: http://www.enterprisedb.com

#36Peter Eisentraut
peter.eisentraut@enterprisedb.com
In reply to: John Naylor (#35)
Re: truncating timestamps on arbitrary intervals

On 10.04.21 14:53, John Naylor wrote:

On Sat, Apr 10, 2021 at 7:43 AM Peter Eisentraut
<peter.eisentraut@enterprisedb.com
<mailto:peter.eisentraut@enterprisedb.com>> wrote:

On 30.03.21 18:06, John Naylor wrote:

Currently, when the origin is after the input, the result is the
timestamp at the end of the bin, rather than the beginning as expected.
The attached puts the result consistently at the beginning of the bin.

In the patch

+   if (origin > timestamp && stride_usecs > 1)
+       tm_delta -= stride_usecs;

is the condition stride_usecs > 1 really necessary?  My assessment is
that it's not, in which case it would be better to omit it.

Without the condition, the case of 1 microsecond will fail to be a
no-op. This case has no practical use, but it still must work correctly,
just as date_trunc('microsecond', input) does.

Ah yes, the tests cover that. Committed.

#37Justin Pryzby
pryzby@telsasoft.com
In reply to: Peter Eisentraut (#33)
Re: truncating timestamps on arbitrary intervals

On Fri, Apr 09, 2021 at 10:02:47PM +0200, Peter Eisentraut wrote:

On 30.03.21 18:50, John Naylor wrote:

On Sat, Mar 27, 2021 at 1:06 PM Justin Pryzby wrote:

The current docs seem to be missing a "synopsis", like

+<synopsis>
+date_trunc(<replaceable>stride</replaceable>, <replaceable>timestamp</replaceable>, <replaceable>origin</replaceable>)
+</synopsis>

The attached
- adds a synopsis
- adds a bit more description to the parameters similar to those in
date_trunc
- documents that negative intervals are treated the same as positive ones

Note on the last point: This just falls out of the math, so was not
deliberate, but it seems fine to me. We could ban negative intervals,
but that would possibly just inconvenience some people unnecessarily. We
could also treat negative strides�differently somehow, but I don't
immediately see a useful and/or intuitive change in behavior to come of
that.

committed

It looks like we all missed that I misspelled "date_bin" as
"date_trunc"...sorry. I will include this with my next round of doc review, in
case you don't want to make a separate commit for it.

https://www.postgresql.org/docs/devel/functions-datetime.html#FUNCTIONS-DATETIME-BIN

From f4eab5c0f908d868540ab33aa12b82fd05f19f52 Mon Sep 17 00:00:00 2001
From: Justin Pryzby <pryzbyj@telsasoft.com>
Date: Thu, 22 Apr 2021 03:37:18 -0500
Subject: [PATCH] date_bin: fixup for added documentation in 49fb4e

---
doc/src/sgml/func.sgml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/doc/src/sgml/func.sgml b/doc/src/sgml/func.sgml
index 53f4c09c81..cc4e1b0a36 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -9946,13 +9946,13 @@ date_trunc(<replaceable>stride</replaceable>, <replaceable>timestamp</replaceabl
    <para>
 <synopsis>
-date_trunc(<replaceable>stride</replaceable>, <replaceable>source</replaceable>, <replaceable>origin</replaceable>)
+date_bin(<replaceable>stride</replaceable>, <replaceable>source</replaceable>, <replaceable>origin</replaceable>)
 </synopsis>
     <replaceable>source</replaceable> is a value expression of type
     <type>timestamp</type> or <type>timestamp with time zone</type>.  (Values
     of type <type>date</type> are cast automatically to
     <type>timestamp</type>.)  <replaceable>stride</replaceable> is a value
-    expression of type <type> interval</type>.  The return value is likewise
+    expression of type <type>interval</type>.  The return value is likewise
     of type <type>timestamp</type> or <type>timestamp with time zone</type>,
     and it marks the beginning of the bin into which the
     <replaceable>source</replaceable> is placed.
-- 
2.17.0
#38Peter Eisentraut
peter.eisentraut@enterprisedb.com
In reply to: Justin Pryzby (#37)
Re: truncating timestamps on arbitrary intervals

On 22.04.21 11:16, Justin Pryzby wrote:

It looks like we all missed that I misspelled "date_bin" as
"date_trunc"...sorry. I will include this with my next round of doc review, in
case you don't want to make a separate commit for it.

fixed

#39Bauyrzhan Sakhariyev
baurzhansahariev@gmail.com
In reply to: John Naylor (#1)
Re: truncating timestamps on arbitrary intervals

Is date_bin supposed to return the beginning of the bin? And does the sign
of an interval define the "direction" of the bin?
Judging by results of queries #1 and #2, sign of interval decides a
direction timestamp gets shifted to (in both cases ts < origin)
but when ts >origin (queries #3 and #4) interval sign doesn't matter,
specifically #4 doesn't return 6-th of January.

1. SELECT date_bin('-2 days'::interval, timestamp '2001-01-01
00:00:00', timestamp
'2001-01-04 00:00:00'); -- 2001-01-02 00:00:00
2. SELECT date_bin('2 days'::interval, timestamp '2001-01-01
00:00:00', timestamp
'2001-01-04 00:00:00'); -- 2000-12-31 00:00:00
3. SELECT date_bin('2 days'::interval, timestamp '2001-01-04
00:00:00', timestamp
'2001-01-01 00:00:00'); -- 2001-01-03 00:00:00
4. SELECT date_bin('-2 days'::interval, timestamp '2001-01-04
00:00:00', timestamp
'2001-01-01 00:00:00'); -- 2001-01-03 00:00:00

On Thu, Jul 22, 2021 at 6:21 PM John Naylor <john.naylor@2ndquadrant.com>
wrote:

Show quoted text

Hi,

When analyzing time-series data, it's useful to be able to bin
timestamps into equally spaced ranges. date_trunc() is only able to
bin on a specified whole unit. In the attached patch for the March
commitfest, I propose a new function date_trunc_interval(), which can
truncate to arbitrary intervals, e.g.:

select date_trunc_interval('15 minutes', timestamp '2020-02-16
20:48:40'); date_trunc_interval
---------------------
2020-02-16 20:45:00
(1 row)

With this addition, it might be possible to turn the existing
date_trunc() functions into wrappers. I haven't done that here because
it didn't seem practical at this point. For one, the existing
functions have special treatment for weeks, centuries, and millennia.

Note: I've only written the implementation for the type timestamp
without timezone. Adding timezone support would be pretty simple, but
I wanted to get feedback on the basic idea first before making it
complete. I've also written tests and very basic documentation.

--
John Naylor https://www.2ndQuadrant.com/
PostgreSQL Development, 24x7 Support, Remote DBA, Training & Services

#40John Naylor
john.naylor@enterprisedb.com
In reply to: Bauyrzhan Sakhariyev (#39)
Re: truncating timestamps on arbitrary intervals

On Thu, Jul 22, 2021 at 12:24 PM Bauyrzhan Sakhariyev <
baurzhansahariev@gmail.com> wrote:

Is date_bin supposed to return the beginning of the bin?

Thanks for testing! And yes.

And does the sign of an interval define the "direction" of the bin?

No, the boundary is intentionally the earlier one:

/*
* Make sure the returned timestamp is at the start of the bin, even if
* the origin is in the future.
*/
if (origin > timestamp && stride_usecs > 1)
tm_delta -= stride_usecs;

I wonder if we should just disallow negative intervals here.

--
John Naylor
EDB: http://www.enterprisedb.com

#41Bauyrzhan Sakhariyev
baurzhansahariev@gmail.com
In reply to: John Naylor (#40)
Re: truncating timestamps on arbitrary intervals

No, the boundary is intentionally the earlier one:

I found that commit in GitHub, thanks for pointing it out.
When I test locally *origin_in_the_future *case I get different results for
positive and negative intervals (see queries #1 and #2 from above, they
have same timestamp, origin and interval magnitude, difference is only in
interval sign) - can it be that the version I downloaded from
https://www.enterprisedb.com/postgresql-early-experience doesn't include
commit with that improvement?

I wonder if we should just disallow negative intervals here.

I cannot imagine somebody using negative as a constant argument but users
can pass another column as a first argument date or some function(ts) - not
likely but possible. A line in docs about the leftmost point of interval as
start of the bin could be helpful.

Not related to negative interval - I created a PR for adding zero check for
stride https://github.com/postgres/postgres/pull/67 and after getting it
closed I stopped right there - 1 line check doesn't worth going through the
patching process I'm not familiar with.

In the case of full units (1 minute, 1 hour, etc.), it gives the same

result as the analogous date_trunc call,
Was not obvious to me that we need to supply Monday origin to make
date_bin(1 week, ts) produce same result with date_trunc

Sorry for the verbose report and thanks for the nice function - I know
it's not yet released, was just playing around with beta as I want to
align CrateDB
date_bin <https://github.com/crate/crate/issues/11310&gt; with Postgresql

On Thu, Jul 22, 2021 at 7:28 PM John Naylor <john.naylor@enterprisedb.com>
wrote:

Show quoted text

On Thu, Jul 22, 2021 at 12:24 PM Bauyrzhan Sakhariyev <
baurzhansahariev@gmail.com> wrote:

Is date_bin supposed to return the beginning of the bin?

Thanks for testing! And yes.

And does the sign of an interval define the "direction" of the bin?

No, the boundary is intentionally the earlier one:

/*
* Make sure the returned timestamp is at the start of the bin, even if
* the origin is in the future.
*/
if (origin > timestamp && stride_usecs > 1)
tm_delta -= stride_usecs;

I wonder if we should just disallow negative intervals here.

--
John Naylor
EDB: http://www.enterprisedb.com

#42John Naylor
john.naylor@enterprisedb.com
In reply to: Bauyrzhan Sakhariyev (#41)
Re: truncating timestamps on arbitrary intervals

On Thu, Jul 22, 2021 at 4:49 PM Bauyrzhan Sakhariyev <
baurzhansahariev@gmail.com> wrote:

Not related to negative interval - I created a PR for adding zero check

for stride https://github.com/postgres/postgres/pull/67 and after getting
it closed I stopped right there - 1 line check doesn't worth going through
the patching process I'm not familiar with.

Thanks for the pull request! I added tests and reworded the error message
slightly to match current style, and pushed.

--
John Naylor
EDB: http://www.enterprisedb.com

#43John Naylor
john.naylor@enterprisedb.com
In reply to: Bauyrzhan Sakhariyev (#41)
Re: truncating timestamps on arbitrary intervals

On Thu, Jul 22, 2021 at 4:49 PM Bauyrzhan Sakhariyev <
baurzhansahariev@gmail.com> wrote:

No, the boundary is intentionally the earlier one:

I found that commit in GitHub, thanks for pointing it out.
When I test locally origin_in_the_future case I get different results for

positive and negative intervals (see queries #1 and #2 from above, they
have same timestamp, origin and interval magnitude, difference is only in
interval sign) - can it be that the version I downloaded from
https://www.enterprisedb.com/postgresql-early-experience doesn't include
commit with that improvement?

Sorry, I wasn't clear. The intention is that the boundary is on the lower
side, but query #1 doesn't follow that, so that's a bug in my view. I found
while developing the feature that the sign of the stride didn't seem to
matter, but evidently I didn't try with the origin in the future.

I wonder if we should just disallow negative intervals here.

I cannot imagine somebody using negative as a constant argument but users

can pass another column as a first argument date or some function(ts) - not
likely but possible. A line in docs about the leftmost point of interval as
start of the bin could be helpful.

In recent years there have been at least two attempts to add an absolute
value function for intervals, and both stalled over semantics, so I'd
rather just side-step the issue, especially as we're in beta.

In the case of full units (1 minute, 1 hour, etc.), it gives the same

result as the analogous date_trunc call,

Was not obvious to me that we need to supply Monday origin to make

date_bin(1 week, ts) produce same result with date_trunc

The docs for date_trunc() don't mention this explicitly, but it might be
worth mentioning ISO weeks. There is a nearby mention for EXTRACT():

https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-EXTRACT

"The number of the ISO 8601 week-numbering week of the year. By definition,
ISO weeks start on Mondays and the first week of a year contains January 4
of that year. In other words, the first Thursday of a year is in week 1 of
that year."

Sorry for the verbose report and thanks for the nice function - I know

it's not yet released, was just playing around with beta as I want to align
CrateDB date_bin with Postgresql

Thanks again for testing! This is good feedback.

--
John Naylor
EDB: http://www.enterprisedb.com

#44John Naylor
john.naylor@enterprisedb.com
In reply to: John Naylor (#43)
1 attachment(s)
Re: truncating timestamps on arbitrary intervals

I wrote:

On Thu, Jul 22, 2021 at 4:49 PM Bauyrzhan Sakhariyev <

baurzhansahariev@gmail.com> wrote:

No, the boundary is intentionally the earlier one:

I found that commit in GitHub, thanks for pointing it out.
When I test locally origin_in_the_future case I get different results

for positive and negative intervals (see queries #1 and #2 from above, they
have same timestamp, origin and interval magnitude, difference is only in
interval sign) - can it be that the version I downloaded from
https://www.enterprisedb.com/postgresql-early-experience doesn't include
commit with that improvement?

Sorry, I wasn't clear. The intention is that the boundary is on the lower

side, but query #1 doesn't follow that, so that's a bug in my view. I found
while developing the feature that the sign of the stride didn't seem to
matter, but evidently I didn't try with the origin in the future.

I wonder if we should just disallow negative intervals here.

I cannot imagine somebody using negative as a constant argument but

users can pass another column as a first argument date or some function(ts)
- not likely but possible. A line in docs about the leftmost point of
interval as start of the bin could be helpful.

In recent years there have been at least two attempts to add an absolute

value function for intervals, and both stalled over semantics, so I'd
rather just side-step the issue, especially as we're in beta.

Concretely, I propose to push the attached on master and v14. Since we're
in beta 2 and this thread might not get much attention, I've CC'd the RMT.

--
John Naylor
EDB: http://www.enterprisedb.com

Attachments:

0001-Disallow-negative-strides-in-date_bin.patchapplication/octet-stream; name=0001-Disallow-negative-strides-in-date_bin.patchDownload
From 09a4795eb589f08305aa2597059248ad11447ef0 Mon Sep 17 00:00:00 2001
From: John Naylor <john.naylor@postgresql.org>
Date: Tue, 27 Jul 2021 11:58:16 -0400
Subject: [PATCH] Disallow negative strides in date_bin()

It's not clear what the semantics of negative strides would be, so throw
an error instead.

Per report from Bauyrzhan Sakhariyev

Backpatch to v14

Discussion: https://www.postgresql.org/message-id/CAKpL73vZmLuFVuwF26FJ%2BNk11PVHhAnQRoREFcA03x7znRoFvA%40mail.gmail.com
---
 src/backend/utils/adt/timestamp.c         | 8 ++++----
 src/test/regress/expected/timestamp.out   | 5 ++++-
 src/test/regress/expected/timestamptz.out | 5 ++++-
 src/test/regress/sql/timestamp.sql        | 3 +++
 src/test/regress/sql/timestamptz.sql      | 3 +++
 5 files changed, 18 insertions(+), 6 deletions(-)

diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index ea847576cd..1c0bf0aa5c 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -3843,10 +3843,10 @@ timestamp_bin(PG_FUNCTION_ARGS)
 
 	stride_usecs = stride->day * USECS_PER_DAY + stride->time;
 
-	if (stride_usecs == 0)
+	if (stride_usecs <= 0)
 		ereport(ERROR,
 				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
-				 errmsg("stride cannot equal zero")));
+				 errmsg("stride must be greater than zero")));
 
 	tm_diff = timestamp - origin;
 	tm_delta = tm_diff - tm_diff % stride_usecs;
@@ -4026,10 +4026,10 @@ timestamptz_bin(PG_FUNCTION_ARGS)
 
 	stride_usecs = stride->day * USECS_PER_DAY + stride->time;
 
-	if (stride_usecs == 0)
+	if (stride_usecs <= 0)
 		ereport(ERROR,
 				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
-				 errmsg("stride cannot equal zero")));
+				 errmsg("stride must be greater than zero")));
 
 	tm_diff = timestamp - origin;
 	tm_delta = tm_diff - tm_diff % stride_usecs;
diff --git a/src/test/regress/expected/timestamp.out b/src/test/regress/expected/timestamp.out
index 7a2cbd9b3f..1a2d48cae9 100644
--- a/src/test/regress/expected/timestamp.out
+++ b/src/test/regress/expected/timestamp.out
@@ -706,7 +706,10 @@ SELECT date_bin('5 years'::interval,  timestamp '2020-02-01 01:01:01', timestamp
 ERROR:  timestamps cannot be binned into intervals containing months or years
 -- disallow zero intervals
 SELECT date_bin('0 days'::interval, timestamp '1970-01-01 01:00:00' , timestamp '1970-01-01 00:00:00');
-ERROR:  stride cannot equal zero
+ERROR:  stride must be greater than zero
+-- disallow negative intervals
+SELECT date_bin('-2 days'::interval, timestamp '1970-01-01 01:00:00' , timestamp '1970-01-01 00:00:00');
+ERROR:  stride must be greater than zero
 -- Test casting within a BETWEEN qualifier
 SELECT d1 - timestamp without time zone '1997-01-02' AS diff
   FROM TIMESTAMP_TBL
diff --git a/src/test/regress/expected/timestamptz.out b/src/test/regress/expected/timestamptz.out
index be6ead0fb5..990c4eddf1 100644
--- a/src/test/regress/expected/timestamptz.out
+++ b/src/test/regress/expected/timestamptz.out
@@ -750,7 +750,10 @@ SELECT date_bin('5 years'::interval,  timestamp with time zone '2020-02-01 01:01
 ERROR:  timestamps cannot be binned into intervals containing months or years
 -- disallow zero intervals
 SELECT date_bin('0 days'::interval, timestamp with time zone '1970-01-01 01:00:00+00' , timestamp with time zone '1970-01-01 00:00:00+00');
-ERROR:  stride cannot equal zero
+ERROR:  stride must be greater than zero
+-- disallow negative intervals
+SELECT date_bin('-2 days'::interval, timestamp with time zone '1970-01-01 01:00:00+00' , timestamp with time zone '1970-01-01 00:00:00+00');
+ERROR:  stride must be greater than zero
 -- Test casting within a BETWEEN qualifier
 SELECT d1 - timestamp with time zone '1997-01-02' AS diff
   FROM TIMESTAMPTZ_TBL
diff --git a/src/test/regress/sql/timestamp.sql b/src/test/regress/sql/timestamp.sql
index 7307a24092..e011e779ea 100644
--- a/src/test/regress/sql/timestamp.sql
+++ b/src/test/regress/sql/timestamp.sql
@@ -266,6 +266,9 @@ SELECT date_bin('5 years'::interval,  timestamp '2020-02-01 01:01:01', timestamp
 -- disallow zero intervals
 SELECT date_bin('0 days'::interval, timestamp '1970-01-01 01:00:00' , timestamp '1970-01-01 00:00:00');
 
+-- disallow negative intervals
+SELECT date_bin('-2 days'::interval, timestamp '1970-01-01 01:00:00' , timestamp '1970-01-01 00:00:00');
+
 -- Test casting within a BETWEEN qualifier
 SELECT d1 - timestamp without time zone '1997-01-02' AS diff
   FROM TIMESTAMP_TBL
diff --git a/src/test/regress/sql/timestamptz.sql b/src/test/regress/sql/timestamptz.sql
index 3642d8c143..b18821de53 100644
--- a/src/test/regress/sql/timestamptz.sql
+++ b/src/test/regress/sql/timestamptz.sql
@@ -241,6 +241,9 @@ SELECT date_bin('5 years'::interval,  timestamp with time zone '2020-02-01 01:01
 -- disallow zero intervals
 SELECT date_bin('0 days'::interval, timestamp with time zone '1970-01-01 01:00:00+00' , timestamp with time zone '1970-01-01 00:00:00+00');
 
+-- disallow negative intervals
+SELECT date_bin('-2 days'::interval, timestamp with time zone '1970-01-01 01:00:00+00' , timestamp with time zone '1970-01-01 00:00:00+00');
+
 -- Test casting within a BETWEEN qualifier
 SELECT d1 - timestamp with time zone '1997-01-02' AS diff
   FROM TIMESTAMPTZ_TBL
-- 
2.31.1

#45Tom Lane
tgl@sss.pgh.pa.us
In reply to: John Naylor (#44)
Re: truncating timestamps on arbitrary intervals

John Naylor <john.naylor@enterprisedb.com> writes:

Concretely, I propose to push the attached on master and v14. Since we're
in beta 2 and this thread might not get much attention, I've CC'd the RMT.

+1, we can figure out whether that has a use some other time.

regards, tom lane

#46Michael Paquier
michael@paquier.xyz
In reply to: John Naylor (#44)
Re: truncating timestamps on arbitrary intervals

On Tue, Jul 27, 2021 at 12:05:51PM -0400, John Naylor wrote:

Concretely, I propose to push the attached on master and v14. Since we're
in beta 2 and this thread might not get much attention, I've CC'd the RMT.

(It looks like gmail has messed up a bit the format of your last
message.)

Hmm. The docs say also the following thing, but your patch does not
reflect that anymore:
"Negative intervals are allowed and are treated the same as positive
intervals."
So you may want to update that, at least.
--
Michael

#47John Naylor
john.naylor@enterprisedb.com
In reply to: Michael Paquier (#46)
Re: truncating timestamps on arbitrary intervals

On Wed, Jul 28, 2021 at 12:15 AM Michael Paquier <michael@paquier.xyz>
wrote:

On Tue, Jul 27, 2021 at 12:05:51PM -0400, John Naylor wrote:

Concretely, I propose to push the attached on master and v14. Since

we're

in beta 2 and this thread might not get much attention, I've CC'd the

RMT.

(It looks like gmail has messed up a bit the format of your last
message.)

Hmm, it looks fine in the archives.

Hmm. The docs say also the following thing, but your patch does not
reflect that anymore:
"Negative intervals are allowed and are treated the same as positive
intervals."

I'd forgotten that was documented based on incomplete information, thanks
for looking! Pushed with that fixed.

--
John Naylor
EDB: http://www.enterprisedb.com