diff --git a/isthmus/src/main/java/io/substrait/isthmus/ConverterProvider.java b/isthmus/src/main/java/io/substrait/isthmus/ConverterProvider.java index 72e77586d..8cd3731b3 100644 --- a/isthmus/src/main/java/io/substrait/isthmus/ConverterProvider.java +++ b/isthmus/src/main/java/io/substrait/isthmus/ConverterProvider.java @@ -272,6 +272,7 @@ public List getCallConverters() { callConverters.add(CallConverters.ROW); callConverters.add(CallConverters.CAST.apply(typeConverter)); callConverters.add(CallConverters.REINTERPRET.apply(typeConverter)); + callConverters.add(CallConverters.EXECUTION_CONTEXT_VARIABLE); callConverters.add(new SqlArrayValueConstructorCallConverter(typeConverter)); callConverters.add(new SqlMapValueConstructorCallConverter()); callConverters.add(CallConverters.CREATE_SEARCH_CONV.apply(new RexBuilder(typeFactory))); diff --git a/isthmus/src/main/java/io/substrait/isthmus/calcite/SubstraitOperatorTable.java b/isthmus/src/main/java/io/substrait/isthmus/calcite/SubstraitOperatorTable.java index 22d7f9a4b..c73baed7e 100644 --- a/isthmus/src/main/java/io/substrait/isthmus/calcite/SubstraitOperatorTable.java +++ b/isthmus/src/main/java/io/substrait/isthmus/calcite/SubstraitOperatorTable.java @@ -1,6 +1,7 @@ package io.substrait.isthmus.calcite; import io.substrait.isthmus.AggregateFunctions; +import io.substrait.isthmus.expression.CurrentTimezoneFunction; import java.util.EnumSet; import java.util.List; import java.util.Set; @@ -49,6 +50,13 @@ public class SubstraitOperatorTable implements SqlOperatorTable { .map(SqlOperator::getKind) .collect(Collectors.toList())); + // Additional Substrait-specific scalar operators that have no standard Calcite equivalent (e.g. + // the session-timezone context variable). These are looked up by name, but deliberately do NOT + // feed OVERRIDE_KINDS: they share generic kinds such as OTHER_FUNCTION with many standard + // operators, which we must not shadow. + private static final SqlOperatorTable SUBSTRAIT_SCALAR_OPERATOR_TABLE = + SqlOperatorTables.of(List.of(CurrentTimezoneFunction.INSTANCE)); + // Utilisation of extended library operators available from calcite 1.35+, i.e hyperbolic // functions private static final SqlOperatorTable LIBRARY_OPERATOR_TABLE = @@ -64,13 +72,14 @@ public class SubstraitOperatorTable implements SqlOperatorTable { private static final SqlOperatorTable STANDARD_OPERATOR_TABLE = SqlStdOperatorTable.instance(); private static final List OPERATOR_LIST = - Stream.concat( + Stream.of( SUBSTRAIT_OPERATOR_TABLE.getOperatorList().stream(), - Stream.concat( - LIBRARY_OPERATOR_TABLE.getOperatorList().stream(), - // filter out the kinds that have been overriden from the standard operator table - STANDARD_OPERATOR_TABLE.getOperatorList().stream() - .filter(op -> !OVERRIDE_KINDS.contains(op.kind)))) + SUBSTRAIT_SCALAR_OPERATOR_TABLE.getOperatorList().stream(), + LIBRARY_OPERATOR_TABLE.getOperatorList().stream(), + // filter out the kinds that have been overriden from the standard operator table + STANDARD_OPERATOR_TABLE.getOperatorList().stream() + .filter(op -> !OVERRIDE_KINDS.contains(op.kind))) + .flatMap(s -> s) .collect(Collectors.toUnmodifiableList()); /** Private constructor. */ @@ -105,6 +114,12 @@ public void lookupOperatorOverloads( return; } + SUBSTRAIT_SCALAR_OPERATOR_TABLE.lookupOperatorOverloads( + opName, category, syntax, operatorList, nameMatcher); + if (!operatorList.isEmpty()) { + return; + } + LIBRARY_OPERATOR_TABLE.lookupOperatorOverloads( opName, category, syntax, operatorList, nameMatcher); if (!operatorList.isEmpty()) { diff --git a/isthmus/src/main/java/io/substrait/isthmus/expression/CallConverters.java b/isthmus/src/main/java/io/substrait/isthmus/expression/CallConverters.java index 0d65b7517..a34b8f87e 100644 --- a/isthmus/src/main/java/io/substrait/isthmus/expression/CallConverters.java +++ b/isthmus/src/main/java/io/substrait/isthmus/expression/CallConverters.java @@ -19,6 +19,7 @@ import org.apache.calcite.rex.RexProgram; import org.apache.calcite.rex.RexUtil; import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; import org.jspecify.annotations.Nullable; /** @@ -236,6 +237,28 @@ else if (operand instanceof Expression.StructLiteral } }; + /** + * Converts Calcite's niladic execution-context operators to Substrait execution context variable + * {@link Expression}s: {@link SqlStdOperatorTable#CURRENT_TIMESTAMP} to {@link + * Expression.CurrentTimestamp} (with the precision taken from the call's result type), {@link + * SqlStdOperatorTable#CURRENT_DATE} to {@link Expression.CurrentDate}, and {@link + * CurrentTimezoneFunction} to {@link Expression.CurrentTimezone}. + * + *

Matching is done on operator identity (these are niladic {@link SqlKind#OTHER_FUNCTION} + * functions with no dedicated {@link SqlKind}). + */ + public static SimpleCallConverter EXECUTION_CONTEXT_VARIABLE = + (call, visitor) -> { + if (call.getOperator() == SqlStdOperatorTable.CURRENT_TIMESTAMP) { + return ExpressionCreator.currentTimestamp(call.getType().getPrecision()); + } else if (call.getOperator() == SqlStdOperatorTable.CURRENT_DATE) { + return ExpressionCreator.currentDate(); + } else if (call.getOperator() == CurrentTimezoneFunction.INSTANCE) { + return ExpressionCreator.currentTimezone(); + } + return null; + }; + /** * Returns the default set of converters for common calls. * @@ -249,6 +272,7 @@ public static List defaults(TypeConverter typeConverter) { CallConverters.ROW, CallConverters.CAST.apply(typeConverter), CallConverters.REINTERPRET.apply(typeConverter), + CallConverters.EXECUTION_CONTEXT_VARIABLE, new SqlArrayValueConstructorCallConverter(typeConverter), new SqlMapValueConstructorCallConverter()); } diff --git a/isthmus/src/main/java/io/substrait/isthmus/expression/CurrentTimezoneFunction.java b/isthmus/src/main/java/io/substrait/isthmus/expression/CurrentTimezoneFunction.java new file mode 100644 index 000000000..34e2a42f2 --- /dev/null +++ b/isthmus/src/main/java/io/substrait/isthmus/expression/CurrentTimezoneFunction.java @@ -0,0 +1,25 @@ +package io.substrait.isthmus.expression; + +import org.apache.calcite.sql.SqlFunctionCategory; +import org.apache.calcite.sql.fun.SqlBaseContextVariable; +import org.apache.calcite.sql.type.ReturnTypes; + +/** + * Niladic Substrait-specific operator representing the current session timezone (Substrait {@code + * current_timezone}). + * + *

Calcite has no built-in operator for the session timezone, so this is modeled on Calcite's own + * string context variables such as {@code CURRENT_ROLE} / {@code CURRENT_USER} (see {@link + * org.apache.calcite.sql.fun.SqlStringContextVariable}). Being a {@link SqlBaseContextVariable} it + * is niladic, has {@link org.apache.calcite.sql.SqlSyntax#FUNCTION_ID} syntax and is a dynamic + * function (plans referencing it are never cached). + */ +public class CurrentTimezoneFunction extends SqlBaseContextVariable { + + /** Singleton instance used by the Substrait ⇄ Calcite expression converters. */ + public static final CurrentTimezoneFunction INSTANCE = new CurrentTimezoneFunction(); + + private CurrentTimezoneFunction() { + super("CURRENT_TIMEZONE", ReturnTypes.VARCHAR_2000, SqlFunctionCategory.SYSTEM); + } +} diff --git a/isthmus/src/main/java/io/substrait/isthmus/expression/ExpressionRexConverter.java b/isthmus/src/main/java/io/substrait/isthmus/expression/ExpressionRexConverter.java index a7ace5d46..ea31d484b 100644 --- a/isthmus/src/main/java/io/substrait/isthmus/expression/ExpressionRexConverter.java +++ b/isthmus/src/main/java/io/substrait/isthmus/expression/ExpressionRexConverter.java @@ -850,6 +850,32 @@ public RexNode visit(Expression.DynamicParameter expr, Context context) throws R return rexBuilder.makeDynamicParam(calciteType, expr.parameterReference()); } + @Override + public RexNode visit(Expression.CurrentTimestamp expr, Context context) throws RuntimeException { + // Substrait current_timestamp is a precision_timestamp_tz, so force the return type to the + // corresponding Calcite TIMESTAMP_WITH_LOCAL_TIME_ZONE(precision); Calcite's CURRENT_TIMESTAMP + // operator would otherwise infer a plain (timezone-less) TIMESTAMP. + RelDataType returnType = typeConverter.toCalcite(typeFactory, expr.getType()); + return rexBuilder.makeCall( + returnType, SqlStdOperatorTable.CURRENT_TIMESTAMP, Collections.emptyList()); + } + + @Override + public RexNode visit(Expression.CurrentDate expr, Context context) throws RuntimeException { + RelDataType returnType = typeConverter.toCalcite(typeFactory, expr.getType()); + return rexBuilder.makeCall( + returnType, SqlStdOperatorTable.CURRENT_DATE, Collections.emptyList()); + } + + @Override + public RexNode visit(Expression.CurrentTimezone expr, Context context) throws RuntimeException { + // Calcite has no built-in session-timezone operator; use the Substrait-specific niladic + // CurrentTimezoneFunction, forcing the (required) string return type. + RelDataType returnType = typeConverter.toCalcite(typeFactory, expr.getType()); + return rexBuilder.makeCall( + returnType, CurrentTimezoneFunction.INSTANCE, Collections.emptyList()); + } + /** * Helper method to create a Calcite ROW expression for encoding UDT struct literals. * diff --git a/isthmus/src/test/java/io/substrait/isthmus/ExecutionContextVariableConversionTest.java b/isthmus/src/test/java/io/substrait/isthmus/ExecutionContextVariableConversionTest.java new file mode 100644 index 000000000..cf24d7585 --- /dev/null +++ b/isthmus/src/test/java/io/substrait/isthmus/ExecutionContextVariableConversionTest.java @@ -0,0 +1,73 @@ +package io.substrait.isthmus; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.substrait.expression.Expression; +import io.substrait.expression.ExpressionCreator; +import io.substrait.extension.DefaultExtensionCatalog; +import io.substrait.extension.SimpleExtension; +import io.substrait.isthmus.SubstraitRelNodeConverter.Context; +import io.substrait.isthmus.expression.CurrentTimezoneFunction; +import io.substrait.isthmus.expression.ExpressionRexConverter; +import io.substrait.isthmus.expression.RexExpressionConverter; +import io.substrait.isthmus.expression.ScalarFunctionConverter; +import io.substrait.type.TypeCreator; +import java.util.Collections; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.junit.jupiter.api.Test; + +/** + * Bi-directional conversion tests for the three Substrait execution context variables ({@code + * current_timestamp}, {@code current_date}, {@code current_timezone}) ⇄ Calcite. + */ +class ExecutionContextVariableConversionTest extends CalciteObjs { + + protected static final SimpleExtension.ExtensionCollection EXTENSION_COLLECTION = + DefaultExtensionCatalog.DEFAULT_COLLECTION; + + private final ScalarFunctionConverter scalarFunctionConverter = + new ScalarFunctionConverter(EXTENSION_COLLECTION.scalarFunctions(), type); + + private final ExpressionRexConverter expressionRexConverter = + new ExpressionRexConverter(type, scalarFunctionConverter, null, TypeConverter.DEFAULT); + + private final RexExpressionConverter rexExpressionConverter = new RexExpressionConverter(); + + @Test + void currentTimestamp() { + // Substrait current_timestamp is a precision_timestamp_tz -> Calcite + // TIMESTAMP_WITH_LOCAL_TIME_ZONE, carrying the fractional-second precision. + RelDataType returnType = + TypeConverter.DEFAULT.toCalcite(type, TypeCreator.REQUIRED.precisionTimestampTZ(6)); + bitest( + ExpressionCreator.currentTimestamp(6), + rex.makeCall(returnType, SqlStdOperatorTable.CURRENT_TIMESTAMP, Collections.emptyList())); + } + + @Test + void currentDate() { + RelDataType returnType = TypeConverter.DEFAULT.toCalcite(type, TypeCreator.REQUIRED.DATE); + bitest( + ExpressionCreator.currentDate(), + rex.makeCall(returnType, SqlStdOperatorTable.CURRENT_DATE, Collections.emptyList())); + } + + @Test + void currentTimezone() { + // Calcite has no built-in session-timezone operator; the Substrait-specific + // CurrentTimezoneFunction is used instead. + RelDataType returnType = TypeConverter.DEFAULT.toCalcite(type, TypeCreator.REQUIRED.STRING); + bitest( + ExpressionCreator.currentTimezone(), + rex.makeCall(returnType, CurrentTimezoneFunction.INSTANCE, Collections.emptyList())); + } + + // bi-directional test: 1) rex -> substrait, 2) substrait -> rex2, compare against expectations + void bitest(Expression expression, RexNode rexNode) { + assertEquals(expression, rexNode.accept(rexExpressionConverter)); + RexNode convertedRex = expression.accept(expressionRexConverter, Context.newContext()); + assertEquals(rexNode, convertedRex); + } +} diff --git a/isthmus/src/test/java/io/substrait/isthmus/Substrait2SqlTest.java b/isthmus/src/test/java/io/substrait/isthmus/Substrait2SqlTest.java index e8a5e7c23..bdc2077c3 100644 --- a/isthmus/src/test/java/io/substrait/isthmus/Substrait2SqlTest.java +++ b/isthmus/src/test/java/io/substrait/isthmus/Substrait2SqlTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; import io.substrait.isthmus.utils.SetUtils; import io.substrait.plan.Plan; @@ -38,6 +39,28 @@ void simpleTest2() throws Exception { assertFullRoundTrip(query); } + @Test + void currentTimestamp() throws Exception { + assertFullRoundTrip("select current_timestamp from part"); + } + + @Test + void currentDate() throws Exception { + assertFullRoundTrip("select current_date from part"); + } + + @Test + void currentTimezone() throws Exception { + // CURRENT_TIMEZONE is a Substrait-specific niladic operator with no standard Calcite + // equivalent; it is registered in SubstraitOperatorTable so it parses without parentheses. + assertFullRoundTrip("select current_timezone from part"); + + // ...and it is emitted back as the bare niladic keyword (no dialect changes required). + Plan plan = toSubstraitPlan("select current_timezone from part", TPCH_CATALOG); + assertTrue( + toSql(plan).contains("CURRENT_TIMEZONE"), "expected CURRENT_TIMEZONE in emitted SQL"); + } + @Test void simpleTestDateInterval() throws Exception { assertFullRoundTrip(