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

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


The following commit(s) were added to refs/heads/main by this push:
     new ef667b041b [CALCITE-5987] SqlImplementor loses type information for 
literals
ef667b041b is described below

commit ef667b041bb7953c91b601982b5af2b4a75b3e7a
Author: Mihai Budiu <[email protected]>
AuthorDate: Fri Jul 17 17:01:12 2026 -0700

    [CALCITE-5987] SqlImplementor loses type information for literals
    
    Signed-off-by: Mihai Budiu <[email protected]>
---
 .../calcite/rel/rel2sql/RelToSqlConverter.java     |  26 ++-
 .../apache/calcite/rel/rel2sql/SqlImplementor.java | 110 ++++++++-
 .../rel2sql/RelToSqlConverterRoundTripTest.java    |  43 ++++
 .../calcite/rel/rel2sql/RelToSqlConverterTest.java | 249 +++++++++++++++++++--
 4 files changed, 401 insertions(+), 27 deletions(-)

diff --git 
a/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java 
b/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
index a5b8858a3a..7806ab5e75 100644
--- a/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
+++ b/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
@@ -139,9 +139,16 @@ public class RelToSqlConverter extends SqlImplementor
   private final Deque<Frame> stack = new ArrayDeque<>();
 
   /** Creates a RelToSqlConverter. */
-  @SuppressWarnings("argument.type.incompatible")
   public RelToSqlConverter(SqlDialect dialect) {
-    super(dialect);
+    this(dialect, false);
+  }
+
+  /** Creates a RelToSqlConverter; if {@code preserveLiteralTypes}, literals
+   * whose type is not implied by their SQL text are wrapped in CASTs;
+   * see {@link SqlImplementor#toSql(RexProgram, RexLiteral, SqlDialect)}. */
+  @SuppressWarnings("argument.type.incompatible")
+  public RelToSqlConverter(SqlDialect dialect, boolean preserveLiteralTypes) {
+    super(dialect, preserveLiteralTypes);
     dispatcher =
         ReflectUtil.createMethodDispatcher(Result.class, this, "visit",
             RelNode.class);
@@ -1397,18 +1404,25 @@ void offsetFetch(Sort e, Builder builder) {
     }
   }
 
-  private static SqlNode toSqlOffset(Sort sort, Context context) {
+  private SqlNode toSqlOffset(Sort sort, Context context) {
     final RexNode offset = requireNonNull(sort.offset, "offset");
     final @Nullable RexLiteral reduced =
         RexUtil.reduceOffsetToLiteral(sort.getCluster(), offset);
-    return context.toSql(null, reduced == null ? offset : reduced);
+    return offsetFetchToSql(context, reduced == null ? offset : reduced);
   }
 
-  private static SqlNode toSqlFetch(Sort sort, Context context) {
+  private SqlNode toSqlFetch(Sort sort, Context context) {
     final RexNode fetch = requireNonNull(sort.fetch, "fetch");
     final @Nullable RexLiteral reduced =
         RexUtil.reduceFetchToLiteral(sort.getCluster(), fetch);
-    return context.toSql(null, reduced == null ? fetch : reduced);
+    return offsetFetchToSql(context, reduced == null ? fetch : reduced);
+  }
+
+  /** Converts an OFFSET or FETCH expression; these can never have a CAST. */
+  private SqlNode offsetFetchToSql(Context context, RexNode rex) {
+    return preserveLiteralTypes && rex instanceof RexLiteral
+        ? SqlImplementor.toSql(null, (RexLiteral) rex)
+        : context.toSql(null, rex);
   }
 
   public boolean hasTrickyRollup(Sort e, Aggregate aggregate) {
diff --git 
a/core/src/main/java/org/apache/calcite/rel/rel2sql/SqlImplementor.java 
b/core/src/main/java/org/apache/calcite/rel/rel2sql/SqlImplementor.java
index fb003caa95..60b068438a 100644
--- a/core/src/main/java/org/apache/calcite/rel/rel2sql/SqlImplementor.java
+++ b/core/src/main/java/org/apache/calcite/rel/rel2sql/SqlImplementor.java
@@ -101,6 +101,7 @@
 import org.apache.calcite.sql.type.SqlTypeFactoryImpl;
 import org.apache.calcite.sql.type.SqlTypeFamily;
 import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.calcite.sql.type.SqlTypeUtil;
 import org.apache.calcite.sql.util.SqlBasicVisitor;
 import org.apache.calcite.sql.util.SqlShuttle;
 import org.apache.calcite.sql.validate.SqlValidatorUtil;
@@ -140,6 +141,7 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.UUID;
 import java.util.function.Function;
@@ -173,6 +175,12 @@ public abstract class SqlImplementor {
       SqlLiteral.createExactNumeric("1", POS);
 
   public final SqlDialect dialect;
+
+  /** If true, literals whose type is not implied by their SQL text are
+   * wrapped in CASTs that make the type explicit;
+   * see {@link #toSql(RexProgram, RexLiteral, SqlDialect)}. */
+  public final boolean preserveLiteralTypes;
+
   protected final Set<String> aliasSet = new LinkedHashSet<>();
 
   protected final Map<CorrelationId, Context> correlTableMap = new HashMap<>();
@@ -187,7 +195,12 @@ public abstract class SqlImplementor {
       new RexBuilder(new SqlTypeFactoryImpl(RelDataTypeSystemImpl.DEFAULT));
 
   protected SqlImplementor(SqlDialect dialect) {
+    this(dialect, false);
+  }
+
+  protected SqlImplementor(SqlDialect dialect, boolean preserveLiteralTypes) {
     this.dialect = requireNonNull(dialect, "dialect");
+    this.preserveLiteralTypes = preserveLiteralTypes;
   }
 
   /** Visits a relational expression that has no parent. */
@@ -694,6 +707,13 @@ protected Context(SqlDialect dialect, int fieldCount, 
boolean ignoreCast) {
 
     public abstract SqlNode field(int ordinal);
 
+    /** Returns whether literals whose type is not implied by their SQL text
+     * are wrapped in CASTs;
+     * see {@link SqlImplementor#toSql(RexProgram, RexLiteral, SqlDialect)}. */
+    protected boolean preservesLiteralTypes() {
+      return false;
+    }
+
     /** Creates a reference to a field to be used in an ORDER BY clause.
      *
      * <p>By default, it returns the same result as {@link #field}.
@@ -798,7 +818,9 @@ public SqlNode toSql(@Nullable RexProgram program, RexNode 
rex) {
         }
 
       case LITERAL:
-        return SqlImplementor.toSql(program, (RexLiteral) rex);
+        return preservesLiteralTypes()
+            ? SqlImplementor.toSql(program, (RexLiteral) rex, dialect)
+            : SqlImplementor.toSql(program, (RexLiteral) rex);
 
       case CASE:
         final RexCall caseCall = (RexCall) rex;
@@ -1608,6 +1630,77 @@ public static SqlNode toSql(@Nullable RexProgram 
program, RexLiteral literal) {
     }
   }
 
+  /** Converts a {@link RexLiteral} in the context of a {@link RexProgram}
+   * to a {@link SqlNode}, preserving the literal's type.
+   *
+   * <p>The SQL text of a literal does not always imply the literal's type:
+   * {@code 1} parses as INTEGER even if the literal's type is TINYINT, and
+   * {@code NULL} loses its type entirely. This method wraps such literals in
+   * a CAST that makes the type explicit; {@code dialect} supplies the SQL
+   * syntax of the CAST target type. */
+  public static SqlNode toSql(@Nullable RexProgram program, RexLiteral literal,
+      SqlDialect dialect) {
+    switch (literal.getTypeName()) {
+    case ROW:
+      // Cast the fields rather than the ROW call, because few dialects can
+      // parse a cast to a ROW type.
+      //noinspection unchecked
+      final List<RexLiteral> list = 
castNonNull(literal.getValueAs(List.class));
+      return SqlStdOperatorTable.ROW.createCall(POS,
+          list.stream().map(e -> toSql(program, e, dialect))
+              .collect(toImmutableList()));
+
+    case SYMBOL:
+    case SARG:
+      return toSql(program, literal);
+
+    default:
+      final SqlNode node = toSql(program, literal);
+      // A result that is not a SqlLiteral is already a CAST; for example
+      // NaN becomes CAST('NaN' AS DOUBLE).
+      return node instanceof SqlLiteral
+          ? castIfTypeAmbiguous((SqlLiteral) node, literal.getType(), dialect)
+          : node;
+    }
+  }
+
+  /** Wraps a literal in a CAST to {@code type} if the type that the
+   * validator would infer for the literal's SQL text differs from
+   * {@code type}. */
+  private static SqlNode castIfTypeAmbiguous(SqlLiteral literal, RelDataType 
type,
+      SqlDialect dialect) {
+    switch (type.getSqlTypeName()) {
+    case NULL:
+    case ANY:
+    case UNKNOWN:
+      // No valid SQL syntax for casts to these types
+      return literal;
+    default:
+      break;
+    }
+    if (typeMatches(literal, type)) {
+      return literal;
+    }
+    final SqlNode castSpec = dialect.getCastSpec(type);
+    if (castSpec == null) {
+      return literal;
+    }
+    return SqlStdOperatorTable.CAST.createCall(POS, literal, castSpec);
+  }
+
+  /** Returns whether the type that the validator infers for {@code literal}'s
+   * SQL text matches {@code type}. Ignores nullability, and collation. */
+  private static boolean typeMatches(SqlLiteral literal, RelDataType type) {
+    final RelDataTypeFactory typeFactory = RexBuilder.DEFAULT.getTypeFactory();
+    final RelDataType impliedType = literal.createSqlType(typeFactory);
+    if (SqlTypeUtil.isCharacter(impliedType) && SqlTypeUtil.isCharacter(type)) 
{
+      return impliedType.getSqlTypeName() == type.getSqlTypeName()
+          && impliedType.getPrecision() == type.getPrecision()
+          && Objects.equals(impliedType.getCharset(), type.getCharset());
+    }
+    return SqlTypeUtil.equalSansNullability(typeFactory, impliedType, type);
+  }
+
   /** Converts a {@link RexLiteral} to a {@link SqlLiteral}. */
   public static SqlNode toSql(RexLiteral literal) {
     SqlTypeName typeName = literal.getTypeName();
@@ -1724,10 +1817,21 @@ protected Context getAliasContext(RexCorrelVariable 
variable) {
    * to use it. It is a good way to convert a {@link RexNode} to SQL text. */
   public static class SimpleContext extends Context {
     private final IntFunction<SqlNode> field;
+    private final boolean preserveLiteralTypes;
 
     public SimpleContext(SqlDialect dialect, IntFunction<SqlNode> field) {
+      this(dialect, field, false);
+    }
+
+    public SimpleContext(SqlDialect dialect, IntFunction<SqlNode> field,
+        boolean preserveLiteralTypes) {
       super(dialect, 0, false);
       this.field = field;
+      this.preserveLiteralTypes = preserveLiteralTypes;
+    }
+
+    @Override protected boolean preservesLiteralTypes() {
+      return preserveLiteralTypes;
     }
 
     @Override public SqlImplementor implementor() {
@@ -1750,6 +1854,10 @@ protected abstract class BaseContext extends Context {
       return SqlImplementor.this.getAliasContext(variable);
     }
 
+    @Override protected boolean preservesLiteralTypes() {
+      return preserveLiteralTypes;
+    }
+
     @Override public SqlImplementor implementor() {
       return SqlImplementor.this;
     }
diff --git 
a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterRoundTripTest.java
 
b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterRoundTripTest.java
new file mode 100644
index 0000000000..eeef73b294
--- /dev/null
+++ 
b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterRoundTripTest.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rel2sql;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Runs every test of {@link RelToSqlConverterTest} through the round trip
+ * SQL &rarr; Rel &rarr; SQL &rarr; Rel &rarr; SQL
+ * and checks that the second and third SQL are the same.
+ *
+ * <p>Tests whose Calcite-dialect output cannot be parsed or validated are 
skipped.
+ */
+class RelToSqlConverterRoundTripTest extends RelToSqlConverterTest {
+  @Override Sql fixture() {
+    return super.fixture().withRoundTrip();
+  }
+
+  @Disabled("SUM(DISTINCT) OVER expands into a deeper CASE on every re-parse,"
+      + " so the conversion never reaches a fixed point")
+  @Test @Override void testConvertWindowToSql() {
+  }
+
+  @Disabled("UNION ALL gains a subquery alias only on the second"
+      + " re-parse, so the second and third SQL differ")
+  @Test @Override void testThreeQueryUnion() {
+  }
+}
diff --git 
a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java 
b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java
index 779b320574..8e2b6efb53 100644
--- 
a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java
+++ 
b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java
@@ -34,6 +34,7 @@
 import org.apache.calcite.rel.hint.RelHint;
 import org.apache.calcite.rel.logical.LogicalAggregate;
 import org.apache.calcite.rel.logical.LogicalFilter;
+import org.apache.calcite.rel.logical.LogicalSort;
 import org.apache.calcite.rel.rules.AggregateGroupingSetsToUnionRule;
 import org.apache.calcite.rel.rules.AggregateJoinTransposeRule;
 import org.apache.calcite.rel.rules.AggregateProjectMergeRule;
@@ -49,7 +50,9 @@
 import org.apache.calcite.rel.type.RelDataTypeFactory;
 import org.apache.calcite.rel.type.RelDataTypeSystem;
 import org.apache.calcite.rel.type.RelDataTypeSystemImpl;
+import org.apache.calcite.rex.RexBuilder;
 import org.apache.calcite.rex.RexCorrelVariable;
+import org.apache.calcite.rex.RexLiteral;
 import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.runtime.FlatLists;
 import org.apache.calcite.runtime.Hook;
@@ -111,6 +114,7 @@
 
 import org.checkerframework.checker.nullness.qual.Nullable;
 import org.junit.jupiter.api.Test;
+import org.opentest4j.TestAbortedException;
 
 import java.math.BigDecimal;
 import java.util.Collection;
@@ -137,7 +141,7 @@
  */
 class RelToSqlConverterTest {
 
-  private Sql fixture() {
+  Sql fixture() {
     return new Sql(CalciteAssert.SchemaSpec.JDBC_FOODMART, "?",
         CalciteSqlDialect.DEFAULT, SqlParser.Config.DEFAULT, ImmutableSet.of(),
         UnaryOperator.identity(), null, ImmutableList.of(), 
StandardConvertletTable.INSTANCE);
@@ -288,6 +292,19 @@ private static String toSql(RelNode root, SqlDialect 
dialect,
         .getSql();
   }
 
+  /** Converts a relational expression to SQL in a given dialect, wrapping
+   * literals whose type is not implied by their SQL text in CASTs. */
+  private static String toSqlPreservingLiteralTypes(RelNode root, SqlDialect 
dialect) {
+    final RelToSqlConverter converter = new RelToSqlConverter(dialect, true);
+    final SqlNode sqlNode = converter.visitRoot(root).asStatement();
+    return sqlNode.toSqlString(c ->
+        c.withDialect(dialect)
+            .withAlwaysUseParentheses(false)
+            .withSelectListItemsOnSeparateLines(false)
+            .withUpdateSetListNewline(false)
+            .withIndentation(0)).getSql();
+  }
+
   /**
    * Test for <a 
href="https://issues.apache.org/jira/browse/CALCITE-5988";>[CALCITE-5988]</a>
    * SqlImplementor.toSql cannot emit VARBINARY literals.
@@ -308,6 +325,143 @@ private static String toSql(RelNode root, SqlDialect 
dialect,
     sql(query).withMysql().ok(expected);
   }
 
+  /** Creates a relational expression that projects literals of many types. */
+  private static RelNode projectOfLiterals() {
+    final RelBuilder b = relBuilder();
+    final RelDataTypeFactory typeFactory = b.getTypeFactory();
+    final RexBuilder rexBuilder = b.getRexBuilder();
+    return b
+        .scan("EMP")
+        .project(
+            rexBuilder.makeLiteral(1,
+                typeFactory.createSqlType(SqlTypeName.TINYINT)),
+            rexBuilder.makeLiteral(1,
+                typeFactory.createSqlType(SqlTypeName.SMALLINT)),
+            rexBuilder.makeLiteral(1,
+                typeFactory.createSqlType(SqlTypeName.INTEGER)),
+            rexBuilder.makeLiteral(1,
+                typeFactory.createSqlType(SqlTypeName.BIGINT)),
+            rexBuilder.makeLiteral(new BigDecimal("1.50"),
+                typeFactory.createSqlType(SqlTypeName.DECIMAL, 10, 2)),
+            rexBuilder.makeLiteral(0.5,
+                typeFactory.createSqlType(SqlTypeName.REAL)),
+            rexBuilder.makeLiteral(0.5,
+                typeFactory.createSqlType(SqlTypeName.DOUBLE)),
+            // makeCast folds the cast into a literal with type VARCHAR(10)
+            rexBuilder.makeCast(
+                typeFactory.createSqlType(SqlTypeName.VARCHAR, 10),
+                rexBuilder.makeLiteral("abc")),
+            rexBuilder.makeLiteral("abc",
+                typeFactory.createSqlType(SqlTypeName.CHAR, 3)),
+            rexBuilder.makeNullLiteral(
+                typeFactory.createSqlType(SqlTypeName.INTEGER)),
+            b.literal(true))
+        .build();
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-5987";>[CALCITE-5987]
+   * SqlImplementor loses type information for literals</a>.  */
+  @Test void testPreserveLiteralTypes() {
+    final RelNode root = projectOfLiterals();
+    final SqlDialect dialect = DatabaseProduct.CALCITE.getDialect();
+    // Without the option, every literal except the NULL loses its type;
+    // visit(Project) casts NULL literals regardless of the option
+    final String expected = "SELECT 1 AS \"$f0\", 1 AS \"$f1\", 1 AS \"$f2\","
+        + " 1 AS \"$f3\", 1.50 AS \"$f4\", 5E-1 AS \"$f5\", 5E-1 AS \"$f6\","
+        + " 'abc' AS \"$f7\", 'abc' AS \"$f8\", CAST(NULL AS INTEGER) AS 
\"$f9\","
+        + " TRUE AS \"$f10\"\n"
+        + "FROM \"scott\".\"EMP\"";
+    assertThat(toSql(root, dialect), isLinux(expected));
+    // With the option, literals whose SQL text parses to a different type
+    // use a cast
+    final String expectedPreserved = "SELECT"
+        + " CAST(1 AS TINYINT) AS \"$f0\","
+        + " CAST(1 AS SMALLINT) AS \"$f1\","
+        + " 1 AS \"$f2\","
+        + " CAST(1 AS BIGINT) AS \"$f3\","
+        + " CAST(1.50 AS DECIMAL(10, 2)) AS \"$f4\","
+        + " CAST(5E-1 AS REAL) AS \"$f5\","
+        + " 5E-1 AS \"$f6\","
+        + " CAST('abc' AS VARCHAR(10) CHARACTER SET \"ISO-8859-1\") AS 
\"$f7\","
+        + " 'abc' AS \"$f8\","
+        + " CAST(NULL AS INTEGER) AS \"$f9\","
+        + " TRUE AS \"$f10\"\n"
+        + "FROM \"scott\".\"EMP\"";
+    assertThat(toSqlPreservingLiteralTypes(root, dialect),
+        isLinux(expectedPreserved));
+  }
+
+  /** As {@link #testPreserveLiteralTypes()}, but for literals in a VALUES
+   * clause. */
+  @Test void testPreserveLiteralTypesValues() {
+    final RelBuilder b = relBuilder();
+    final RelDataTypeFactory typeFactory = b.getTypeFactory();
+    final RexBuilder rexBuilder = b.getRexBuilder();
+    final RelDataType tinyint = typeFactory.createSqlType(SqlTypeName.TINYINT);
+    final RelDataType varchar5 =
+        typeFactory.createSqlType(SqlTypeName.VARCHAR, 5);
+    final RelDataType rowType = typeFactory.builder()
+        .add("a", tinyint)
+        .add("b", varchar5)
+        .build();
+    final RelNode root = b
+        .values(
+            ImmutableList.of(
+                ImmutableList.of(rexBuilder.makeLiteral(1, tinyint),
+                    (RexLiteral) rexBuilder.makeCast(varchar5,
+                        rexBuilder.makeLiteral("x"))),
+                ImmutableList.of(rexBuilder.makeLiteral(2, tinyint),
+                    (RexLiteral) rexBuilder.makeCast(varchar5,
+                        rexBuilder.makeLiteral("y")))),
+            rowType)
+        .build();
+    final SqlDialect dialect = DatabaseProduct.CALCITE.getDialect();
+    final String expectedPreserved = "SELECT *\n"
+        + "FROM (VALUES"
+        + " (CAST(1 AS TINYINT),"
+        + " CAST('x' AS VARCHAR(5) CHARACTER SET \"ISO-8859-1\")),\n"
+        + "(CAST(2 AS TINYINT),"
+        + " CAST('y' AS VARCHAR(5) CHARACTER SET \"ISO-8859-1\")))"
+        + " AS \"t\" (\"a\", \"b\")";
+    assertThat(toSqlPreservingLiteralTypes(root, dialect),
+        isLinux(expectedPreserved));
+  }
+
+  /** Parses a SQL query and converts it to a relational expression. */
+  private static RelNode sqlToRel(String sql, SchemaPlus defaultSchema,
+      SqlParser.Config parserConfig, Set<SqlLibrary> librarySet,
+      SqlToRelConverter.Config config, SqlDialect dialect,
+      SqlRexConvertletTable convertletTable) throws Exception {
+    final Planner planner =
+        getPlanner(null, parserConfig, defaultSchema, config, librarySet,
+            dialect.getTypeSystem(), convertletTable);
+    final SqlNode parse = planner.parse(sql);
+    final SqlNode validate = planner.validate(parse);
+    return planner.rel(validate).project();
+  }
+
+  /** As {@link #testPreserveLiteralTypes()}, but the type of a FETCH or
+   * OFFSET literal carries no information, so those literals are never
+   * cast, whatever their type. */
+  @Test void testPreserveLiteralTypesFetchOffset() {
+    final RelBuilder b = relBuilder();
+    final RelDataTypeFactory typeFactory = b.getTypeFactory();
+    final RexBuilder rexBuilder = b.getRexBuilder();
+    final RelDataType bigint = typeFactory.createSqlType(SqlTypeName.BIGINT);
+    final RelNode root =
+        LogicalSort.create(b.scan("EMP").build(), RelCollations.EMPTY,
+            rexBuilder.makeLiteral(2, bigint),
+            rexBuilder.makeLiteral(3, bigint));
+    final SqlDialect dialect = DatabaseProduct.CALCITE.getDialect();
+    final String expectedPreserved = "SELECT *\n"
+        + "FROM \"scott\".\"EMP\"\n"
+        + "OFFSET 2 ROWS\n"
+        + "FETCH NEXT 3 ROWS ONLY";
+    assertThat(toSqlPreservingLiteralTypes(root, dialect),
+        isLinux(expectedPreserved));
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-2152";>[CALCITE-2152]
    * SQL parser unable to parse SQL with nested joins produced by 
RelToSqlConverter</a>. */
@@ -12306,6 +12460,10 @@ static class Sql {
     private final SqlParser.Config parserConfig;
     private final UnaryOperator<SqlToRelConverter.Config> config;
     private final SqlRexConvertletTable convertletTable;
+    /** If true, {@link #exec()} additionally checks that Calcite-dialect
+     * output round-trips when literal types are preserved;
+     * see {@link #checkRoundTrip(RelNode, SchemaPlus)}. */
+    private final boolean roundTrip;
 
     Sql(CalciteAssert.SchemaSpec schemaSpec, String sql, SqlDialect dialect,
         SqlParser.Config parserConfig, Set<SqlLibrary> librarySet,
@@ -12313,6 +12471,17 @@ static class Sql {
         @Nullable Function<RelBuilder, RelNode> relFn,
         List<Function<RelNode, RelNode>> transforms,
         SqlRexConvertletTable convertletTable) {
+      this(schemaSpec, sql, dialect, parserConfig, librarySet, config, relFn,
+          transforms, convertletTable, false);
+    }
+
+    Sql(CalciteAssert.SchemaSpec schemaSpec, String sql, SqlDialect dialect,
+        SqlParser.Config parserConfig, Set<SqlLibrary> librarySet,
+        UnaryOperator<SqlToRelConverter.Config> config,
+        @Nullable Function<RelBuilder, RelNode> relFn,
+        List<Function<RelNode, RelNode>> transforms,
+        SqlRexConvertletTable convertletTable,
+        boolean roundTrip) {
       this.schemaSpec = schemaSpec;
       this.sql = sql;
       this.dialect = dialect;
@@ -12322,21 +12491,22 @@ static class Sql {
       this.parserConfig = parserConfig;
       this.config = config;
       this.convertletTable = convertletTable;
+      this.roundTrip = roundTrip;
     }
 
     Sql withSql(String sql) {
       return new Sql(schemaSpec, sql, dialect, parserConfig, librarySet, 
config,
-          relFn, transforms, convertletTable);
+          relFn, transforms, convertletTable, roundTrip);
     }
 
     Sql dialect(SqlDialect dialect) {
       return new Sql(schemaSpec, sql, dialect, parserConfig, librarySet, 
config,
-          relFn, transforms, convertletTable);
+          relFn, transforms, convertletTable, roundTrip);
     }
 
     Sql relFn(Function<RelBuilder, RelNode> relFn) {
       return new Sql(schemaSpec, sql, dialect, parserConfig, librarySet, 
config,
-          relFn, transforms, convertletTable);
+          relFn, transforms, convertletTable, roundTrip);
     }
 
     Sql withCalcite() {
@@ -12579,12 +12749,12 @@ Sql withOracleModifiedTypeSystem() {
 
     Sql parserConfig(SqlParser.Config parserConfig) {
       return new Sql(schemaSpec, sql, dialect, parserConfig, librarySet, 
config,
-          relFn, transforms, convertletTable);
+          relFn, transforms, convertletTable, roundTrip);
     }
 
     Sql withConfig(UnaryOperator<SqlToRelConverter.Config> config) {
       return new Sql(schemaSpec, sql, dialect, parserConfig, librarySet, 
config,
-          relFn, transforms, convertletTable);
+          relFn, transforms, convertletTable, roundTrip);
     }
 
     final Sql withLibrary(SqlLibrary library) {
@@ -12593,7 +12763,7 @@ final Sql withLibrary(SqlLibrary library) {
 
     Sql withLibrarySet(Iterable<? extends SqlLibrary> librarySet) {
       return new Sql(schemaSpec, sql, dialect, parserConfig,
-          ImmutableSet.copyOf(librarySet), config, relFn, transforms, 
convertletTable);
+          ImmutableSet.copyOf(librarySet), config, relFn, transforms, 
convertletTable, roundTrip);
     }
 
     Sql optimize(final RuleSet ruleSet,
@@ -12610,12 +12780,20 @@ Sql optimize(final RuleSet ruleSet,
                 ImmutableList.of(), ImmutableList.of());
           });
       return new Sql(schemaSpec, sql, dialect, parserConfig, librarySet, 
config,
-          relFn, transforms, convertletTable);
+          relFn, transforms, convertletTable, roundTrip);
     }
 
     Sql withConvertletTable(SqlRexConvertletTable convertletTable) {
       return new Sql(schemaSpec, sql, dialect, parserConfig, librarySet, 
config,
-          relFn, transforms, convertletTable);
+          relFn, transforms, convertletTable, roundTrip);
+    }
+
+    /** Returns a copy of this Sql whose {@link #exec()} also checks the
+     * round trip of Calcite-dialect output;
+     * see {@link #checkRoundTrip(RelNode, SchemaPlus)}. */
+    Sql withRoundTrip() {
+      return new Sql(schemaSpec, sql, dialect, parserConfig, librarySet,
+          config, relFn, transforms, convertletTable, true);
     }
 
     Sql ok(String expectedQuery) {
@@ -12647,28 +12825,59 @@ String exec() {
           final RelBuilder relBuilder = RelBuilder.create(frameworkConfig);
           rel = relFn.apply(relBuilder);
         } else {
-          final SqlToRelConverter.Config config = 
this.config.apply(SqlToRelConverter.config()
-              .withTrimUnusedFields(false));
-          RelDataTypeSystem typeSystem = dialect.getTypeSystem();
-          final Planner planner =
-              getPlanner(null, parserConfig, defaultSchema, config, 
librarySet, typeSystem,
-                  convertletTable);
-          SqlNode parse = planner.parse(sql);
-          SqlNode validate = planner.validate(parse);
-          rel = planner.rel(validate).project();
+          rel =
+          sqlToRel(sql, defaultSchema, parserConfig, librarySet,
+            sqlToRelConverterConfig(), dialect, convertletTable);
         }
         for (Function<RelNode, RelNode> transform : transforms) {
           rel = transform.apply(rel);
         }
-        return toSql(rel, dialect);
+        final String result = toSql(rel, dialect);
+        if (roundTrip && dialect instanceof CalciteSqlDialect) {
+          checkRoundTrip(rel, defaultSchema);
+        }
+        return result;
       } catch (Exception e) {
         throw TestUtil.rethrow(e);
       }
     }
 
+    /** Checks the round trip Rel &rarr; SQL1 &rarr; Rel1 &rarr; SQL2 &rarr;
+     * Rel2 &rarr; SQL3, where every conversion preserves literal types:
+     * SQL3 must equal SQL2. */
+    private void checkRoundTrip(RelNode rel, SchemaPlus defaultSchema) {
+      final String sql1 = toSqlPreservingLiteralTypes(rel, dialect);
+      final RelNode rel1 = parseBack(sql1, defaultSchema);
+      final String sql2 = toSqlPreservingLiteralTypes(rel1, dialect);
+      final RelNode rel2 = parseBack(sql2, defaultSchema);
+      final String sql3 = toSqlPreservingLiteralTypes(rel2, dialect);
+      assertThat(sql3, is(sql2));
+    }
+
+    /** Parses SQL generated for the Calcite dialect and converts it back to
+     * a relational expression. Aborts the test if the SQL cannot be parsed
+     * or validated. */
+    private RelNode parseBack(String sql, SchemaPlus defaultSchema) {
+      try {
+        return sqlToRel(sql, defaultSchema, SqlParser.Config.DEFAULT,
+            librarySet, sqlToRelConverterConfig(), dialect, convertletTable);
+      } catch (Exception | AssertionError e) {
+        throw new TestAbortedException("cannot re-parse: " + sql, e);
+      }
+    }
+
+    /** Materializes the SQL-to-rel configuration for this test: the default
+     * configuration, with field trimming disabled so that the rel keeps the
+     * shape that the test expects, transformed by the operator that the test
+     * supplied to {@link #withConfig}. */
+    private SqlToRelConverter.Config sqlToRelConverterConfig() {
+      return this.config.apply(SqlToRelConverter.config()
+          .withTrimUnusedFields(false));
+    }
+
     public Sql schema(CalciteAssert.SchemaSpec schemaSpec) {
       return new Sql(schemaSpec, sql, dialect, parserConfig, librarySet, 
config,
-          relFn, transforms, convertletTable);
+          relFn, transforms, convertletTable, roundTrip);
     }
   }
 

Reply via email to