 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
