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

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

commit 2fefd69b01f9658bdede98c7a18d1a5f32f0b412
Author: Mihai Budiu <[email protected]>
AuthorDate: Tue Sep 3 23:34:05 2024 -0700

    [CALCITE-4918] Add a VARIANT data type - runtime support
    
    Signed-off-by: Mihai Budiu <[email protected]>
---
 .../adapter/enumerable/RexToLixTranslator.java     |  20 ++
 .../java/org/apache/calcite/rex/RexBuilder.java    |   7 +-
 .../java/org/apache/calcite/rex/RexLiteral.java    |   3 +
 .../org/apache/calcite/runtime/SqlFunctions.java   |   4 +
 .../apache/calcite/sql/fun/SqlCastFunction.java    |  19 +-
 .../apache/calcite/sql/fun/SqlItemOperator.java    |   4 +-
 .../sql/type/JavaToSqlTypeConversionRules.java     |   1 -
 .../calcite/sql/type/SqlTypeCoercionRule.java      |  29 +--
 .../org/apache/calcite/sql/type/SqlTypeName.java   |   1 -
 .../org/apache/calcite/sql/type/SqlTypeUtil.java   |   6 +-
 .../org/apache/calcite/util/BuiltInMethod.java     |   4 +-
 .../main/java/org/apache/calcite/util/Variant.java | 181 ++++++++++++++++
 .../apache/calcite/util/rtti/BasicSqlTypeRtti.java | 113 ++++++++++
 .../calcite/util/rtti/GenericSqlTypeRtti.java      |  75 +++++++
 .../apache/calcite/util/rtti/RowSqlTypeRtti.java   |  70 +++++++
 .../calcite/util/rtti/RuntimeTypeInformation.java  | 228 +++++++++++++++++++++
 .../org/apache/calcite/util/rtti/package-info.java |  21 ++
 .../calcite/jdbc/CalciteRemoteDriverTest.java      |   2 +-
 .../org/apache/calcite/test/SqlValidatorTest.java  |  11 +-
 site/_docs/reference.md                            |  31 +++
 .../apache/calcite/sql/parser/SqlParserTest.java   |   1 +
 .../org/apache/calcite/test/SqlOperatorTest.java   |  66 +++++-
 22 files changed, 856 insertions(+), 41 deletions(-)

diff --git 
a/core/src/main/java/org/apache/calcite/adapter/enumerable/RexToLixTranslator.java
 
b/core/src/main/java/org/apache/calcite/adapter/enumerable/RexToLixTranslator.java
index 206da0307b..0cebb750e2 100644
--- 
a/core/src/main/java/org/apache/calcite/adapter/enumerable/RexToLixTranslator.java
+++ 
b/core/src/main/java/org/apache/calcite/adapter/enumerable/RexToLixTranslator.java
@@ -66,6 +66,8 @@ import org.apache.calcite.util.BuiltInMethod;
 import org.apache.calcite.util.ControlFlowException;
 import org.apache.calcite.util.Pair;
 import org.apache.calcite.util.Util;
+import org.apache.calcite.util.Variant;
+import org.apache.calcite.util.rtti.RuntimeTypeInformation;
 
 import com.google.common.base.CaseFormat;
 import com.google.common.collect.ImmutableList;
@@ -313,7 +315,25 @@ public class RexToLixTranslator implements 
RexVisitor<RexToLixTranslator.Result>
     final Supplier<Expression> defaultExpression = () ->
         EnumUtils.convert(operand, typeFactory.getJavaClass(targetType));
 
+    if (sourceType.getSqlTypeName() == SqlTypeName.VARIANT) {
+      // Converting VARIANT to VARIANT uses the default conversion
+      if (targetType.getSqlTypeName() == SqlTypeName.VARIANT) {
+        return defaultExpression.get();
+      }
+      // Converting a VARIANT to any other type calls the Variant.cast method
+      Expression cast =
+          Expressions.call(BuiltInMethod.VARIANT_CAST.method, operand,
+              RuntimeTypeInformation.createExpression(targetType));
+      // The cast returns an Object, so we need a convert too
+      RelDataType nullableTarget = 
typeFactory.createTypeWithNullability(targetType, true);
+      return Expressions.convert_(cast, 
typeFactory.getJavaClass(nullableTarget));
+    }
+
     switch (targetType.getSqlTypeName()) {
+    case VARIANT:
+      // Converting any type to a VARIANT invokes the Variant constructor
+      Expression rtti = RuntimeTypeInformation.createExpression(sourceType);
+      return Expressions.new_(Variant.class, operand, rtti);
     case ANY:
       return operand;
 
diff --git a/core/src/main/java/org/apache/calcite/rex/RexBuilder.java 
b/core/src/main/java/org/apache/calcite/rex/RexBuilder.java
index b1291af74c..a3a5248ba5 100644
--- a/core/src/main/java/org/apache/calcite/rex/RexBuilder.java
+++ b/core/src/main/java/org/apache/calcite/rex/RexBuilder.java
@@ -196,6 +196,10 @@ public class RexBuilder {
   public RexNode makeFieldAccess(RexNode expr, String fieldName,
       boolean caseSensitive) {
     final RelDataType type = expr.getType();
+    if (type.getSqlTypeName() == SqlTypeName.VARIANT) {
+      // VARIANT.field is rewritten as an VARIANT[field]
+      return this.makeCall(SqlStdOperatorTable.ITEM, expr, 
this.makeLiteral(fieldName));
+    }
     final RelDataTypeField field =
         type.getField(fieldName, caseSensitive, false);
     if (field == null) {
@@ -855,7 +859,8 @@ public class RexBuilder {
       return true;
     }
     final SqlTypeName sqlType = toType.getSqlTypeName();
-    if (sqlType == SqlTypeName.MEASURE) {
+    if (sqlType == SqlTypeName.MEASURE
+        || sqlType == SqlTypeName.VARIANT) {
       return false;
     }
     if (!RexLiteral.valueMatchesType(value, sqlType, false)) {
diff --git a/core/src/main/java/org/apache/calcite/rex/RexLiteral.java 
b/core/src/main/java/org/apache/calcite/rex/RexLiteral.java
index 1d95b0b3ed..69230a1ec9 100644
--- a/core/src/main/java/org/apache/calcite/rex/RexLiteral.java
+++ b/core/src/main/java/org/apache/calcite/rex/RexLiteral.java
@@ -43,6 +43,7 @@ import org.apache.calcite.util.TimeWithTimeZoneString;
 import org.apache.calcite.util.TimestampString;
 import org.apache.calcite.util.TimestampWithTimeZoneString;
 import org.apache.calcite.util.Util;
+import org.apache.calcite.util.Variant;
 
 import com.google.common.collect.ImmutableList;
 
@@ -315,6 +316,8 @@ public class RexLiteral extends RexNode {
       return true;
     }
     switch (typeName) {
+    case VARIANT:
+      return value instanceof Variant;
     case BOOLEAN:
       // Unlike SqlLiteral, we do not allow boolean null.
       return value instanceof Boolean;
diff --git a/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java 
b/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java
index 9946d6ab84..a5959c3a3a 100644
--- a/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java
+++ b/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java
@@ -47,6 +47,7 @@ import org.apache.calcite.util.TimestampWithTimeZoneString;
 import org.apache.calcite.util.TryThreadLocal;
 import org.apache.calcite.util.Unsafe;
 import org.apache.calcite.util.Util;
+import org.apache.calcite.util.Variant;
 import org.apache.calcite.util.format.FormatElement;
 import org.apache.calcite.util.format.FormatModel;
 import org.apache.calcite.util.format.FormatModels;
@@ -5766,6 +5767,9 @@ public class SqlFunctions {
    * known until runtime.
    */
   public static @Nullable Object item(Object object, Object index) {
+    if (object instanceof Variant) {
+      return ((Variant) object).item(index);
+    }
     if (object instanceof Map) {
       return mapItem((Map) object, index);
     }
diff --git a/core/src/main/java/org/apache/calcite/sql/fun/SqlCastFunction.java 
b/core/src/main/java/org/apache/calcite/sql/fun/SqlCastFunction.java
index 8e3388236f..93bcf284a9 100644
--- a/core/src/main/java/org/apache/calcite/sql/fun/SqlCastFunction.java
+++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlCastFunction.java
@@ -142,6 +142,19 @@ public class SqlCastFunction extends SqlFunction {
       RelDataType expressionType, RelDataType targetType, boolean safe) {
     boolean isNullable = expressionType.isNullable() || safe;
 
+    if (targetType.getSqlTypeName() == SqlTypeName.VARIANT) {
+      // A variant can be cast from any other type, and it inherits
+      // the nullability of the source.
+      // Note that the order of this test and the next one is important.
+      return typeFactory.createTypeWithNullability(targetType, 
expressionType.isNullable());
+    }
+
+    if (expressionType.getSqlTypeName() == SqlTypeName.VARIANT) {
+      // A variant can be cast to any other type, but the result
+      // is always nullable, like in the case of a safe cast.
+      return typeFactory.createTypeWithNullability(targetType, true);
+    }
+
     if (isCollection(expressionType)) {
       RelDataType expressionElementType = expressionType.getComponentType();
       RelDataType targetElementType = targetType.getComponentType();
@@ -190,12 +203,6 @@ public class SqlCastFunction extends SqlFunction {
       SqlTypeUtil.createMapType(typeFactory, keyType, valueType, isNullable);
     }
 
-    if (expressionType.getSqlTypeName() == SqlTypeName.VARIANT) {
-      // A variant can be cast to any other type, but the result
-      // is always nullable, like in the case of a safe cast.
-      return typeFactory.createTypeWithNullability(targetType, true);
-    }
-
     return typeFactory.createTypeWithNullability(targetType, isNullable);
   }
 
diff --git a/core/src/main/java/org/apache/calcite/sql/fun/SqlItemOperator.java 
b/core/src/main/java/org/apache/calcite/sql/fun/SqlItemOperator.java
index fc832a64cc..dffaa91389 100644
--- a/core/src/main/java/org/apache/calcite/sql/fun/SqlItemOperator.java
+++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlItemOperator.java
@@ -137,8 +137,8 @@ public class SqlItemOperator extends SqlSpecialOperator {
     if (name.equals("ITEM")) {
       return "<ARRAY>[<INTEGER>]\n"
           + "<MAP>[<ANY>]\n"
-          + "<ROW>[<CHARACTER>|<INTEGER>]"
-          + "<VARIANT>[<CHARACTER>|<INTEGER>]";
+          + "<ROW>[<CHARACTER>|<INTEGER>]\n"
+          + "<VARIANT>[<ANY>]";
     } else {
       return "<ARRAY>[" + name + "(<INTEGER>)]";
     }
diff --git 
a/core/src/main/java/org/apache/calcite/sql/type/JavaToSqlTypeConversionRules.java
 
b/core/src/main/java/org/apache/calcite/sql/type/JavaToSqlTypeConversionRules.java
index dc910c0dd1..f1941b29fe 100644
--- 
a/core/src/main/java/org/apache/calcite/sql/type/JavaToSqlTypeConversionRules.java
+++ 
b/core/src/main/java/org/apache/calcite/sql/type/JavaToSqlTypeConversionRules.java
@@ -81,7 +81,6 @@ public class JavaToSqlTypeConversionRules {
           .put(List.class, SqlTypeName.ARRAY)
           .put(Map.class, SqlTypeName.MAP)
           .put(Void.class, SqlTypeName.NULL)
-          .put(Object.class, SqlTypeName.VARIANT)
           .build();
 
   //~ Methods ----------------------------------------------------------------
diff --git 
a/core/src/main/java/org/apache/calcite/sql/type/SqlTypeCoercionRule.java 
b/core/src/main/java/org/apache/calcite/sql/type/SqlTypeCoercionRule.java
index e462a11fc7..ab03b24ce7 100644
--- a/core/src/main/java/org/apache/calcite/sql/type/SqlTypeCoercionRule.java
+++ b/core/src/main/java/org/apache/calcite/sql/type/SqlTypeCoercionRule.java
@@ -138,7 +138,6 @@ public class SqlTypeCoercionRule implements 
SqlTypeMappingRule {
       coerceRules.add(exactType,
           coerceRules.copyValues(exactType)
               .addAll(SqlTypeName.INTERVAL_TYPES)
-              .add(SqlTypeName.VARIANT)
               .build());
     }
 
@@ -153,31 +152,27 @@ public class SqlTypeCoercionRule implements 
SqlTypeMappingRule {
               .add(SqlTypeName.DECIMAL)
               .add(SqlTypeName.CHAR)
               .add(SqlTypeName.VARCHAR)
-              .add(SqlTypeName.VARIANT)
               .build());
     }
 
-    // BINARY is castable from VARBINARY, CHARACTERS, VARIANT.
+    // BINARY is castable from VARBINARY, CHARACTERS.
     coerceRules.add(SqlTypeName.BINARY,
         coerceRules.copyValues(SqlTypeName.BINARY)
             .add(SqlTypeName.VARBINARY)
-            .add(SqlTypeName.VARIANT)
             .addAll(SqlTypeName.CHAR_TYPES)
             .build());
 
-    // VARBINARY is castable from BINARY, CHARACTERS, VARIANT.
+    // VARBINARY is castable from BINARY, CHARACTERS.
     coerceRules.add(SqlTypeName.VARBINARY,
         coerceRules.copyValues(SqlTypeName.VARBINARY)
-            .add(SqlTypeName.VARIANT)
             .add(SqlTypeName.BINARY)
             .addAll(SqlTypeName.CHAR_TYPES)
             .build());
 
     // VARCHAR is castable from BOOLEAN, DATE, TIME, TIMESTAMP, numeric types, 
binary and
-    // intervals, VARIANT
+    // intervals
     coerceRules.add(SqlTypeName.VARCHAR,
         coerceRules.copyValues(SqlTypeName.VARCHAR)
-            .add(SqlTypeName.VARIANT)
             .add(SqlTypeName.CHAR)
             .add(SqlTypeName.BOOLEAN)
             .add(SqlTypeName.DATE)
@@ -192,17 +187,10 @@ public class SqlTypeCoercionRule implements 
SqlTypeMappingRule {
             .addAll(SqlTypeName.INTERVAL_TYPES)
             .build());
 
-    // VARIANT is castable from anything
-    coerceRules.add(SqlTypeName.VARIANT,
-        coerceRules.copyValues(SqlTypeName.CHAR)
-            .addAll(SqlTypeName.ALL_TYPES)
-            .build());
-
     // CHAR is castable from BOOLEAN, DATE, TIME, TIMESTAMP, numeric types, 
binary and
-    // intervals, VARIANT
+    // intervals
     coerceRules.add(SqlTypeName.CHAR,
         coerceRules.copyValues(SqlTypeName.CHAR)
-            .add(SqlTypeName.VARIANT)
             .add(SqlTypeName.VARCHAR)
             .add(SqlTypeName.BOOLEAN)
             .add(SqlTypeName.DATE)
@@ -220,7 +208,6 @@ public class SqlTypeCoercionRule implements 
SqlTypeMappingRule {
     // BOOLEAN is castable from ...
     coerceRules.add(SqlTypeName.BOOLEAN,
         coerceRules.copyValues(SqlTypeName.BOOLEAN)
-            .add(SqlTypeName.VARIANT)
             .add(SqlTypeName.CHAR)
             .add(SqlTypeName.VARCHAR)
             .addAll(SqlTypeName.NUMERIC_TYPES)
@@ -232,7 +219,6 @@ public class SqlTypeCoercionRule implements 
SqlTypeMappingRule {
     // DATE is castable from...
     coerceRules.add(SqlTypeName.DATE,
         coerceRules.copyValues(SqlTypeName.DATE)
-            .add(SqlTypeName.VARIANT)
             .add(SqlTypeName.TIMESTAMP)
             .add(SqlTypeName.TIMESTAMP_WITH_LOCAL_TIME_ZONE)
             .add(SqlTypeName.TIMESTAMP_TZ)
@@ -244,7 +230,6 @@ public class SqlTypeCoercionRule implements 
SqlTypeMappingRule {
     // TIME is castable from...
     coerceRules.add(SqlTypeName.TIME,
         coerceRules.copyValues(SqlTypeName.TIME)
-            .add(SqlTypeName.VARIANT)
             .add(SqlTypeName.TIME_WITH_LOCAL_TIME_ZONE)
             .add(SqlTypeName.TIME_TZ)
             .add(SqlTypeName.TIMESTAMP)
@@ -258,7 +243,6 @@ public class SqlTypeCoercionRule implements 
SqlTypeMappingRule {
     // TIME WITH LOCAL TIME ZONE is castable from...
     coerceRules.add(SqlTypeName.TIME_WITH_LOCAL_TIME_ZONE,
         coerceRules.copyValues(SqlTypeName.TIME_WITH_LOCAL_TIME_ZONE)
-            .add(SqlTypeName.VARIANT)
             .add(SqlTypeName.TIME_TZ)
             .add(SqlTypeName.TIME)
             .add(SqlTypeName.TIMESTAMP)
@@ -273,7 +257,6 @@ public class SqlTypeCoercionRule implements 
SqlTypeMappingRule {
     coerceRules.add(SqlTypeName.TIME_TZ,
         coerceRules.copyValues(SqlTypeName.TIME_TZ)
             .add(SqlTypeName.TIME_WITH_LOCAL_TIME_ZONE)
-            .add(SqlTypeName.VARIANT)
             .add(SqlTypeName.TIME)
             .add(SqlTypeName.TIMESTAMP)
             .add(SqlTypeName.TIMESTAMP_WITH_LOCAL_TIME_ZONE)
@@ -286,7 +269,6 @@ public class SqlTypeCoercionRule implements 
SqlTypeMappingRule {
     // TIMESTAMP is castable from...
     coerceRules.add(SqlTypeName.TIMESTAMP,
         coerceRules.copyValues(SqlTypeName.TIMESTAMP)
-            .add(SqlTypeName.VARIANT)
             .add(SqlTypeName.TIMESTAMP_WITH_LOCAL_TIME_ZONE)
             .add(SqlTypeName.TIMESTAMP_TZ)
             .add(SqlTypeName.DATE)
@@ -302,7 +284,6 @@ public class SqlTypeCoercionRule implements 
SqlTypeMappingRule {
     // TIMESTAMP WITH LOCAL TIME ZONE is castable from...
     coerceRules.add(SqlTypeName.TIMESTAMP_WITH_LOCAL_TIME_ZONE,
         coerceRules.copyValues(SqlTypeName.TIMESTAMP_WITH_LOCAL_TIME_ZONE)
-            .add(SqlTypeName.VARIANT)
             .add(SqlTypeName.TIMESTAMP_TZ)
             .add(SqlTypeName.TIMESTAMP)
             .add(SqlTypeName.DATE)
@@ -318,7 +299,6 @@ public class SqlTypeCoercionRule implements 
SqlTypeMappingRule {
     // TIMESTAMP WITH TIME ZONE is castable from...
     coerceRules.add(SqlTypeName.TIMESTAMP_TZ,
         coerceRules.copyValues(SqlTypeName.TIMESTAMP_TZ)
-            .add(SqlTypeName.VARIANT)
             .add(SqlTypeName.TIMESTAMP_WITH_LOCAL_TIME_ZONE)
             .add(SqlTypeName.TIMESTAMP)
             .add(SqlTypeName.DATE)
@@ -334,7 +314,6 @@ public class SqlTypeCoercionRule implements 
SqlTypeMappingRule {
     // GEOMETRY is castable from ...
     coerceRules.add(SqlTypeName.GEOMETRY,
         coerceRules.copyValues(SqlTypeName.GEOMETRY)
-            .add(SqlTypeName.VARIANT)
             .addAll(SqlTypeName.CHAR_TYPES)
             .build());
 
diff --git a/core/src/main/java/org/apache/calcite/sql/type/SqlTypeName.java 
b/core/src/main/java/org/apache/calcite/sql/type/SqlTypeName.java
index ed986bb4ca..522d4639da 100644
--- a/core/src/main/java/org/apache/calcite/sql/type/SqlTypeName.java
+++ b/core/src/main/java/org/apache/calcite/sql/type/SqlTypeName.java
@@ -277,7 +277,6 @@ public enum SqlTypeName {
           .put(Types.DISTINCT, DISTINCT)
           .put(Types.STRUCT, STRUCTURED)
           .put(Types.ARRAY, ARRAY)
-          .put(Types.JAVA_OBJECT, VARIANT)
           .build();
 
   /**
diff --git a/core/src/main/java/org/apache/calcite/sql/type/SqlTypeUtil.java 
b/core/src/main/java/org/apache/calcite/sql/type/SqlTypeUtil.java
index 126e76e365..245756824e 100644
--- a/core/src/main/java/org/apache/calcite/sql/type/SqlTypeUtil.java
+++ b/core/src/main/java/org/apache/calcite/sql/type/SqlTypeUtil.java
@@ -772,6 +772,10 @@ public abstract class SqlTypeUtil {
     return t.getFamily() == SqlTypeFamily.ANY;
   }
 
+  private static boolean isVariant(RelDataType t) {
+    return t.getFamily() == SqlTypeFamily.VARIANT;
+  }
+
   public static boolean isMeasure(RelDataType t) {
     return t instanceof MeasureSqlType;
   }
@@ -895,7 +899,7 @@ public abstract class SqlTypeUtil {
       return canCastFrom(toType, 
requireNonNull(fromType.getMeasureElementType()),
                          typeMappingRule);
     }
-    if (isAny(toType) || isAny(fromType)) {
+    if (isAny(toType) || isAny(fromType) || isVariant(toType) || 
isVariant(fromType)) {
       return true;
     }
 
diff --git a/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java 
b/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java
index 6236e0e556..ab6e2c3ce0 100644
--- a/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java
+++ b/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java
@@ -116,6 +116,7 @@ import org.apache.calcite.sql.SqlJsonExistsErrorBehavior;
 import org.apache.calcite.sql.SqlJsonQueryEmptyOrErrorBehavior;
 import org.apache.calcite.sql.SqlJsonQueryWrapperBehavior;
 import org.apache.calcite.sql.SqlJsonValueEmptyOrErrorBehavior;
+import org.apache.calcite.util.rtti.RuntimeTypeInformation;
 
 import com.google.common.collect.ImmutableMap;
 
@@ -935,7 +936,8 @@ public enum BuiltInMethod {
       long.class),
   BIG_DECIMAL_ADD(BigDecimal.class, "add", BigDecimal.class),
   BIG_DECIMAL_NEGATE(BigDecimal.class, "negate"),
-  COMPARE_TO(Comparable.class, "compareTo", Object.class);
+  COMPARE_TO(Comparable.class, "compareTo", Object.class),
+  VARIANT_CAST(Variant.class, "cast", Object.class, 
RuntimeTypeInformation.class);
 
   @SuppressWarnings("ImmutableEnumChecker")
   public final Method method;
diff --git a/core/src/main/java/org/apache/calcite/util/Variant.java 
b/core/src/main/java/org/apache/calcite/util/Variant.java
new file mode 100644
index 0000000000..05c91cf186
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/util/Variant.java
@@ -0,0 +1,181 @@
+/*
+ * 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.util;
+
+import org.apache.calcite.runtime.SqlFunctions;
+import org.apache.calcite.sql.type.BasicSqlType;
+import org.apache.calcite.util.rtti.BasicSqlTypeRtti;
+import org.apache.calcite.util.rtti.RuntimeTypeInformation;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import java.util.Objects;
+
+/** This class is the runtime support for values of the VARIANT SQL type. */
+public class Variant {
+  /** Actual value. */
+  final @Nullable Object value;
+  /** Type of the value. */
+  final RuntimeTypeInformation runtimeType;
+  /** The VARIANT type has its own notion of null, which is
+   * different from the SQL NULL value.  For example, two variant nulls are 
equal
+   * with each other.  This flag is 'true' if this value represents a variant 
null. */
+  final boolean isVariantNull;
+
+  private Variant(@Nullable Object value, RuntimeTypeInformation runtimeType,
+      boolean isVariantNull) {
+    this.value = value;
+    this.runtimeType = runtimeType;
+    this.isVariantNull = isVariantNull;
+  }
+
+  public Variant(@Nullable Object value, RuntimeTypeInformation runtimeType) {
+    this(value, runtimeType, false);
+  }
+
+  /** Create a variant object with a null value. */
+  public Variant() {
+    this(null,
+        new BasicSqlTypeRtti(RuntimeTypeInformation.RuntimeSqlTypeName.VARIANT,
+            BasicSqlType.PRECISION_NOT_SPECIFIED, 
BasicSqlType.SCALE_NOT_SPECIFIED),
+        true);
+  }
+
+  public String getType() {
+    return this.runtimeType.getTypeString();
+  }
+
+  public boolean isVariantNull() {
+    return this.isVariantNull;
+  }
+
+  @Override public boolean equals(@Nullable Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+
+    Variant variant = (Variant) o;
+    return isVariantNull == variant.isVariantNull
+        && Objects.equals(value, variant.value)
+        && runtimeType.equals(variant.runtimeType);
+  }
+
+  @Override public int hashCode() {
+    int result = Objects.hashCode(value);
+    result = 31 * result + runtimeType.hashCode();
+    result = 31 * result + Boolean.hashCode(isVariantNull);
+    return result;
+  }
+
+  /** Cast this value to the specified type.  Currently, the rule is:
+   * if the value has the specified type, the value field is returned, 
otherwise a SQL
+   * NULL is returned. */
+  @Nullable Object cast(RuntimeTypeInformation type) {
+    if (this.runtimeType.isScalar()) {
+      if (this.runtimeType.equals(type)) {
+        return this.value;
+      } else {
+        return null;
+      }
+    } else {
+      if (this.runtimeType.equals(type)) {
+        return this.value;
+      }
+      // TODO: allow casts that change some of the generic arguments only
+    }
+    return null;
+  }
+
+  // This method is invoked from {@link RexToLixTranslator} VARIANT_CAST
+  public static @Nullable Object cast(@Nullable Object variant, 
RuntimeTypeInformation type) {
+    if (variant == null) {
+      return null;
+    }
+    if (!(variant instanceof Variant)) {
+      throw new RuntimeException("Expected a variant value " + variant);
+    }
+    return ((Variant) variant).cast(type);
+  }
+
+  // Implementation of the array index operator for VARIANT values
+  public @Nullable Object item(Object index) {
+    if (this.value == null) {
+      return null;
+    }
+    switch (this.runtimeType.getTypeName()) {
+    case ROW:
+    case ARRAY:
+    case MAP:
+      return SqlFunctions.item(this.value, index);
+    default:
+      return null;
+    }
+  }
+
+  // This method is called by the testing code.
+  @Override public String toString() {
+    if (value == null) {
+      return "NULL";
+    }
+    if (this.isVariantNull()) {
+      return "null";
+    }
+    if (this.runtimeType.getTypeName() == 
RuntimeTypeInformation.RuntimeSqlTypeName.ROW) {
+      if (value instanceof Object[]) {
+        Object[] array = (Object []) value;
+        StringBuilder buf = new StringBuilder("{");
+
+        boolean first = true;
+        for (Object o : array) {
+          if (!first) {
+            buf.append(", ");
+          }
+          first = false;
+          buf.append(o.toString());
+        }
+        buf.append("}");
+        return buf.toString();
+      }
+    }
+    String quote = "";
+    switch (this.runtimeType.getTypeName()) {
+    case TIME:
+    case TIME_WITH_LOCAL_TIME_ZONE:
+    case TIME_TZ:
+    case TIMESTAMP:
+    case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
+    case TIMESTAMP_TZ:
+    case INTERVAL_LONG:
+    case INTERVAL_SHORT:
+    case CHAR:
+    case VARCHAR:
+    case BINARY:
+    case VARBINARY:
+      // At least in Snowflake VARIANT values that are strings
+      // are printed with double quotes
+      // https://docs.snowflake.com/en/sql-reference/data-types-semistructured
+      quote = "\"";
+      break;
+    default:
+      break;
+    }
+    return quote + value + quote;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/calcite/util/rtti/BasicSqlTypeRtti.java 
b/core/src/main/java/org/apache/calcite/util/rtti/BasicSqlTypeRtti.java
new file mode 100644
index 0000000000..63f7afe60a
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/util/rtti/BasicSqlTypeRtti.java
@@ -0,0 +1,113 @@
+/*
+ * 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.util.rtti;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** Runtime type information about a base (primitive) SQL type. */
+public class BasicSqlTypeRtti extends RuntimeTypeInformation {
+  private final int precision;
+  private final int scale;
+
+  public BasicSqlTypeRtti(RuntimeSqlTypeName typeName, int precision, int 
scale) {
+    super(typeName);
+    assert typeName.isScalar() : "Base SQL type must be a scalar type " + 
typeName;
+    this.precision = precision;
+    this.scale = scale;
+  }
+
+  @Override public boolean equals(@Nullable Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+
+    BasicSqlTypeRtti that = (BasicSqlTypeRtti) o;
+    return typeName == that.typeName && precision == that.precision && scale 
== that.scale;
+  }
+
+  @Override public int hashCode() {
+    int result = precision;
+    result = 31 * result + scale;
+    return result;
+  }
+
+  @Override public String getTypeString()  {
+    switch (this.typeName) {
+    case BOOLEAN:
+      return "BOOLEAN";
+    case TINYINT:
+      return "TINYINT";
+    case SMALLINT:
+      return "SMALLINT";
+    case INTEGER:
+      return "INTEGER";
+    case BIGINT:
+      return "BIGINT";
+    case DECIMAL:
+      return "DECIMAL";
+    case FLOAT:
+      return "FLOAT";
+    case REAL:
+      return "REAL";
+    case DOUBLE:
+      return "DOUBLE";
+    case DATE:
+      return "DATE";
+    case TIME:
+      return "TIME";
+    case TIME_WITH_LOCAL_TIME_ZONE:
+      return "TIME_WITH_LOCAL_TIME_ZONE";
+    case TIME_TZ:
+      return "TIME_WITH_TIME_ZONE";
+    case TIMESTAMP:
+      return "TIMESTAMP";
+    case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
+      return "TIMESTAMP_WITH_LOCAL_TIME_ZONE";
+    case TIMESTAMP_TZ:
+      return "TIMESTAMP_WITH_TIME_ZONE";
+    case INTERVAL_LONG:
+    case INTERVAL_SHORT:
+      return "INTERVAL";
+    case CHAR:
+      return "CHAR";
+    case VARCHAR:
+      return "VARCHAR";
+    case BINARY:
+      return "BINARY";
+    case VARBINARY:
+      return "VARBINARY";
+    case NULL:
+      return "NULL";
+    case GEOMETRY:
+      return "GEOMETRY";
+    case VARIANT:
+      return "VARIANT";
+    default:
+      throw new RuntimeException("Unexpected type " + this.typeName);
+    }
+  }
+
+  // This method is used to serialize the type in Java code implementations,
+  // so it should produce a computation that reconstructs the type at runtime
+  @Override public String toString() {
+    return "new BasicSqlTypeRtti("
+        + this.getTypeString() + ", " + this.precision + ", " + this.scale + 
")";
+  }
+}
diff --git 
a/core/src/main/java/org/apache/calcite/util/rtti/GenericSqlTypeRtti.java 
b/core/src/main/java/org/apache/calcite/util/rtti/GenericSqlTypeRtti.java
new file mode 100644
index 0000000000..25f3be33e8
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/util/rtti/GenericSqlTypeRtti.java
@@ -0,0 +1,75 @@
+/*
+ * 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.util.rtti;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import java.util.Arrays;
+
+/** Runtime type information which contains some type parameters. */
+public class GenericSqlTypeRtti extends RuntimeTypeInformation {
+  final RuntimeTypeInformation[] typeArguments;
+
+  public GenericSqlTypeRtti(RuntimeSqlTypeName typeName, 
RuntimeTypeInformation... typeArguments) {
+    super(typeName);
+    assert !typeName.isScalar() : "Generic type cannot be a scalar type " + 
typeName;
+    this.typeArguments = typeArguments;
+  }
+
+  @Override public String getTypeString()  {
+    switch (this.typeName) {
+    case ARRAY:
+      return "ARRAY";
+    case MAP:
+      return "MAP";
+    case MULTISET:
+      return "MULTISET";
+    default:
+      throw new RuntimeException("Unexpected type " + this.typeName);
+    }
+  }
+
+  // This method is used to serialize the type in Java code implementations,
+  // so it should produce a computation that reconstructs the type at runtime
+  @Override public String toString() {
+    StringBuilder builder = new StringBuilder();
+    builder.append("new GenericSqlTypeRtti(")
+        .append(this.getTypeString());
+    for (RuntimeTypeInformation arg : this.typeArguments) {
+      builder.append(", ");
+      builder.append(arg.toString());
+    }
+    builder.append(")");
+    return builder.toString();
+  }
+
+  @Override public boolean equals(@Nullable Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+
+    GenericSqlTypeRtti that = (GenericSqlTypeRtti) o;
+    return typeName == that.typeName && Arrays.equals(typeArguments, 
that.typeArguments);
+  }
+
+  @Override public int hashCode() {
+    return Arrays.hashCode(typeArguments);
+  }
+}
diff --git 
a/core/src/main/java/org/apache/calcite/util/rtti/RowSqlTypeRtti.java 
b/core/src/main/java/org/apache/calcite/util/rtti/RowSqlTypeRtti.java
new file mode 100644
index 0000000000..d876e7ea76
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/util/rtti/RowSqlTypeRtti.java
@@ -0,0 +1,70 @@
+/*
+ * 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.util.rtti;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import java.util.Arrays;
+import java.util.Map;
+
+/** Runtime type information for a ROW type. */
+public class RowSqlTypeRtti extends RuntimeTypeInformation {
+  private final Map.Entry<String, RuntimeTypeInformation>[] fieldNames;
+
+  @SafeVarargs
+  public RowSqlTypeRtti(Map.Entry<String, RuntimeTypeInformation>... 
fieldNames) {
+    super(RuntimeSqlTypeName.ROW);
+    this.fieldNames = fieldNames;
+  }
+
+  @Override public String getTypeString()  {
+    return "ROW";
+  }
+
+  // This method is used to serialize the type in Java code implementations,
+  // so it should produce a computation that reconstructs the type at runtime
+  @Override public String toString() {
+    StringBuilder builder = new StringBuilder();
+    builder.append("new RowSqlTypeRtti(");
+    boolean first = true;
+    for (Map.Entry<String, RuntimeTypeInformation> arg : this.fieldNames) {
+      if (!first) {
+        builder.append(", ");
+      }
+      first = false;
+      builder.append(arg.toString());
+    }
+    builder.append(")");
+    return builder.toString();
+  }
+
+  @Override public boolean equals(@Nullable Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+
+    RowSqlTypeRtti that = (RowSqlTypeRtti) o;
+    return Arrays.equals(fieldNames, that.fieldNames);
+  }
+
+  @Override public int hashCode() {
+    return Arrays.hashCode(fieldNames);
+  }
+}
diff --git 
a/core/src/main/java/org/apache/calcite/util/rtti/RuntimeTypeInformation.java 
b/core/src/main/java/org/apache/calcite/util/rtti/RuntimeTypeInformation.java
new file mode 100644
index 0000000000..73527408d7
--- /dev/null
+++ 
b/core/src/main/java/org/apache/calcite/util/rtti/RuntimeTypeInformation.java
@@ -0,0 +1,228 @@
+/*
+ * 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.util.rtti;
+
+import org.apache.calcite.linq4j.tree.Expression;
+import org.apache.calcite.linq4j.tree.Expressions;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeField;
+
+import java.util.AbstractMap;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * This class represents the type of a SQL expression at runtime.
+ * Normally SQL is a statically-typed language, and there is no need for
+ * runtime-type information.  However, the VARIANT data type is actually
+ * a dynamically-typed value, and needs this kind of information.
+ * We cannot use the very similar RelDataType type since it carries extra
+ * baggage, like the type system, which is not available at runtime. */
+public abstract class RuntimeTypeInformation {
+  /** Names of SQL types as represented at runtime. */
+  public enum RuntimeSqlTypeName {
+    BOOLEAN(false),
+    TINYINT(false),
+    SMALLINT(false),
+    INTEGER(false),
+    BIGINT(false),
+    DECIMAL(false),
+    FLOAT(false),
+    REAL(false),
+    DOUBLE(false),
+    DATE(false),
+    TIME(false),
+    TIME_WITH_LOCAL_TIME_ZONE(false),
+    TIME_TZ(false),
+    TIMESTAMP(false),
+    TIMESTAMP_WITH_LOCAL_TIME_ZONE(false),
+    TIMESTAMP_TZ(false),
+    INTERVAL_LONG(false),
+    INTERVAL_SHORT(false),
+    CHAR(false),
+    VARCHAR(false),
+    BINARY(false),
+    VARBINARY(false),
+    NULL(false),
+    MULTISET(true),
+    ARRAY(true),
+    MAP(true),
+    ROW(true),
+    GEOMETRY(false),
+    // used only for VARIANT.null value
+    VARIANT(false);
+
+    private final boolean composite;
+
+    RuntimeSqlTypeName(boolean composite) {
+      this.composite = composite;
+    }
+
+    public boolean isScalar() {
+      return !this.composite;
+    }
+  }
+
+  final RuntimeSqlTypeName typeName;
+
+  protected RuntimeTypeInformation(RuntimeSqlTypeName typeName) {
+    this.typeName = typeName;
+  }
+
+  public abstract String getTypeString();
+
+  public RuntimeSqlTypeName getTypeName() {
+    return this.typeName;
+  }
+
+  public boolean isScalar() {
+    return this.typeName.isScalar();
+  }
+
+  /**
+   * Create and return an expression that creates a runtime type that
+   * reflects the information in the statically-known type 'type'.
+   *
+   * @param type The static type of an expression.
+   */
+  public static Expression createExpression(RelDataType type) {
+    final Expression precision = Expressions.constant(type.getPrecision());
+    final Expression scale = Expressions.constant(type.getScale());
+    switch (type.getSqlTypeName()) {
+    case BOOLEAN:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.BOOLEAN), precision, scale);
+    case TINYINT:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.TINYINT), precision, scale);
+    case SMALLINT:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.SMALLINT), precision, scale);
+    case INTEGER:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.INTEGER), precision, scale);
+    case BIGINT:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.BIGINT), precision, scale);
+    case DECIMAL:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.DECIMAL), precision, scale);
+    case FLOAT:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.FLOAT), precision, scale);
+    case REAL:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.REAL), precision, scale);
+    case DOUBLE:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.DOUBLE), precision, scale);
+    case DATE:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.DATE), precision, scale);
+    case TIME:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.TIME), precision, scale);
+    case TIME_WITH_LOCAL_TIME_ZONE:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.TIME_WITH_LOCAL_TIME_ZONE),
+          precision, scale);
+    case TIME_TZ:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.TIME_TZ), precision, scale);
+    case TIMESTAMP:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.TIMESTAMP), precision, 
scale);
+    case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          
Expressions.constant(RuntimeSqlTypeName.TIMESTAMP_WITH_LOCAL_TIME_ZONE),
+          precision, scale);
+    case TIMESTAMP_TZ:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.TIMESTAMP_TZ), precision, 
scale);
+    case INTERVAL_YEAR:
+    case INTERVAL_YEAR_MONTH:
+    case INTERVAL_MONTH:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.INTERVAL_LONG), precision, 
scale);
+    case INTERVAL_DAY:
+    case INTERVAL_DAY_HOUR:
+    case INTERVAL_DAY_MINUTE:
+    case INTERVAL_DAY_SECOND:
+    case INTERVAL_HOUR:
+    case INTERVAL_HOUR_MINUTE:
+    case INTERVAL_HOUR_SECOND:
+    case INTERVAL_MINUTE:
+    case INTERVAL_MINUTE_SECOND:
+    case INTERVAL_SECOND:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.INTERVAL_SHORT), precision, 
scale);
+    case CHAR:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.CHAR), precision, scale);
+    case VARCHAR:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.VARCHAR), precision, scale);
+    case BINARY:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.BINARY), precision, scale);
+    case VARBINARY:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.VARBINARY), precision, 
scale);
+    case NULL:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.NULL), precision, scale);
+    case MULTISET: {
+      Expression comp = 
createExpression(requireNonNull(type.getComponentType()));
+      return Expressions.new_(GenericSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.MULTISET), comp);
+    }
+    case ARRAY: {
+      Expression comp = 
createExpression(requireNonNull(type.getComponentType()));
+      return Expressions.new_(GenericSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.ARRAY), comp);
+    }
+    case MAP: {
+      Expression key = createExpression(requireNonNull(type.getValueType()));
+      Expression value = createExpression(requireNonNull(type.getKeyType()));
+      return Expressions.new_(GenericSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.MAP), key, value);
+    }
+    case ROW: {
+      Expression[] fields = new Expression[type.getFieldCount()];
+      int index = 0;
+      for (RelDataTypeField field : type.getFieldList()) {
+        String name = field.getName();
+        RelDataType fieldType = field.getType();
+        Expression fieldTypeExpression = createExpression(fieldType);
+        Expression nameExpression = Expressions.constant(name);
+        Expression entry =
+            Expressions.new_(AbstractMap.SimpleEntry.class, nameExpression, 
fieldTypeExpression);
+        fields[index++] = entry;
+      }
+      return Expressions.new_(RowSqlTypeRtti.class, fields);
+    }
+    case GEOMETRY:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.GEOMETRY), precision, scale);
+    case VARIANT:
+      return Expressions.new_(BasicSqlTypeRtti.class,
+          Expressions.constant(RuntimeSqlTypeName.VARIANT), precision, scale);
+    default:
+      throw new RuntimeException("Unexpected type " + type);
+    }
+  }
+}
diff --git a/core/src/main/java/org/apache/calcite/util/rtti/package-info.java 
b/core/src/main/java/org/apache/calcite/util/rtti/package-info.java
new file mode 100644
index 0000000000..7113279765
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/util/rtti/package-info.java
@@ -0,0 +1,21 @@
+/*
+ * 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.
+ */
+
+/**
+ * Support for runtime type information.
+ */
+package org.apache.calcite.util.rtti;
diff --git 
a/core/src/test/java/org/apache/calcite/jdbc/CalciteRemoteDriverTest.java 
b/core/src/test/java/org/apache/calcite/jdbc/CalciteRemoteDriverTest.java
index 44c0cee15a..1e11113bc4 100644
--- a/core/src/test/java/org/apache/calcite/jdbc/CalciteRemoteDriverTest.java
+++ b/core/src/test/java/org/apache/calcite/jdbc/CalciteRemoteDriverTest.java
@@ -327,7 +327,7 @@ class CalciteRemoteDriverTest {
     CalciteAssert.hr()
         .with(CalciteRemoteDriverTest::getRemoteConnection)
         .metaData(CalciteRemoteDriverTest::getTypeInfo)
-        .returns(CalciteAssert.checkResultCount(is(43)));
+        .returns(CalciteAssert.checkResultCount(is(44)));
   }
 
   @Test void testRemoteTableTypes() {
diff --git a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java 
b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
index 9434f8b23d..1e9ae827a7 100644
--- a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
+++ b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
@@ -1356,6 +1356,10 @@ public class SqlValidatorTest extends 
SqlValidatorTestCase {
         .columnType("VARIANT NOT NULL");
     expr("cast(TIMESTAMP '2024-09-01 00:00:00' as variant)")
         .columnType("VARIANT NOT NULL");
+    expr("cast(ARRAY[1,2,3] AS VARIANT)")
+        .columnType("VARIANT NOT NULL");
+    expr("cast(MAP[1, 2, 3, 4] AS VARIANT)")
+        .columnType("VARIANT NOT NULL");
 
     expr("cast(cast(NULL as variant) as int)")
         .columnType("INTEGER");
@@ -1367,6 +1371,10 @@ public class SqlValidatorTest extends 
SqlValidatorTestCase {
         .columnType("VARCHAR");
     expr("cast(cast(TIMESTAMP '2024-09-01 00:00:00' as variant) as timestamp)")
         .columnType("TIMESTAMP(0)");
+    expr("cast(ARRAY[1,2,3] AS VARIANT ARRAY)")
+        .columnType("VARIANT NOT NULL ARRAY NOT NULL");
+    expr("cast(MAP['a','b','c','d'] AS MAP<VARCHAR, VARIANT>)")
+        .columnType("(VARCHAR NOT NULL, VARIANT NOT NULL) MAP NOT NULL");
   }
 
   @Test void testAccessVariant() {
@@ -8636,7 +8644,8 @@ public class SqlValidatorTest extends 
SqlValidatorTestCase {
         .fails("Cannot apply 'ITEM' to arguments of type 
'ITEM\\(<VARCHAR\\(10\\)>, "
             +  "<INTEGER>\\)'\\. Supported form\\(s\\): 
<ARRAY>\\[<INTEGER>\\]\n"
             + "<MAP>\\[<ANY>\\]\n"
-            + "<ROW>\\[<CHARACTER>\\|<INTEGER>\\].*");
+            + "<ROW>\\[<CHARACTER>\\|<INTEGER>\\]\n"
+            + "<VARIANT>\\[<ANY>\\].*");
   }
 
   /** Test case for
diff --git a/site/_docs/reference.md b/site/_docs/reference.md
index 2fe835f39f..8795333b58 100644
--- a/site/_docs/reference.md
+++ b/site/_docs/reference.md
@@ -1131,6 +1131,7 @@ UTF8,
 **VALUE_OF**,
 **VARBINARY**,
 **VARCHAR**,
+**VARIANT**,
 **VARYING**,
 **VAR_POP**,
 **VAR_SAMP**,
@@ -1248,6 +1249,36 @@ Note:
 * Every `ROW` column type can have an optional [ NULL | NOT NULL ] suffix
   to indicate if this column type is nullable, default is not nullable.
 
+### The `VARIANT` type
+
+Values of `VARIANT` type are dynamically-typed.
+Any such value holds at runtime two pieces of information:
+- the data type
+- the data value
+
+Values of `VARIANT` type can be created by casting any other value to a 
`VARIANT`: e.g.
+`SELECT CAST(x AS VARIANT)`.  Conversely, values of type `VARIANT` can be cast 
to any other data type
+`SELECT CAST(variant AS INT)`.  A cast of a value of type `VARIANT` to target 
type T
+will compare the runtime type with T.  If the types are identical, the
+original value is returned.  Otherwise the `CAST` returns `NULL`.
+
+Values of type `ARRAY`, `MAP`, and `ROW` type can be cast to `VARIANT`.  
`VARIANT` values
+also offer the following operations:
+
+- indexing using array indexing notation `variant[index]`.  If the `VARIANT` is
+  obtained from an `ARRAY` value, the indexing operation returns a `VARIANT` 
whose value element
+  is the element at the specified index.  Otherwise this operation returns 
`NULL`
+- indexing using map element access notation `variant[key]`, where `key` can 
have
+  any legal `MAP` key type.  If the `VARIANT` is obtained from a `MAP` value
+  that has en element with this key, a `VARIANT` value holding the associated 
value in
+  the `MAP` is returned.  Otherwise `NULL` is returned.  If the `VARIANT` is 
obtained from `ROW` value
+  which has a field with the name `key`, this operation returns a `VARIANT` 
value holding
+  the corresponding field value.  Otherwise `NULL` is returned.
+- field access using the dot notation: `variant.field`.  This operation is 
interpreted
+  as equivalent to `variant['field']`.  Note, however, that the field notation
+  is subject to the capitalization rules of the SQL dialect, so for correct
+  operation the field may need to be quoted: `variant."field"`
+
 ### Spatial types
 
 Spatial data is represented as character strings encoded as
diff --git 
a/testkit/src/main/java/org/apache/calcite/sql/parser/SqlParserTest.java 
b/testkit/src/main/java/org/apache/calcite/sql/parser/SqlParserTest.java
index c6f06280aa..39faceb01b 100644
--- a/testkit/src/main/java/org/apache/calcite/sql/parser/SqlParserTest.java
+++ b/testkit/src/main/java/org/apache/calcite/sql/parser/SqlParserTest.java
@@ -576,6 +576,7 @@ public class SqlParserTest {
       "VALUE_OF",                                                  "2014", "c",
       "VARBINARY",                                         "2011", "2014", "c",
       "VARCHAR",                       "92", "99", "2003", "2011", "2014", "c",
+      "VARIANT",                                                           "c",
       "VARYING",                       "92", "99", "2003", "2011", "2014", "c",
       "VAR_POP",                                           "2011", "2014", "c",
       "VAR_SAMP",                                          "2011", "2014", "c",
diff --git a/testkit/src/main/java/org/apache/calcite/test/SqlOperatorTest.java 
b/testkit/src/main/java/org/apache/calcite/test/SqlOperatorTest.java
index 1bb7e3058b..a75cf71c1e 100644
--- a/testkit/src/main/java/org/apache/calcite/test/SqlOperatorTest.java
+++ b/testkit/src/main/java/org/apache/calcite/test/SqlOperatorTest.java
@@ -1802,6 +1802,69 @@ public class SqlOperatorTest {
     f.checkNull("cast(null as row(f0 varchar, f1 varchar))");
   }
 
+  /** Test cases for
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-4918";>
+   * [CALCITE-4918]  Add a VARIANT data type</a>. */
+  @Test public void testVariant() {
+    SqlOperatorFixture f = fixture();
+    f.checkScalar("cast(1 as VARIANT)", "1", "VARIANT NOT NULL");
+    // String variants include quotes when output
+    f.checkScalar("cast('abc' as VARIANT)", "\"abc\"", "VARIANT NOT NULL");
+    f.checkScalar("cast(ARRAY[1,2,3] as VARIANT)", "[1, 2, 3]", "VARIANT NOT 
NULL");
+    f.checkScalar("cast(MAP['a',1,'b',2] as VARIANT)", "{a=1, b=2}", "VARIANT 
NOT NULL");
+    f.checkScalar("cast((1, 2) as row(f0 integer, f1 bigint))", "{1, 2}",
+        "RecordType(INTEGER NOT NULL F0, BIGINT NOT NULL F1) NOT NULL");
+    f.checkScalar("cast(row(1, 2) AS VARIANT)", "{1, 2}", "VARIANT NOT NULL");
+    f.checkNull("cast(NULL AS VARIANT)");
+
+    // Converting a VARIANT back to the original type produces the original 
value
+    f.checkScalar("cast(cast(1 as VARIANT) AS INTEGER)", "1", "INTEGER");
+    // no quotes printed, since the result is a VARCHAR
+    f.checkScalar("cast(cast(CAST('abc' AS VARCHAR) as VARIANT) AS VARCHAR)", 
"abc",
+        "VARCHAR");
+    f.checkScalar("cast(cast(ARRAY[1,2,3] as VARIANT) AS INTEGER ARRAY)", "[1, 
2, 3]",
+        "INTEGER NOT NULL ARRAY");
+    // If the type is not exaclty the same the conversion fails (here CHAR to 
VARCHAR)
+    f.checkNull("cast(cast('abc' as VARIANT) AS VARCHAR)");
+    f.checkScalar("cast(cast('abc' as VARIANT) AS CHAR(3))", "abc", "CHAR(3)");
+
+    // Converting a variant to anything that does not match the runtime type 
returns null
+    f.checkScalar("cast(cast(1 as VARIANT) as INTEGER)", "1", "INTEGER");
+    f.checkNull("cast(cast(1 as VARIANT) as VARCHAR)");
+    f.checkNull("cast(cast(1 as VARIANT) as INT ARRAY)");
+
+    // Arrays of variant objects
+    f.checkScalar("ARRAY[CAST(1 AS VARIANT), CAST('abc' AS VARIANT)]", "[1, 
\"abc\"]",
+        "VARIANT NOT NULL ARRAY NOT NULL");
+    // Arrays can even contain other arrays
+    f.checkScalar("ARRAY[CAST(1 AS VARIANT), CAST('abc' AS VARIANT), 
CAST(ARRAY[2] AS VARIANT)]",
+        "[1, \"abc\", [2]]", "VARIANT NOT NULL ARRAY NOT NULL");
+    // Field access in a VARIANT ARRAY
+    f.checkScalar("CAST(ARRAY[CAST(1 AS VARIANT), CAST('abc' AS VARIANT)][1] 
AS INTEGER)", "1",
+        "INTEGER");
+    // Field access in a VARIANT MAP
+    f.checkScalar("cast(MAP['a',1,'b',2] as VARIANT)['a']", "1", "VARIANT");
+    // Alternative field access in a VARIANT MAP.  Field names have to be 
quoted, though
+    f.checkScalar("cast(MAP['a',1,'b',2] as VARIANT).\"a\"", "1", "VARIANT");
+
+    // Here is a possible representation of a JSON document { "a": 1, "b": [ { 
"c": 2.3 }, 5 ] }
+    f.checkScalar("MAP["
+            + "CAST('a' AS VARIANT), CAST(1 AS VARIANT), "
+            + "CAST('b' AS VARIANT), CAST(ARRAY["
+            + "CAST(MAP[CAST('c' AS VARIANT), CAST(2.3 AS VARIANT)] AS 
VARIANT), CAST(5 AS VARIANT)]"
+            + " AS VARIANT)]",
+        "{\"a\"=1, \"b\"=[{\"c\"=2.3}, 5]}",
+        "(VARIANT NOT NULL, VARIANT NOT NULL) MAP NOT NULL");
+    // Another possible representation using CHAR instead of VARIANT for MAP 
keys
+    f.checkScalar("MAP["
+            + "'a', CAST(1 AS VARIANT), "
+            + "'b', CAST(ARRAY["
+            + "CAST(MAP['c', CAST(2.3 AS VARIANT)] AS VARIANT), CAST(5 AS 
VARIANT)]"
+            + " AS VARIANT)]",
+        "{a=1, b=[{c=2.3}, 5]}",
+        "(CHAR(1) NOT NULL, VARIANT NOT NULL) MAP NOT NULL");
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/projects/CALCITE/issues/CALCITE-6095";>
    * [CALCITE-6095] Arithmetic expression with VARBINARY value causes 
AssertionFailure</a>.
@@ -12907,7 +12970,8 @@ public class SqlOperatorTest {
         "Cannot apply 'ITEM' to arguments of type 'ITEM\\(<CHAR\\(3\\) ARRAY>, 
"
             + "<CHAR\\(3\\)>\\)'\\. Supported form\\(s\\): 
<ARRAY>\\[<INTEGER>\\]\n"
             + "<MAP>\\[<ANY>\\]\n"
-            + "<ROW>\\[<CHARACTER>\\|<INTEGER>\\]",
+            + "<ROW>\\[<CHARACTER>\\|<INTEGER>\\]\n"
+            + "<VARIANT>\\[<ANY>\\]",
         false);
 
     // Array of INTEGER NOT NULL is interesting because we might be tempted

Reply via email to