You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@calcite.apache.org by jh...@apache.org on 2019/10/11 00:53:22 UTC

[calcite] 02/02: [CALCITE-3383] Plural time units

This is an automated email from the ASF dual-hosted git repository.

jhyde pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/calcite.git

commit 4be2d08e2116234a7df982014f8ae5bc79a7e0bf
Author: Julian Hyde <jh...@apache.org>
AuthorDate: Wed Oct 9 19:16:45 2019 -0700

    [CALCITE-3383] Plural time units
    
    Add SqlConformance.allowPluralTimeUnits(); if it is true, we allow
    
    INTERVAL '2' days
    
    otherwise we only allow
    
    INTERVAL '2' DAY
---
 babel/src/main/codegen/config.fmpp                 |  21 ++--
 core/src/main/codegen/config.fmpp                  |   6 +
 core/src/main/codegen/templates/Parser.jj          | 111 +++++++++++++++----
 .../apache/calcite/runtime/CalciteResource.java    |   3 +
 .../calcite/sql/parser/SqlAbstractParserImpl.java  |   4 +
 .../org/apache/calcite/sql/parser/SqlParser.java   |   9 ++
 .../sql/validate/SqlAbstractConformance.java       |   4 +
 .../calcite/sql/validate/SqlConformance.java       |  15 +++
 .../calcite/sql/validate/SqlConformanceEnum.java   |  10 ++
 .../calcite/runtime/CalciteResource.properties     |   1 +
 core/src/test/codegen/config.fmpp                  |   6 +
 .../apache/calcite/sql/parser/SqlParserTest.java   | 123 ++++++++++++++++-----
 server/src/main/codegen/config.fmpp                |   6 +
 site/_docs/reference.md                            |  10 ++
 14 files changed, 269 insertions(+), 60 deletions(-)

diff --git a/babel/src/main/codegen/config.fmpp b/babel/src/main/codegen/config.fmpp
index c120557..503bd1c 100644
--- a/babel/src/main/codegen/config.fmpp
+++ b/babel/src/main/codegen/config.fmpp
@@ -84,6 +84,7 @@ data: {
       "DATABASE"
       "DATETIME_INTERVAL_CODE"
       "DATETIME_INTERVAL_PRECISION"
+      "DAYS"
       "DECADE"
       "DEFAULTS"
       "DEFERRABLE"
@@ -124,6 +125,7 @@ data: {
       "GOTO"
       "GRANTED"
       "HIERARCHY"
+      "HOURS"
       "IGNORE"
       "IMMEDIATE"
       "IMMEDIATELY"
@@ -160,7 +162,9 @@ data: {
       "MICROSECOND"
       "MILLENNIUM"
       "MILLISECOND"
+      "MINUTES"
       "MINVALUE"
+      "MONTHS"
       "MORE_"
       "MUMPS"
       "NAME"
@@ -227,6 +231,7 @@ data: {
       "SCOPE_CATALOGS"
       "SCOPE_NAME"
       "SCOPE_SCHEMA"
+      "SECONDS"
       "SECTION"
       "SECURITY"
       "SELF"
@@ -332,14 +337,10 @@ data: {
       "WRAPPER"
       "WRITE"
       "XML"
+      "YEARS"
       "ZONE"
     ]
 
-    # List of non-reserved keywords to remove;
-    # items in this list become reserved
-    nonReservedKeywordsToRemove: [
-    ]
-
     # List of non-reserved keywords to add;
     # items in this list become non-reserved
     nonReservedKeywordsToAdd: [
@@ -456,7 +457,6 @@ data: {
       "DATA"
 #     "DATE"
       "DAY"
-#     "DAYS" # not a keyword in Calcite
       "DEALLOCATE"
       "DEC"
       "DECIMAL"
@@ -535,7 +535,6 @@ data: {
 #     "HAVING"
       "HOLD"
       "HOUR"
-#     "HOURS" # not a keyword in Calcite
       "IDENTITY"
 #     "IF" # not a keyword in Calcite
       "IMMEDIATE"
@@ -604,7 +603,6 @@ data: {
       "MIN"
 #     "MINUS"
       "MINUTE"
-#     "MINUTES" # not a keyword in Calcite
       "MOD"
       "MODIFIES"
       "MODULE"
@@ -717,7 +715,6 @@ data: {
       "SCROLL"
       "SEARCH"
       "SECOND"
-#     "SECONDS" # not a keyword in Calcite
       "SECTION"
       "SEEK"
 #     "SELECT"
@@ -818,10 +815,14 @@ data: {
       "WORK"
       "WRITE"
       "YEAR"
-#     "YEARS" # not a keyword in Calcite
       "ZONE"
     ]
 
+    # List of non-reserved keywords to remove;
+    # items in this list become reserved
+    nonReservedKeywordsToRemove: [
+    ]
+
     # List of additional join types. Each is a method with no arguments.
     # Example: LeftSemiJoin()
     joinTypes: [
diff --git a/core/src/main/codegen/config.fmpp b/core/src/main/codegen/config.fmpp
index e2bde0f..88ed04cd 100644
--- a/core/src/main/codegen/config.fmpp
+++ b/core/src/main/codegen/config.fmpp
@@ -104,6 +104,7 @@ data: {
       "DATABASE"
       "DATETIME_INTERVAL_CODE"
       "DATETIME_INTERVAL_PRECISION"
+      "DAYS"
       "DECADE"
       "DEFAULTS"
       "DEFERRABLE"
@@ -144,6 +145,7 @@ data: {
       "GOTO"
       "GRANTED"
       "HIERARCHY"
+      "HOURS"
       "IGNORE"
       "IMMEDIATE"
       "IMMEDIATELY"
@@ -180,7 +182,9 @@ data: {
       "MICROSECOND"
       "MILLENNIUM"
       "MILLISECOND"
+      "MINUTES"
       "MINVALUE"
+      "MONTHS"
       "MORE_"
       "MUMPS"
       "NAME"
@@ -247,6 +251,7 @@ data: {
       "SCOPE_CATALOGS"
       "SCOPE_NAME"
       "SCOPE_SCHEMA"
+      "SECONDS"
       "SECTION"
       "SECURITY"
       "SELF"
@@ -352,6 +357,7 @@ data: {
       "WRAPPER"
       "WRITE"
       "XML"
+      "YEARS"
       "ZONE"
     ]
 
diff --git a/core/src/main/codegen/templates/Parser.jj b/core/src/main/codegen/templates/Parser.jj
index 47a88bc..afebba8 100644
--- a/core/src/main/codegen/templates/Parser.jj
+++ b/core/src/main/codegen/templates/Parser.jj
@@ -234,6 +234,20 @@ public class ${parser.class} extends SqlAbstractParserImpl
         return SqlStdOperatorTable.EXTEND.createCall(
             Span.of(table, extendList).pos(), table, extendList);
     }
+
+    /** Adds a warning that a token such as "HOURS" was used,
+    * whereas the SQL standard only allows "HOUR".
+    *
+    * <p>Currently, we silently add an exception to a list of warnings. In
+    * future, we may have better compliance checking, for example a strict
+    * compliance mode that throws if any non-standard features are used. */
+    private TimeUnit warn(TimeUnit timeUnit) throws ParseException {
+        final String token = getToken(0).image.toUpperCase(Locale.ROOT);
+        warnings.add(
+            SqlUtil.newContextException(getPos(),
+                RESOURCE.nonStandardFeatureUsed(token)));
+        return timeUnit;
+    }
 }
 
 PARSER_END(${parser.class})
@@ -4146,66 +4160,111 @@ SqlLiteral IntervalLiteral() :
     }
 }
 
+TimeUnit Year() :
+{
+}
+{
+    <YEAR> { return TimeUnit.YEAR; }
+|
+    <YEARS> { return warn(TimeUnit.YEAR); }
+}
+
+TimeUnit Month() :
+{
+}
+{
+    <MONTH> { return TimeUnit.MONTH; }
+|
+    <MONTHS> { return warn(TimeUnit.MONTH); }
+}
+
+TimeUnit Day() :
+{
+}
+{
+    <DAY> { return TimeUnit.DAY; }
+|
+    <DAYS> { return warn(TimeUnit.DAY); }
+}
+
+TimeUnit Hour() :
+{
+}
+{
+    <HOUR> { return TimeUnit.HOUR; }
+|
+    <HOURS> { return warn(TimeUnit.HOUR); }
+}
+
+TimeUnit Minute() :
+{
+}
+{
+    <MINUTE> { return TimeUnit.MINUTE; }
+|
+    <MINUTES> { return warn(TimeUnit.MINUTE); }
+}
+
+TimeUnit Second() :
+{
+}
+{
+    <SECOND> { return TimeUnit.SECOND; }
+|
+    <SECONDS> { return warn(TimeUnit.SECOND); }
+}
+
 SqlIntervalQualifier IntervalQualifier() :
 {
-    TimeUnit start;
+    final TimeUnit start;
     TimeUnit end = null;
     int startPrec = RelDataType.PRECISION_NOT_SPECIFIED;
     int secondFracPrec = RelDataType.PRECISION_NOT_SPECIFIED;
 }
 {
     (
-        <YEAR> [ <LPAREN> startPrec = UnsignedIntLiteral() <RPAREN> ]
+        start = Year() [ <LPAREN> startPrec = UnsignedIntLiteral() <RPAREN> ]
         [
-            LOOKAHEAD(2) <TO> <MONTH>
-            {
-                end = TimeUnit.MONTH;
-            }
+            LOOKAHEAD(2) <TO> end = Month()
         ]
-        { start = TimeUnit.YEAR; }
     |
-        <MONTH> [ <LPAREN> startPrec = UnsignedIntLiteral() <RPAREN> ]
-        { start = TimeUnit.MONTH; }
+        start = Month() [ <LPAREN> startPrec = UnsignedIntLiteral() <RPAREN> ]
     |
-        <DAY> [ <LPAREN> startPrec = UnsignedIntLiteral() <RPAREN> ]
+        start = Day() [ <LPAREN> startPrec = UnsignedIntLiteral() <RPAREN> ]
         [ LOOKAHEAD(2) <TO>
             (
-                <HOUR> { end = TimeUnit.HOUR; }
+                end = Hour()
             |
-                <MINUTE> { end = TimeUnit.MINUTE; }
+                end = Minute()
             |
-                <SECOND> { end = TimeUnit.SECOND; }
+                end = Second()
                 [ <LPAREN> secondFracPrec = UnsignedIntLiteral() <RPAREN> ]
             )
         ]
-        { start = TimeUnit.DAY; }
     |
-        <HOUR> [ <LPAREN> startPrec = UnsignedIntLiteral() <RPAREN> ]
+        start = Hour() [ <LPAREN> startPrec = UnsignedIntLiteral() <RPAREN> ]
         [ LOOKAHEAD(2) <TO>
             (
-                <MINUTE> { end = TimeUnit.MINUTE; }
+                end = Minute()
             |
-                <SECOND> { end = TimeUnit.SECOND; }
+                end = Second()
                 [ <LPAREN> secondFracPrec = UnsignedIntLiteral() <RPAREN> ]
             )
         ]
-        { start = TimeUnit.HOUR; }
     |
-        <MINUTE> [ <LPAREN> startPrec = UnsignedIntLiteral() <RPAREN> ]
+        start = Minute() [ <LPAREN> startPrec = UnsignedIntLiteral() <RPAREN> ]
         [ LOOKAHEAD(2) <TO>
             (
-                <SECOND> { end = TimeUnit.SECOND; }
+                end = Second()
                 [ <LPAREN> secondFracPrec = UnsignedIntLiteral() <RPAREN> ]
             )
         ]
-        { start = TimeUnit.MINUTE; }
     |
-        <SECOND>
+        start = Second()
         [   <LPAREN> startPrec = UnsignedIntLiteral()
             [ <COMMA> secondFracPrec = UnsignedIntLiteral() ]
             <RPAREN>
         ]
-        { start = TimeUnit.SECOND; }
     )
     {
         return new SqlIntervalQualifier(start,
@@ -6575,6 +6634,7 @@ SqlPostfixOperator PostfixRowOperator() :
 |   < DATETIME_INTERVAL_CODE: "DATETIME_INTERVAL_CODE" >
 |   < DATETIME_INTERVAL_PRECISION: "DATETIME_INTERVAL_PRECISION" >
 |   < DAY: "DAY" >
+|   < DAYS: "DAYS" >
 |   < DEALLOCATE: "DEALLOCATE" >
 |   < DEC: "DEC" >
 |   < DECADE: "DECADE" >
@@ -6675,6 +6735,7 @@ SqlPostfixOperator PostfixRowOperator() :
 |   < HIERARCHY: "HIERARCHY" >
 |   < HOLD: "HOLD" >
 |   < HOUR: "HOUR" >
+|   < HOURS: "HOURS" >
 |   < IDENTITY: "IDENTITY" >
 |   < IGNORE: "IGNORE" >
 |   < IMMEDIATE: "IMMEDIATE" >
@@ -6762,11 +6823,13 @@ SqlPostfixOperator PostfixRowOperator() :
 |   < MILLENNIUM: "MILLENNIUM" >
 |   < MIN: "MIN" >
 |   < MINUTE: "MINUTE" >
+|   < MINUTES: "MINUTES" >
 |   < MINVALUE: "MINVALUE" >
 |   < MOD: "MOD" >
 |   < MODIFIES: "MODIFIES" >
 |   < MODULE: "MODULE" >
 |   < MONTH: "MONTH" >
+|   < MONTHS: "MONTHS" >
 |   < MORE_: "MORE" >
 |   < MULTISET: "MULTISET" >
 |   < MUMPS: "MUMPS" >
@@ -6921,6 +6984,7 @@ SqlPostfixOperator PostfixRowOperator() :
 |   < SCROLL: "SCROLL" >
 |   < SEARCH: "SEARCH" >
 |   < SECOND: "SECOND" >
+|   < SECONDS: "SECONDS" >
 |   < SECTION: "SECTION" >
 |   < SECURITY: "SECURITY" >
 |   < SEEK: "SEEK" >
@@ -7106,6 +7170,7 @@ SqlPostfixOperator PostfixRowOperator() :
 |   < WRITE: "WRITE" >
 |   < XML: "XML" >
 |   < YEAR: "YEAR" >
+|   < YEARS: "YEARS" >
 |   < ZONE: "ZONE" >
 <#-- additional parser keywords are included here -->
 <#list parser.keywords as keyword>
diff --git a/core/src/main/java/org/apache/calcite/runtime/CalciteResource.java b/core/src/main/java/org/apache/calcite/runtime/CalciteResource.java
index da277e1..9805b4e 100644
--- a/core/src/main/java/org/apache/calcite/runtime/CalciteResource.java
+++ b/core/src/main/java/org/apache/calcite/runtime/CalciteResource.java
@@ -560,6 +560,9 @@ public interface CalciteResource {
   @BaseMessage("Statement preparation aborted")
   ExInst<CalciteException> preparationAborted();
 
+  @BaseMessage("Warning: use of non-standard feature ''{0}''")
+  ExInst<CalciteException> nonStandardFeatureUsed(String feature);
+
   @BaseMessage("SELECT DISTINCT not supported")
   @Property(name = "FeatureDefinition", value = "SQL:2003 Part 2 Annex F")
   Feature sQLFeature_E051_01();
diff --git a/core/src/main/java/org/apache/calcite/sql/parser/SqlAbstractParserImpl.java b/core/src/main/java/org/apache/calcite/sql/parser/SqlAbstractParserImpl.java
index 552031a..8615821 100644
--- a/core/src/main/java/org/apache/calcite/sql/parser/SqlAbstractParserImpl.java
+++ b/core/src/main/java/org/apache/calcite/sql/parser/SqlAbstractParserImpl.java
@@ -17,6 +17,7 @@
 package org.apache.calcite.sql.parser;
 
 import org.apache.calcite.avatica.util.Casing;
+import org.apache.calcite.runtime.CalciteContextException;
 import org.apache.calcite.sql.SqlCall;
 import org.apache.calcite.sql.SqlFunctionCategory;
 import org.apache.calcite.sql.SqlIdentifier;
@@ -36,6 +37,7 @@ import java.io.Reader;
 import java.io.StringReader;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
+import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
@@ -330,6 +332,8 @@ public abstract class SqlAbstractParserImpl {
 
   protected String originalSql;
 
+  protected final List<CalciteContextException> warnings = new ArrayList<>();
+
   //~ Methods ----------------------------------------------------------------
 
   /**
diff --git a/core/src/main/java/org/apache/calcite/sql/parser/SqlParser.java b/core/src/main/java/org/apache/calcite/sql/parser/SqlParser.java
index 61e0a01..32c5b71 100644
--- a/core/src/main/java/org/apache/calcite/sql/parser/SqlParser.java
+++ b/core/src/main/java/org/apache/calcite/sql/parser/SqlParser.java
@@ -30,6 +30,7 @@ import org.apache.calcite.util.SourceStringReader;
 
 import java.io.Reader;
 import java.io.StringReader;
+import java.util.List;
 import java.util.Objects;
 
 /**
@@ -214,6 +215,14 @@ public class SqlParser {
   }
 
   /**
+   * Returns the warnings that were generated by the previous invocation
+   * of the parser.
+   */
+  public List<CalciteContextException> getWarnings() {
+    return parser.warnings;
+  }
+
+  /**
    * Builder for a {@link Config}.
    */
   public static ConfigBuilder configBuilder() {
diff --git a/core/src/main/java/org/apache/calcite/sql/validate/SqlAbstractConformance.java b/core/src/main/java/org/apache/calcite/sql/validate/SqlAbstractConformance.java
index f676419..643ff5c 100644
--- a/core/src/main/java/org/apache/calcite/sql/validate/SqlAbstractConformance.java
+++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlAbstractConformance.java
@@ -102,6 +102,10 @@ public abstract class SqlAbstractConformance implements SqlConformance {
   public boolean allowExtendedTrim() {
     return SqlConformanceEnum.DEFAULT.allowExtendedTrim();
   }
+
+  public boolean allowPluralTimeUnits() {
+    return SqlConformanceEnum.DEFAULT.allowPluralTimeUnits();
+  }
 }
 
 // End SqlAbstractConformance.java
diff --git a/core/src/main/java/org/apache/calcite/sql/validate/SqlConformance.java b/core/src/main/java/org/apache/calcite/sql/validate/SqlConformance.java
index 644886f..b4e68c7 100644
--- a/core/src/main/java/org/apache/calcite/sql/validate/SqlConformance.java
+++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlConformance.java
@@ -393,6 +393,21 @@ public interface SqlConformance {
    * false otherwise.
    */
   boolean allowExtendedTrim();
+
+  /**
+   * Whether interval literals should allow plural time units
+   * such as "YEARS" and "DAYS" in interval literals.
+   *
+   * <p>Under strict behavior, {@code INTERVAL '2' DAY} is valid
+   * and {@code INTERVAL '2' DAYS} is invalid;
+   * PostgreSQL allows both; Oracle only allows singular time units.
+   *
+   * <p>Among the built-in conformance levels, true in
+   * {@link SqlConformanceEnum#BABEL},
+   * {@link SqlConformanceEnum#LENIENT};
+   * false otherwise.
+   */
+  boolean allowPluralTimeUnits();
 }
 
 // End SqlConformance.java
diff --git a/core/src/main/java/org/apache/calcite/sql/validate/SqlConformanceEnum.java b/core/src/main/java/org/apache/calcite/sql/validate/SqlConformanceEnum.java
index 5d9c44e..b2abc84 100644
--- a/core/src/main/java/org/apache/calcite/sql/validate/SqlConformanceEnum.java
+++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlConformanceEnum.java
@@ -304,6 +304,16 @@ public enum SqlConformanceEnum implements SqlConformance {
     }
   }
 
+  @Override public boolean allowPluralTimeUnits() {
+    switch (this) {
+    case BABEL:
+    case LENIENT:
+      return true;
+    default:
+      return false;
+    }
+  }
+
 }
 
 // End SqlConformanceEnum.java
diff --git a/core/src/main/resources/org/apache/calcite/runtime/CalciteResource.properties b/core/src/main/resources/org/apache/calcite/runtime/CalciteResource.properties
index f4852a2..7263d92 100644
--- a/core/src/main/resources/org/apache/calcite/runtime/CalciteResource.properties
+++ b/core/src/main/resources/org/apache/calcite/runtime/CalciteResource.properties
@@ -185,6 +185,7 @@ InvalidDatetimeFormat=''{0}'' is not a valid datetime format
 InsertIntoAlwaysGenerated=Cannot INSERT into generated column ''{0}''
 ArgumentMustHaveScaleZero=Argument to function ''{0}'' must have a scale of 0
 PreparationAborted=Statement preparation aborted
+NonStandardFeatureUsed=Warning: use of non-standard feature ''{0}''
 SQLFeature_E051_01=SELECT DISTINCT not supported
 SQLFeature_E071_03=EXCEPT not supported
 SQLFeature_E101_03=UPDATE not supported
diff --git a/core/src/test/codegen/config.fmpp b/core/src/test/codegen/config.fmpp
index 5be63a4..0667110 100644
--- a/core/src/test/codegen/config.fmpp
+++ b/core/src/test/codegen/config.fmpp
@@ -88,6 +88,7 @@ data: {
       "DATABASE"
       "DATETIME_INTERVAL_CODE"
       "DATETIME_INTERVAL_PRECISION"
+      "DAYS"
       "DECADE"
       "DEFAULTS"
       "DEFERRABLE"
@@ -128,6 +129,7 @@ data: {
       "GOTO"
       "GRANTED"
       "HIERARCHY"
+      "HOURS"
       "IGNORE"
       "IMMEDIATE"
       "IMMEDIATELY"
@@ -164,7 +166,9 @@ data: {
       "MICROSECOND"
       "MILLENNIUM"
       "MILLISECOND"
+      "MINUTES"
       "MINVALUE"
+      "MONTHS"
       "MORE_"
       "MUMPS"
       "NAME"
@@ -231,6 +235,7 @@ data: {
       "SCOPE_CATALOGS"
       "SCOPE_NAME"
       "SCOPE_SCHEMA"
+      "SECONDS"
       "SECTION"
       "SECURITY"
       "SELF"
@@ -336,6 +341,7 @@ data: {
       "WRAPPER"
       "WRITE"
       "XML"
+      "YEARS"
       "ZONE"
     ]
 
diff --git a/core/src/test/java/org/apache/calcite/sql/parser/SqlParserTest.java b/core/src/test/java/org/apache/calcite/sql/parser/SqlParserTest.java
index d2acca0..13f986d 100644
--- a/core/src/test/java/org/apache/calcite/sql/parser/SqlParserTest.java
+++ b/core/src/test/java/org/apache/calcite/sql/parser/SqlParserTest.java
@@ -35,6 +35,7 @@ import org.apache.calcite.sql.validate.SqlConformanceEnum;
 import org.apache.calcite.test.DiffTestCase;
 import org.apache.calcite.util.Bug;
 import org.apache.calcite.util.ConversionUtil;
+import org.apache.calcite.util.Pair;
 import org.apache.calcite.util.SourceStringReader;
 import org.apache.calcite.util.TestUtil;
 import org.apache.calcite.util.Util;
@@ -53,16 +54,20 @@ import org.junit.Test;
 
 import java.io.Reader;
 import java.io.StringReader;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Objects;
 import java.util.SortedSet;
 import java.util.TreeSet;
+import java.util.function.Consumer;
 import java.util.function.UnaryOperator;
 import java.util.stream.Collectors;
 import javax.annotation.Nonnull;
 
+import static org.hamcrest.CoreMatchers.containsString;
 import static org.hamcrest.CoreMatchers.equalTo;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.CoreMatchers.not;
@@ -590,11 +595,11 @@ public class SqlParserTest {
   }
 
   protected Sql sql(String sql) {
-    return new Sql(sql, false, null);
+    return new Sql(sql, false, null, parser -> { });
   }
 
   protected Sql expr(String sql) {
-    return new Sql(sql, true, null);
+    return new Sql(sql, true, null, parser -> { });
   }
 
   /** Creates an instance of helper class {@link SqlList} to test parsing a
@@ -6420,11 +6425,17 @@ public class SqlParserTest {
         .fails("Encountered \"<EOF>\" at line 1, column 12\\.\n"
             + "Was expecting one of:\n"
             + "    \"DAY\" \\.\\.\\.\n"
+            + "    \"DAYS\" \\.\\.\\.\n"
             + "    \"HOUR\" \\.\\.\\.\n"
+            + "    \"HOURS\" \\.\\.\\.\n"
             + "    \"MINUTE\" \\.\\.\\.\n"
+            + "    \"MINUTES\" \\.\\.\\.\n"
             + "    \"MONTH\" \\.\\.\\.\n"
+            + "    \"MONTHS\" \\.\\.\\.\n"
             + "    \"SECOND\" \\.\\.\\.\n"
+            + "    \"SECONDS\" \\.\\.\\.\n"
             + "    \"YEAR\" \\.\\.\\.\n"
+            + "    \"YEARS\" \\.\\.\\.\n"
             + "    ");
 
     // illegal qualifiers, no precision in either field
@@ -6791,6 +6802,45 @@ public class SqlParserTest {
         .fails(ANY);
   }
 
+  /** Tests that plural time units are allowed when not in strict mode. */
+  @Test public void testIntervalPluralUnits() {
+    expr("interval '2' years")
+        .hasWarning(checkWarnings("YEARS"))
+        .ok("INTERVAL '2' YEAR");
+    expr("interval '2:1' years to months")
+        .hasWarning(checkWarnings("YEARS", "MONTHS"))
+        .ok("INTERVAL '2:1' YEAR TO MONTH");
+    expr("interval '2' days")
+        .hasWarning(checkWarnings("DAYS"))
+        .ok("INTERVAL '2' DAY");
+    expr("interval '2:1' days to hours")
+        .hasWarning(checkWarnings("DAYS", "HOURS"))
+        .ok("INTERVAL '2:1' DAY TO HOUR");
+    expr("interval '2:1' day to hours")
+        .hasWarning(checkWarnings("HOURS"))
+        .ok("INTERVAL '2:1' DAY TO HOUR");
+    expr("interval '2:1' days to hour")
+        .hasWarning(checkWarnings("DAYS"))
+        .ok("INTERVAL '2:1' DAY TO HOUR");
+    expr("interval '1:1' minutes to seconds")
+        .hasWarning(checkWarnings("MINUTES", "SECONDS"))
+        .ok("INTERVAL '1:1' MINUTE TO SECOND");
+  }
+
+  @Nonnull private Consumer<List<? extends Throwable>> checkWarnings(
+      String... tokens) {
+    final List<String> messages = new ArrayList<>();
+    for (String token : tokens) {
+      messages.add("Warning: use of non-standard feature '" + token + "'");
+    }
+    return throwables -> {
+      assertThat(throwables.size(), is(messages.size()));
+      for (Pair<? extends Throwable, String> pair : Pair.zip(throwables, messages)) {
+        assertThat(pair.left.getMessage(), containsString(pair.right));
+      }
+    };
+  }
+
   @Test public void testMiscIntervalQualifier() {
     expr("interval '-' day")
         .ok("INTERVAL '-' DAY");
@@ -8535,9 +8585,11 @@ public class SqlParserTest {
   protected interface Tester {
     void checkList(String sql, List<String> expected);
 
-    void check(String sql, SqlDialect dialect, String expected);
+    void check(String sql, SqlDialect dialect, String expected,
+        Consumer<SqlParser> parserChecker);
 
-    void checkExp(String sql, String expected);
+    void checkExp(String sql, String expected,
+        Consumer<SqlParser> parserChecker);
 
     void checkFails(String sql, boolean list, String expectedMsgPattern);
 
@@ -8573,19 +8625,23 @@ public class SqlParserTest {
       }
     }
 
-    public void check(String sql, SqlDialect dialect, String expected) {
+    public void check(String sql, SqlDialect dialect, String expected,
+        Consumer<SqlParser> parserChecker) {
       final SqlNode sqlNode = parseStmtAndHandleEx(sql,
-          dialect == null ? UnaryOperator.identity() : dialect::configureParser);
+          dialect == null ? UnaryOperator.identity() : dialect::configureParser,
+          parserChecker);
       check(sqlNode, dialect, expected);
     }
 
     protected SqlNode parseStmtAndHandleEx(String sql,
-        UnaryOperator<SqlParser.ConfigBuilder> transform) {
+        UnaryOperator<SqlParser.ConfigBuilder> transform,
+        Consumer<SqlParser> parserChecker) {
       final SqlParser parser =
           getSqlParser(new SourceStringReader(sql), transform);
       final SqlNode sqlNode;
       try {
         sqlNode = parser.parseStmt();
+        parserChecker.accept(parser);
       } catch (SqlParseException e) {
         throw new RuntimeException("Error while parsing SQL: " + sql, e);
       }
@@ -8603,18 +8659,20 @@ public class SqlParserTest {
       return sqlNodeList;
     }
 
-    public void checkExp(
-        String sql,
-        String expected) {
-      final SqlNode sqlNode = parseExpressionAndHandleEx(sql);
+    public void checkExp(String sql, String expected,
+        Consumer<SqlParser> parserChecker) {
+      final SqlNode sqlNode = parseExpressionAndHandleEx(sql, parserChecker);
       final String actual = sqlNode.toSqlString(null, true).getSql();
       TestUtil.assertEqualsVerbose(expected, linux(actual));
     }
 
-    protected SqlNode parseExpressionAndHandleEx(String sql) {
+    protected SqlNode parseExpressionAndHandleEx(String sql,
+        Consumer<SqlParser> parserChecker) {
       final SqlNode sqlNode;
       try {
-        sqlNode = getSqlParser(sql).parseExpression();
+        final SqlParser parser = getSqlParser(sql);
+        sqlNode = parser.parseExpression();
+        parserChecker.accept(parser);
       } catch (SqlParseException e) {
         throw new RuntimeException("Error while parsing expression: " + sql, e);
       }
@@ -8735,10 +8793,11 @@ public class SqlParserTest {
       checkList(sqlNodeList2, expected);
     }
 
-    @Override public void check(String sql, SqlDialect dialect,
-        String expected) {
+    @Override public void check(String sql, SqlDialect dialect, String expected,
+        Consumer<SqlParser> parserChecker) {
       SqlNode sqlNode = parseStmtAndHandleEx(sql,
-          dialect == null ? UnaryOperator.identity() : dialect::configureParser);
+          dialect == null ? UnaryOperator.identity() : dialect::configureParser,
+          parserChecker);
 
       // Unparse with the given dialect, always parenthesize.
       final String actual = sqlNode.toSqlString(dialect, true).getSql();
@@ -8754,7 +8813,7 @@ public class SqlParserTest {
       final Quoting q = quoting;
       try {
         quoting = Quoting.DOUBLE_QUOTE;
-        sqlNode2 = parseStmtAndHandleEx(sql1, b -> b);
+        sqlNode2 = parseStmtAndHandleEx(sql1, b -> b, parser -> { });
       } finally {
         quoting = q;
       }
@@ -8771,8 +8830,9 @@ public class SqlParserTest {
       assertEquals(expected, linux(actual2));
     }
 
-    @Override public void checkExp(String sql, String expected) {
-      SqlNode sqlNode = parseExpressionAndHandleEx(sql);
+    @Override public void checkExp(String sql, String expected,
+        Consumer<SqlParser> parserChecker) {
+      SqlNode sqlNode = parseExpressionAndHandleEx(sql, parserChecker);
 
       // Unparse with no dialect, always parenthesize.
       final String actual = sqlNode.toSqlString(null, true).getSql();
@@ -8784,11 +8844,12 @@ public class SqlParserTest {
           sqlNode.toSqlString(CalciteSqlDialect.DEFAULT, false).getSql();
 
       // Parse and unparse again.
+      // (Turn off parser checking, and use double-quotes.)
       SqlNode sqlNode2;
       final Quoting q = quoting;
       try {
         quoting = Quoting.DOUBLE_QUOTE;
-        sqlNode2 = parseExpressionAndHandleEx(sql1);
+        sqlNode2 = parseExpressionAndHandleEx(sql1, parser -> { });
       } finally {
         quoting = q;
       }
@@ -8830,11 +8891,14 @@ public class SqlParserTest {
     private final String sql;
     private final boolean expression;
     private final SqlDialect dialect;
+    private final Consumer<SqlParser> parserChecker;
 
-    Sql(String sql, boolean expression, SqlDialect dialect) {
-      this.sql = sql;
+    Sql(String sql, boolean expression, SqlDialect dialect,
+        Consumer<SqlParser> parserChecker) {
+      this.sql = Objects.requireNonNull(sql);
       this.expression = expression;
       this.dialect = dialect;
+      this.parserChecker = Objects.requireNonNull(parserChecker);
     }
 
     public Sql same() {
@@ -8843,9 +8907,9 @@ public class SqlParserTest {
 
     public Sql ok(String expected) {
       if (expression) {
-        getTester().checkExp(sql, expected);
+        getTester().checkExp(sql, expected, parserChecker);
       } else {
-        getTester().check(sql, dialect, expected);
+        getTester().check(sql, dialect, expected, parserChecker);
       }
       return this;
     }
@@ -8859,6 +8923,11 @@ public class SqlParserTest {
       return this;
     }
 
+    public Sql hasWarning(Consumer<List<? extends Throwable>> messageMatcher) {
+      return new Sql(sql, expression, dialect, parser ->
+          messageMatcher.accept(parser.getWarnings()));
+    }
+
     public Sql node(Matcher<SqlNode> matcher) {
       getTester().checkNode(sql, matcher);
       return this;
@@ -8866,18 +8935,18 @@ public class SqlParserTest {
 
     /** Flags that this is an expression, not a whole query. */
     public Sql expression() {
-      return expression ? this : new Sql(sql, true, dialect);
+      return expression ? this : new Sql(sql, true, dialect, parserChecker);
     }
 
     /** Removes the carets from the SQL string. Useful if you want to run
      * a test once at a conformance level where it fails, then run it again
      * at a conformance level where it succeeds. */
     public Sql sansCarets() {
-      return new Sql(sql.replace("^", ""), expression, dialect);
+      return new Sql(sql.replace("^", ""), expression, dialect, parserChecker);
     }
 
     public Sql withDialect(SqlDialect dialect) {
-      return new Sql(sql, expression, dialect);
+      return new Sql(sql, expression, dialect, parserChecker);
     }
   }
 
diff --git a/server/src/main/codegen/config.fmpp b/server/src/main/codegen/config.fmpp
index c2e9e2a..cbdfa74 100644
--- a/server/src/main/codegen/config.fmpp
+++ b/server/src/main/codegen/config.fmpp
@@ -96,6 +96,7 @@ data: {
       "DATABASE"
       "DATETIME_INTERVAL_CODE"
       "DATETIME_INTERVAL_PRECISION"
+      "DAYS"
       "DECADE"
       "DEFAULTS"
       "DEFERRABLE"
@@ -136,6 +137,7 @@ data: {
       "GOTO"
       "GRANTED"
       "HIERARCHY"
+      "HOURS"
       "IGNORE"
       "IMMEDIATE"
       "IMMEDIATELY"
@@ -172,7 +174,9 @@ data: {
       "MICROSECOND"
       "MILLENNIUM"
       "MILLISECOND"
+      "MINUTES"
       "MINVALUE"
+      "MONTHS"
       "MORE_"
       "MUMPS"
       "NAME"
@@ -239,6 +243,7 @@ data: {
       "SCOPE_CATALOGS"
       "SCOPE_NAME"
       "SCOPE_SCHEMA"
+      "SECONDS"
       "SECTION"
       "SECURITY"
       "SELF"
@@ -344,6 +349,7 @@ data: {
       "WRAPPER"
       "WRITE"
       "XML"
+      "YEARS"
       "ZONE"
     ]
 
diff --git a/site/_docs/reference.md b/site/_docs/reference.md
index 595b443..3390ba9 100644
--- a/site/_docs/reference.md
+++ b/site/_docs/reference.md
@@ -463,6 +463,7 @@ DATABASE,
 DATETIME_INTERVAL_CODE,
 DATETIME_INTERVAL_PRECISION,
 **DAY**,
+DAYS,
 **DEALLOCATE**,
 **DEC**,
 DECADE,
@@ -563,6 +564,7 @@ GRANTED,
 HIERARCHY,
 **HOLD**,
 **HOUR**,
+HOURS,
 **IDENTITY**,
 IGNORE,
 IMMEDIATE,
@@ -651,11 +653,13 @@ MILLISECOND,
 **MIN**,
 **MINUS**,
 **MINUTE**,
+MINUTES,
 MINVALUE,
 **MOD**,
 **MODIFIES**,
 **MODULE**,
 **MONTH**,
+MONTHS,
 MORE,
 **MULTISET**,
 MUMPS,
@@ -810,6 +814,7 @@ SCOPE_SCHEMA,
 **SCROLL**,
 **SEARCH**,
 **SECOND**,
+SECONDS,
 SECTION,
 SECURITY,
 **SEEK**,
@@ -994,6 +999,7 @@ WRAPPER,
 WRITE,
 XML,
 **YEAR**,
+YEARS,
 ZONE.
 {% comment %} end {% endcomment %}
 
@@ -1061,6 +1067,10 @@ Note:
   it will rely on the supplied time zone to provide correct semantics.
 * GEOMETRY is allowed only in certain
   [conformance levels]({{ site.apiRoot }}/org/apache/calcite/sql/validate/SqlConformance.html#allowGeometry--).
+* Interval literals may only use time units
+  YEAR, MONTH, DAY, HOUR, MINUTE and SECOND. In certain
+  [conformance levels]({{ site.apiRoot }}/org/apache/calcite/sql/validate/SqlConformance.html#allowPluralTimeUnits--),
+  we also allow their plurals, YEARS, MONTHS, DAYS, HOURS, MINUTES and SECONDS.
 
 ### Non-scalar types