From e3f2e074830254a4419771070d3e79002fa5f0f1 Mon Sep 17 00:00:00 2001 From: Chen Dai Date: Thu, 7 May 2026 11:55:20 -0700 Subject: [PATCH 1/2] feat(api): Register LENGTH, REGEXP_REPLACE, DATE_TRUNC in operator table Add FunctionSpecBuilder DSL with three construction paths: delegateTo() for existing Calcite operators, vararg() for pushdown-only UDFs, and operands() for typed functions with optional late-binding impl. Register LENGTH, REGEXP_REPLACE, and DATE_TRUNC in UnifiedFunctionSpec LIBRARY category. Contribute via CoreExtension registered in UnifiedSqlSpec.extended(). This unblocks ClickBench q28 (LENGTH), q29 (REGEXP_REPLACE), and q43 (DATE_TRUNC) at the SQL Plugin parsing/validation layer. Signed-off-by: Chen Dai --- .../sql/api/spec/CoreExtension.java | 20 +++ .../sql/api/spec/FunctionSpecBuilder.java | 153 ++++++++++++++++++ .../sql/api/spec/UnifiedFunctionSpec.java | 97 +++++------ .../sql/api/spec/UnifiedSqlSpec.java | 2 +- .../sql/api/UnifiedFunctionLibraryTest.java | 90 +++++++++++ 5 files changed, 316 insertions(+), 46 deletions(-) create mode 100644 api/src/main/java/org/opensearch/sql/api/spec/CoreExtension.java create mode 100644 api/src/main/java/org/opensearch/sql/api/spec/FunctionSpecBuilder.java create mode 100644 api/src/test/java/org/opensearch/sql/api/UnifiedFunctionLibraryTest.java diff --git a/api/src/main/java/org/opensearch/sql/api/spec/CoreExtension.java b/api/src/main/java/org/opensearch/sql/api/spec/CoreExtension.java new file mode 100644 index 00000000000..005bed2f199 --- /dev/null +++ b/api/src/main/java/org/opensearch/sql/api/spec/CoreExtension.java @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.api.spec; + +import org.apache.calcite.sql.SqlOperatorTable; + +/** + * Core language extension: contributes library functions (LENGTH, REGEXP_REPLACE, DATE_TRUNC) that + * extend the standard Calcite operator table. These functions are shared across SQL and PPL. + */ +public class CoreExtension implements LanguageSpec.LanguageExtension { + + @Override + public SqlOperatorTable operators() { + return UnifiedFunctionSpec.LIBRARY.operatorTable(); + } +} diff --git a/api/src/main/java/org/opensearch/sql/api/spec/FunctionSpecBuilder.java b/api/src/main/java/org/opensearch/sql/api/spec/FunctionSpecBuilder.java new file mode 100644 index 00000000000..9b4e3cbad37 --- /dev/null +++ b/api/src/main/java/org/opensearch/sql/api/spec/FunctionSpecBuilder.java @@ -0,0 +1,153 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.api.spec; + +import java.util.List; +import java.util.Objects; +import java.util.function.BiFunction; +import javax.annotation.Nullable; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlFunction; +import org.apache.calcite.sql.SqlFunctionCategory; +import org.apache.calcite.sql.SqlIdentifier; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.SqlOperator; +import org.apache.calcite.sql.parser.SqlParserPos; +import org.apache.calcite.sql.type.InferTypes; +import org.apache.calcite.sql.type.OperandTypes; +import org.apache.calcite.sql.type.SqlReturnTypeInference; +import org.apache.calcite.sql.type.SqlTypeFamily; +import org.apache.calcite.sql.validate.SqlUserDefinedFunction; + +/** + * Fluent DSL for building {@link UnifiedFunctionSpec} instances. Dispatches to specialized builders + * based on the construction strategy: + * + * + */ +public class FunctionSpecBuilder { + private final String name; + + FunctionSpecBuilder(String name) { + this.name = name; + } + + /** Wrap an existing Calcite operator (preserves native type system and RexImpTable impl). */ + public DelegateBuilder delegateTo(SqlOperator op) { + return new DelegateBuilder(name, op); + } + + /** Build a pushdown-only function with relaxed type checking. */ + public UdfBuilder vararg(String... paramNames) { + return new UdfBuilder(name, List.of(paramNames)); + } + + /** Build a function with strict operand type checking. */ + public ScalarBuilder operands(SqlTypeFamily... families) { + return new ScalarBuilder(name, families); + } + + /** Wraps an existing Calcite operator as a function spec. */ + public static class DelegateBuilder { + private final String name; + private final SqlOperator operator; + + DelegateBuilder(String name, SqlOperator operator) { + this.name = name; + this.operator = operator; + } + + public UnifiedFunctionSpec build() { + return new UnifiedFunctionSpec(name.toLowerCase(), operator, null); + } + } + + /** Builds a pushdown-only SqlUserDefinedFunction with variadic operand metadata. */ + public static class UdfBuilder { + private final String name; + private final List paramNames; + private SqlReturnTypeInference returnType; + + UdfBuilder(String name, List paramNames) { + this.name = name; + this.paramNames = paramNames; + } + + /** Set return type inference. */ + public UdfBuilder returnType(SqlReturnTypeInference type) { + this.returnType = type; + return this; + } + + public UnifiedFunctionSpec build() { + Objects.requireNonNull(returnType, "returnType is required"); + return new UnifiedFunctionSpec( + name, + new SqlUserDefinedFunction( + new SqlIdentifier(name, SqlParserPos.ZERO), + SqlKind.OTHER_FUNCTION, + returnType, + InferTypes.ANY_NULLABLE, + new UnifiedFunctionSpec.VariadicOperandMetadata(paramNames), + List::of), // Pushdown-only: no local implementation + null); + } + } + + /** Builds a SqlFunction with strict operand type families and optional late-binding impl. */ + public static class ScalarBuilder { + private final String name; + private final SqlTypeFamily[] operandFamilies; + private SqlReturnTypeInference returnType; + private SqlFunctionCategory category = SqlFunctionCategory.USER_DEFINED_FUNCTION; + private @Nullable BiFunction impl; + + ScalarBuilder(String name, SqlTypeFamily[] operandFamilies) { + this.name = name; + this.operandFamilies = operandFamilies; + } + + /** Set return type inference. */ + public ScalarBuilder returns(SqlReturnTypeInference type) { + this.returnType = type; + return this; + } + + /** Set function category. */ + public ScalarBuilder category(SqlFunctionCategory cat) { + this.category = cat; + return this; + } + + /** + * Define how this function executes by rewriting to existing Calcite operators. Applied only at + * compilation time (late binding) — the logical plan preserves the original function call. + */ + public ScalarBuilder impl(BiFunction impl) { + this.impl = impl; + return this; + } + + public UnifiedFunctionSpec build() { + Objects.requireNonNull(returnType, "returns() is required"); + SqlFunction op = + new SqlFunction( + name.toUpperCase(), + SqlKind.OTHER_FUNCTION, + returnType, + null, + OperandTypes.family(operandFamilies), + category); + return new UnifiedFunctionSpec(name.toLowerCase(), op, impl); + } + } +} diff --git a/api/src/main/java/org/opensearch/sql/api/spec/UnifiedFunctionSpec.java b/api/src/main/java/org/opensearch/sql/api/spec/UnifiedFunctionSpec.java index f60fc61a50c..10448ea0bd6 100644 --- a/api/src/main/java/org/opensearch/sql/api/spec/UnifiedFunctionSpec.java +++ b/api/src/main/java/org/opensearch/sql/api/spec/UnifiedFunctionSpec.java @@ -9,30 +9,30 @@ import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; +import java.util.function.BiFunction; import java.util.stream.Collectors; import java.util.stream.Stream; -import lombok.AccessLevel; +import javax.annotation.Nullable; import lombok.EqualsAndHashCode; import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.ToString; import org.apache.calcite.rel.type.RelDataType; import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexNode; import org.apache.calcite.sql.SqlCallBinding; -import org.apache.calcite.sql.SqlIdentifier; -import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.SqlFunctionCategory; import org.apache.calcite.sql.SqlOperandCountRange; import org.apache.calcite.sql.SqlOperator; import org.apache.calcite.sql.SqlOperatorTable; -import org.apache.calcite.sql.parser.SqlParserPos; -import org.apache.calcite.sql.type.InferTypes; +import org.apache.calcite.sql.fun.SqlLibraryOperators; +import org.apache.calcite.sql.type.ReturnTypes; import org.apache.calcite.sql.type.SqlOperandCountRanges; import org.apache.calcite.sql.type.SqlOperandMetadata; -import org.apache.calcite.sql.type.SqlReturnTypeInference; +import org.apache.calcite.sql.type.SqlTypeFamily; import org.apache.calcite.sql.util.SqlOperatorTables; -import org.apache.calcite.sql.validate.SqlUserDefinedFunction; /** * Declarative registry of language-level functions for the unified query engine. Functions defined @@ -43,7 +43,6 @@ @Getter @ToString(of = "funcName") @EqualsAndHashCode(of = "funcName") -@RequiredArgsConstructor(access = AccessLevel.PRIVATE) public final class UnifiedFunctionSpec { /** Function name as registered in the operator table (e.g., "match", "multi_match"). */ @@ -52,6 +51,22 @@ public final class UnifiedFunctionSpec { /** Calcite operator for chaining into the framework config's operator table. */ private final SqlOperator operator; + /** + * Optional Rex-level implementation that rewrites this function into executable RexNodes. Applied + * only at compilation time (late binding) — the logical plan preserves the original function call + * for consumers like the Analytics Engine. + */ + private final @Nullable BiFunction impl; + + UnifiedFunctionSpec( + String funcName, + SqlOperator operator, + @Nullable BiFunction impl) { + this.funcName = funcName; + this.operator = operator; + this.impl = impl; + } + /** Full-text search functions. */ public static final Category RELEVANCE = new Category( @@ -64,12 +79,34 @@ public final class UnifiedFunctionSpec { function("simple_query_string").vararg("fields", "query").returnType(BOOLEAN).build(), function("query_string").vararg("fields", "query").returnType(BOOLEAN).build())); + /** Common functions beyond ANSI standard (shared across SQL and PPL). */ + public static final Category LIBRARY = + new Category( + List.of( + function("length").delegateTo(SqlLibraryOperators.LENGTH).build(), + function("regexp_replace").delegateTo(SqlLibraryOperators.REGEXP_REPLACE_3).build(), + function("date_trunc") + .operands(SqlTypeFamily.CHARACTER, SqlTypeFamily.DATETIME) + .returns(ReturnTypes.ARG1_NULLABLE) + .category(SqlFunctionCategory.TIMEDATE) + .build())); + /** All registered function specs, keyed by function name. */ private static final Map ALL_SPECS = - Stream.of(RELEVANCE) + Stream.of(RELEVANCE, LIBRARY) .flatMap(c -> c.specs().stream()) .collect(Collectors.toMap(UnifiedFunctionSpec::getFuncName, s -> s)); + /** + * Returns all specs that have a non-null impl, keyed by their operator. Used by pre-compilation + * rules to bind function implementations at execution time. + */ + public static Map> implBindings() { + return ALL_SPECS.values().stream() + .filter(spec -> spec.impl != null) + .collect(Collectors.toMap(spec -> spec.operator, spec -> spec.impl)); + } + /** * Looks up a function spec by name across all categories. * @@ -101,39 +138,9 @@ public boolean contains(UnifiedFunctionSpec spec) { } } - public static Builder function(String name) { - return new Builder(name); - } - - /** Fluent builder for function specs. */ - @RequiredArgsConstructor(access = AccessLevel.PRIVATE) - public static class Builder { - private final String funcName; - private List paramNames = List.of(); - private SqlReturnTypeInference returnType; - - public Builder vararg(String... names) { - this.paramNames = List.of(names); - return this; - } - - public Builder returnType(SqlReturnTypeInference type) { - this.returnType = type; - return this; - } - - public UnifiedFunctionSpec build() { - Objects.requireNonNull(returnType, "returnType is required"); - return new UnifiedFunctionSpec( - funcName, - new SqlUserDefinedFunction( - new SqlIdentifier(funcName, SqlParserPos.ZERO), - SqlKind.OTHER_FUNCTION, - returnType, - InferTypes.ANY_NULLABLE, - new VariadicOperandMetadata(paramNames), - List::of)); // Pushdown-only: no local implementation - } + /** Entry point for the function spec builder DSL. */ + public static FunctionSpecBuilder function(String name) { + return new FunctionSpecBuilder(name); } /** @@ -141,7 +148,7 @@ public UnifiedFunctionSpec build() { * FamilyOperandTypeChecker} rejects variadic calls (CALCITE-5366), so this implementation accepts * any operand types and delegates validation to pushdown. */ - private record VariadicOperandMetadata(List paramNames) implements SqlOperandMetadata { + record VariadicOperandMetadata(List paramNames) implements SqlOperandMetadata { @Override public List paramNames() { diff --git a/api/src/main/java/org/opensearch/sql/api/spec/UnifiedSqlSpec.java b/api/src/main/java/org/opensearch/sql/api/spec/UnifiedSqlSpec.java index a5433f015fa..021da58247d 100644 --- a/api/src/main/java/org/opensearch/sql/api/spec/UnifiedSqlSpec.java +++ b/api/src/main/java/org/opensearch/sql/api/spec/UnifiedSqlSpec.java @@ -50,7 +50,7 @@ public static UnifiedSqlSpec extended() { Lex.BIG_QUERY, SqlBabelParserImpl.FACTORY, SqlConformanceEnum.BABEL, - List.of(new SearchExtension())); + List.of(new CoreExtension(), new SearchExtension())); } @Override diff --git a/api/src/test/java/org/opensearch/sql/api/UnifiedFunctionLibraryTest.java b/api/src/test/java/org/opensearch/sql/api/UnifiedFunctionLibraryTest.java new file mode 100644 index 00000000000..9f65fc31350 --- /dev/null +++ b/api/src/test/java/org/opensearch/sql/api/UnifiedFunctionLibraryTest.java @@ -0,0 +1,90 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.api; + +import static java.sql.Types.INTEGER; +import static java.sql.Types.VARCHAR; +import static org.junit.Assert.assertTrue; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import org.apache.calcite.rel.RelNode; +import org.junit.Before; +import org.junit.Test; +import org.opensearch.sql.api.compiler.UnifiedQueryCompiler; +import org.opensearch.sql.api.spec.UnifiedFunctionSpec; +import org.opensearch.sql.executor.QueryType; + +/** + * Tests for library functions registered in {@link UnifiedFunctionSpec#LIBRARY}. Verifies both + * planning (function resolves correctly) and execution (produces correct results in-memory). + */ +public class UnifiedFunctionLibraryTest extends UnifiedQueryTestBase implements ResultSetAssertion { + + private UnifiedQueryCompiler compiler; + + @Override + protected QueryType queryType() { + return QueryType.SQL; + } + + @Before + public void setUp() { + super.setUp(); + compiler = new UnifiedQueryCompiler(context); + } + + @Test + public void testLengthPlanning() { + givenQuery("SELECT LENGTH(name) AS len FROM catalog.employees") + .assertPlanContains("LENGTH($1)"); + } + + @Test + public void testLengthExecution() throws Exception { + RelNode plan = planner.plan("SELECT LENGTH(name) AS len FROM catalog.employees"); + try (PreparedStatement stmt = compiler.compile(plan)) { + ResultSet rs = stmt.executeQuery(); + verify(rs) + .expectSchema(col("len", INTEGER)) + .expectData(row(5), row(3), row(7), row(5)); // Alice, Bob, Charlie, Diana + } + } + + @Test + public void testRegexpReplacePlanning() { + givenQuery("SELECT REGEXP_REPLACE(name, 'A.*', 'X') AS replaced FROM catalog.employees") + .assertPlanContains("REGEXP_REPLACE($1, 'A.*', 'X')"); + } + + @Test + public void testRegexpReplaceExecution() throws Exception { + RelNode plan = + planner.plan("SELECT REGEXP_REPLACE(name, '^A.*', 'Replaced') FROM catalog.employees"); + try (PreparedStatement stmt = compiler.compile(plan)) { + ResultSet rs = stmt.executeQuery(); + verify(rs) + .expectSchema(col("EXPR$0", VARCHAR)) + .expectData(row("Replaced"), row("Bob"), row("Charlie"), row("Diana")); + } + } + + @Test + public void testDateTruncPlanning() { + // Plan preserves DATE_TRUNC (late binding — not rewritten until compilation) + givenQuery( + "SELECT DATE_TRUNC('minute', TIMESTAMP '2023-01-01 12:34:56') AS truncated" + + " FROM catalog.employees") + .assertPlanContains("DATE_TRUNC('minute'"); + } + + @Test + public void testFunctionSpecLookup() { + assertTrue(UnifiedFunctionSpec.of("length").isPresent()); + assertTrue(UnifiedFunctionSpec.of("regexp_replace").isPresent()); + assertTrue(UnifiedFunctionSpec.of("date_trunc").isPresent()); + } +} From 6561560c70277d39a2839f4e0a353554eb96eb48 Mon Sep 17 00:00:00 2001 From: Chen Dai Date: Thu, 7 May 2026 11:57:10 -0700 Subject: [PATCH 2/2] feat(api): Add pre-compilation rule for late-binding function impl Add preCompilationRules() extension point to LanguageSpec that allows extensions to transform the logical plan before in-memory execution only. The plan remains clean for external consumers (Analytics Engine). CoreExtension registers FunctionImplBindingRule which fetches impl bindings from UnifiedFunctionSpec and rewrites custom function calls into executable Calcite expressions at compilation time. DATE_TRUNC now has an impl that rewrites to FLOOR(ts TO unit), making it executable in-memory while preserving DATE_TRUNC in the logical plan for the Analytics Engine path. Signed-off-by: Chen Dai --- .../api/compiler/UnifiedQueryCompiler.java | 5 + .../sql/api/spec/CoreExtension.java | 20 -- .../sql/api/spec/FunctionSpecBuilder.java | 187 ++++++++++-------- .../opensearch/sql/api/spec/LanguageSpec.java | 17 ++ .../sql/api/spec/UnifiedFunctionSpec.java | 117 ++++------- .../sql/api/spec/UnifiedSqlSpec.java | 1 + .../sql/api/spec/core/CoreExtension.java | 28 +++ .../spec/core/LateBindingFunctionRule.java | 49 +++++ .../sql/api/UnifiedFunctionLibraryTest.java | 90 --------- .../sql/api/UnifiedFunctionSpecTest.java | 79 ++++++++ 10 files changed, 326 insertions(+), 267 deletions(-) delete mode 100644 api/src/main/java/org/opensearch/sql/api/spec/CoreExtension.java create mode 100644 api/src/main/java/org/opensearch/sql/api/spec/core/CoreExtension.java create mode 100644 api/src/main/java/org/opensearch/sql/api/spec/core/LateBindingFunctionRule.java delete mode 100644 api/src/test/java/org/opensearch/sql/api/UnifiedFunctionLibraryTest.java create mode 100644 api/src/test/java/org/opensearch/sql/api/UnifiedFunctionSpecTest.java diff --git a/api/src/main/java/org/opensearch/sql/api/compiler/UnifiedQueryCompiler.java b/api/src/main/java/org/opensearch/sql/api/compiler/UnifiedQueryCompiler.java index 9caa2125427..4554b3d060d 100644 --- a/api/src/main/java/org/opensearch/sql/api/compiler/UnifiedQueryCompiler.java +++ b/api/src/main/java/org/opensearch/sql/api/compiler/UnifiedQueryCompiler.java @@ -55,6 +55,11 @@ public PreparedStatement compile(@NonNull RelNode plan) { } private PreparedStatement doCompile(RelNode plan) throws Exception { + // Apply pre-compilation rules (e.g., late-binding function impl) + for (var rule : context.getLangSpec().preCompilationRules()) { + plan = plan.accept(rule); + } + // Apply shuttle to convert LogicalTableScan to BindableTableScan final RelHomogeneousShuttle shuttle = new RelHomogeneousShuttle() { diff --git a/api/src/main/java/org/opensearch/sql/api/spec/CoreExtension.java b/api/src/main/java/org/opensearch/sql/api/spec/CoreExtension.java deleted file mode 100644 index 005bed2f199..00000000000 --- a/api/src/main/java/org/opensearch/sql/api/spec/CoreExtension.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.opensearch.sql.api.spec; - -import org.apache.calcite.sql.SqlOperatorTable; - -/** - * Core language extension: contributes library functions (LENGTH, REGEXP_REPLACE, DATE_TRUNC) that - * extend the standard Calcite operator table. These functions are shared across SQL and PPL. - */ -public class CoreExtension implements LanguageSpec.LanguageExtension { - - @Override - public SqlOperatorTable operators() { - return UnifiedFunctionSpec.LIBRARY.operatorTable(); - } -} diff --git a/api/src/main/java/org/opensearch/sql/api/spec/FunctionSpecBuilder.java b/api/src/main/java/org/opensearch/sql/api/spec/FunctionSpecBuilder.java index 9b4e3cbad37..e1916d33f5b 100644 --- a/api/src/main/java/org/opensearch/sql/api/spec/FunctionSpecBuilder.java +++ b/api/src/main/java/org/opensearch/sql/api/spec/FunctionSpecBuilder.java @@ -9,86 +9,134 @@ import java.util.Objects; import java.util.function.BiFunction; import javax.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; import org.apache.calcite.rex.RexBuilder; import org.apache.calcite.rex.RexCall; import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlCallBinding; import org.apache.calcite.sql.SqlFunction; import org.apache.calcite.sql.SqlFunctionCategory; import org.apache.calcite.sql.SqlIdentifier; import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.SqlOperandCountRange; import org.apache.calcite.sql.SqlOperator; import org.apache.calcite.sql.parser.SqlParserPos; import org.apache.calcite.sql.type.InferTypes; import org.apache.calcite.sql.type.OperandTypes; +import org.apache.calcite.sql.type.SqlOperandCountRanges; +import org.apache.calcite.sql.type.SqlOperandMetadata; import org.apache.calcite.sql.type.SqlReturnTypeInference; import org.apache.calcite.sql.type.SqlTypeFamily; import org.apache.calcite.sql.validate.SqlUserDefinedFunction; -/** - * Fluent DSL for building {@link UnifiedFunctionSpec} instances. Dispatches to specialized builders - * based on the construction strategy: - * - *
    - *
  • {@link #delegateTo(SqlOperator)} — wraps an existing Calcite operator - *
  • {@link #vararg(String...)} — builds a pushdown-only UDF with relaxed type checking - *
  • {@link #operands(SqlTypeFamily...)} — builds a typed SqlFunction with strict operands - *
- */ -public class FunctionSpecBuilder { +/** Fluent DSL for building {@link UnifiedFunctionSpec} instances. */ +@RequiredArgsConstructor +class FunctionSpecBuilder { + /** Function name to register. */ private final String name; - FunctionSpecBuilder(String name) { - this.name = name; + /** + * Wraps an existing Calcite operator, preserving its native type system and RexImpTable + * implementation for in-memory execution. + * + * @param op the Calcite operator to delegate to + * @return a builder that produces the spec on {@code build()} + */ + DelegateFunctionBuilder delegateTo(SqlOperator op) { + return new DelegateFunctionBuilder(name, op); } - /** Wrap an existing Calcite operator (preserves native type system and RexImpTable impl). */ - public DelegateBuilder delegateTo(SqlOperator op) { - return new DelegateBuilder(name, op); + /** + * Builds a pushdown-only UDF with relaxed type checking. The resulting function has no local + * implementation and delegates execution to the data source via pushdown. + * + * @param paramNames required parameter names for signature display + * @return a builder that produces the spec on {@code build()} + */ + CatalogFunctionBuilder vararg(String... paramNames) { + return new CatalogFunctionBuilder(name, List.of(paramNames)); } - /** Build a pushdown-only function with relaxed type checking. */ - public UdfBuilder vararg(String... paramNames) { - return new UdfBuilder(name, List.of(paramNames)); + /** + * Builds a typed SqlFunction with strict operand type checking. Optionally accepts a late-binding + * {@code impl} that rewrites the function into executable Calcite expressions at compilation + * time. + * + * @param families operand type families for validation + * @return a builder that produces the spec on {@code build()} + */ + DefaultFunctionBuilder operands(SqlTypeFamily... families) { + return new DefaultFunctionBuilder(name, families); } - /** Build a function with strict operand type checking. */ - public ScalarBuilder operands(SqlTypeFamily... families) { - return new ScalarBuilder(name, families); + @RequiredArgsConstructor + static class DefaultFunctionBuilder { + private final String name; + private final SqlTypeFamily[] operandFamilies; + private SqlReturnTypeInference returnType; + private SqlFunctionCategory category = SqlFunctionCategory.USER_DEFINED_FUNCTION; + private @Nullable BiFunction impl; + + DefaultFunctionBuilder returns(SqlReturnTypeInference type) { + this.returnType = type; + return this; + } + + DefaultFunctionBuilder category(SqlFunctionCategory cat) { + this.category = cat; + return this; + } + + /** + * Defines how this function executes by rewriting to existing Calcite operators. Applied only + * at compilation time (late binding) — the logical plan preserves the original function call. + * + * @param impl rewrite function that converts this call into executable RexNodes + * @return this builder + */ + DefaultFunctionBuilder impl(BiFunction impl) { + this.impl = impl; + return this; + } + + UnifiedFunctionSpec build() { + Objects.requireNonNull(returnType, "returns() is required"); + SqlFunction op = + new SqlFunction( + name.toUpperCase(), + SqlKind.OTHER_FUNCTION, + returnType, + null, + OperandTypes.family(operandFamilies), + category); + return new UnifiedFunctionSpec(name.toLowerCase(), op, impl); + } } - /** Wraps an existing Calcite operator as a function spec. */ - public static class DelegateBuilder { + @RequiredArgsConstructor + static class DelegateFunctionBuilder { private final String name; private final SqlOperator operator; - DelegateBuilder(String name, SqlOperator operator) { - this.name = name; - this.operator = operator; - } - - public UnifiedFunctionSpec build() { + UnifiedFunctionSpec build() { return new UnifiedFunctionSpec(name.toLowerCase(), operator, null); } } - /** Builds a pushdown-only SqlUserDefinedFunction with variadic operand metadata. */ - public static class UdfBuilder { + @RequiredArgsConstructor + static class CatalogFunctionBuilder { private final String name; private final List paramNames; private SqlReturnTypeInference returnType; - UdfBuilder(String name, List paramNames) { - this.name = name; - this.paramNames = paramNames; - } - - /** Set return type inference. */ - public UdfBuilder returnType(SqlReturnTypeInference type) { + CatalogFunctionBuilder returnType(SqlReturnTypeInference type) { this.returnType = type; return this; } - public UnifiedFunctionSpec build() { + UnifiedFunctionSpec build() { Objects.requireNonNull(returnType, "returnType is required"); return new UnifiedFunctionSpec( name, @@ -97,57 +145,42 @@ public UnifiedFunctionSpec build() { SqlKind.OTHER_FUNCTION, returnType, InferTypes.ANY_NULLABLE, - new UnifiedFunctionSpec.VariadicOperandMetadata(paramNames), + new VariadicOperandMetadata(paramNames), List::of), // Pushdown-only: no local implementation null); } } - /** Builds a SqlFunction with strict operand type families and optional late-binding impl. */ - public static class ScalarBuilder { - private final String name; - private final SqlTypeFamily[] operandFamilies; - private SqlReturnTypeInference returnType; - private SqlFunctionCategory category = SqlFunctionCategory.USER_DEFINED_FUNCTION; - private @Nullable BiFunction impl; + /** + * Custom operand metadata that bypasses Calcite's built-in type checking. Calcite's {@code + * FamilyOperandTypeChecker} rejects variadic calls (CALCITE-5366), so this implementation accepts + * any operand types and delegates validation to pushdown. + */ + record VariadicOperandMetadata(List paramNames) implements SqlOperandMetadata { - ScalarBuilder(String name, SqlTypeFamily[] operandFamilies) { - this.name = name; - this.operandFamilies = operandFamilies; + @Override + public List paramNames() { + return paramNames; } - /** Set return type inference. */ - public ScalarBuilder returns(SqlReturnTypeInference type) { - this.returnType = type; - return this; + @Override + public List paramTypes(RelDataTypeFactory tf) { + return List.of(); } - /** Set function category. */ - public ScalarBuilder category(SqlFunctionCategory cat) { - this.category = cat; - return this; + @Override + public boolean checkOperandTypes(SqlCallBinding binding, boolean throwOnFailure) { + return true; } - /** - * Define how this function executes by rewriting to existing Calcite operators. Applied only at - * compilation time (late binding) — the logical plan preserves the original function call. - */ - public ScalarBuilder impl(BiFunction impl) { - this.impl = impl; - return this; + @Override + public SqlOperandCountRange getOperandCountRange() { + return SqlOperandCountRanges.from(paramNames.size()); } - public UnifiedFunctionSpec build() { - Objects.requireNonNull(returnType, "returns() is required"); - SqlFunction op = - new SqlFunction( - name.toUpperCase(), - SqlKind.OTHER_FUNCTION, - returnType, - null, - OperandTypes.family(operandFamilies), - category); - return new UnifiedFunctionSpec(name.toLowerCase(), op, impl); + @Override + public String getAllowedSignatures(SqlOperator op, String opName) { + return opName + "(" + String.join(", ", paramNames) + "[, option=value ...])"; } } } diff --git a/api/src/main/java/org/opensearch/sql/api/spec/LanguageSpec.java b/api/src/main/java/org/opensearch/sql/api/spec/LanguageSpec.java index e824c89f8de..4009ee13bc0 100644 --- a/api/src/main/java/org/opensearch/sql/api/spec/LanguageSpec.java +++ b/api/src/main/java/org/opensearch/sql/api/spec/LanguageSpec.java @@ -57,6 +57,15 @@ default List> postParseRules() { default List postAnalysisRules() { return List.of(); } + + /** + * Pre-compilation rules applied only before in-memory execution. Each rule transforms the + * logical plan (e.g., binding function implementations). Not applied when the plan is consumed + * by external engines. + */ + default List preCompilationRules() { + return List.of(); + } } /** @@ -104,4 +113,12 @@ default List> postParseRules() { default List postAnalysisRules() { return extensions().stream().flatMap(ext -> ext.postAnalysisRules().stream()).toList(); } + + /** + * All pre-compilation rules from registered extensions, flattened in registration order. Applied + * only before in-memory execution. + */ + default List preCompilationRules() { + return extensions().stream().flatMap(ext -> ext.preCompilationRules().stream()).toList(); + } } diff --git a/api/src/main/java/org/opensearch/sql/api/spec/UnifiedFunctionSpec.java b/api/src/main/java/org/opensearch/sql/api/spec/UnifiedFunctionSpec.java index 10448ea0bd6..72392b7c520 100644 --- a/api/src/main/java/org/opensearch/sql/api/spec/UnifiedFunctionSpec.java +++ b/api/src/main/java/org/opensearch/sql/api/spec/UnifiedFunctionSpec.java @@ -5,7 +5,14 @@ package org.opensearch.sql.api.spec; +import static org.apache.calcite.sql.SqlFunctionCategory.TIMEDATE; +import static org.apache.calcite.sql.fun.SqlLibraryOperators.LENGTH; +import static org.apache.calcite.sql.fun.SqlLibraryOperators.REGEXP_REPLACE_3; +import static org.apache.calcite.sql.fun.SqlStdOperatorTable.FLOOR; +import static org.apache.calcite.sql.type.ReturnTypes.ARG1_NULLABLE; import static org.apache.calcite.sql.type.ReturnTypes.BOOLEAN; +import static org.apache.calcite.sql.type.SqlTypeFamily.CHARACTER; +import static org.apache.calcite.sql.type.SqlTypeFamily.DATETIME; import java.util.List; import java.util.Map; @@ -14,24 +21,19 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nullable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; -import org.apache.calcite.rel.type.RelDataType; -import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.avatica.util.TimeUnitRange; import org.apache.calcite.rex.RexBuilder; import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexLiteral; import org.apache.calcite.rex.RexNode; -import org.apache.calcite.sql.SqlCallBinding; -import org.apache.calcite.sql.SqlFunctionCategory; -import org.apache.calcite.sql.SqlOperandCountRange; import org.apache.calcite.sql.SqlOperator; import org.apache.calcite.sql.SqlOperatorTable; -import org.apache.calcite.sql.fun.SqlLibraryOperators; -import org.apache.calcite.sql.type.ReturnTypes; -import org.apache.calcite.sql.type.SqlOperandCountRanges; import org.apache.calcite.sql.type.SqlOperandMetadata; -import org.apache.calcite.sql.type.SqlTypeFamily; import org.apache.calcite.sql.util.SqlOperatorTables; /** @@ -43,6 +45,7 @@ @Getter @ToString(of = "funcName") @EqualsAndHashCode(of = "funcName") +@AllArgsConstructor(access = AccessLevel.PACKAGE) public final class UnifiedFunctionSpec { /** Function name as registered in the operator table (e.g., "match", "multi_match"). */ @@ -51,21 +54,30 @@ public final class UnifiedFunctionSpec { /** Calcite operator for chaining into the framework config's operator table. */ private final SqlOperator operator; - /** - * Optional Rex-level implementation that rewrites this function into executable RexNodes. Applied - * only at compilation time (late binding) — the logical plan preserves the original function call - * for consumers like the Analytics Engine. - */ + /** Optional late-binding implementation applied only at compilation time. */ private final @Nullable BiFunction impl; - UnifiedFunctionSpec( - String funcName, - SqlOperator operator, - @Nullable BiFunction impl) { - this.funcName = funcName; - this.operator = operator; - this.impl = impl; - } + /** Common scalar functions beyond standard. */ + public static final Category SCALAR = + new Category( + List.of( + function("length").delegateTo(LENGTH).build(), + function("regexp_replace").delegateTo(REGEXP_REPLACE_3).build(), + function("date_trunc") + .operands(CHARACTER, DATETIME) + .returns(ARG1_NULLABLE) + .category(TIMEDATE) + .impl( + (rexBuilder, call) -> { + RexLiteral unitLiteral = (RexLiteral) call.operands.get(0); + String unit = unitLiteral.getValueAs(String.class); + RexNode datetime = call.operands.get(1); + return rexBuilder.makeCall( + FLOOR, + datetime, + rexBuilder.makeFlag(TimeUnitRange.valueOf(unit.toUpperCase()))); + }) + .build())); /** Full-text search functions. */ public static final Category RELEVANCE = @@ -79,34 +91,12 @@ public final class UnifiedFunctionSpec { function("simple_query_string").vararg("fields", "query").returnType(BOOLEAN).build(), function("query_string").vararg("fields", "query").returnType(BOOLEAN).build())); - /** Common functions beyond ANSI standard (shared across SQL and PPL). */ - public static final Category LIBRARY = - new Category( - List.of( - function("length").delegateTo(SqlLibraryOperators.LENGTH).build(), - function("regexp_replace").delegateTo(SqlLibraryOperators.REGEXP_REPLACE_3).build(), - function("date_trunc") - .operands(SqlTypeFamily.CHARACTER, SqlTypeFamily.DATETIME) - .returns(ReturnTypes.ARG1_NULLABLE) - .category(SqlFunctionCategory.TIMEDATE) - .build())); - /** All registered function specs, keyed by function name. */ - private static final Map ALL_SPECS = - Stream.of(RELEVANCE, LIBRARY) + public static final Map ALL_SPECS = + Stream.of(SCALAR, RELEVANCE) .flatMap(c -> c.specs().stream()) .collect(Collectors.toMap(UnifiedFunctionSpec::getFuncName, s -> s)); - /** - * Returns all specs that have a non-null impl, keyed by their operator. Used by pre-compilation - * rules to bind function implementations at execution time. - */ - public static Map> implBindings() { - return ALL_SPECS.values().stream() - .filter(spec -> spec.impl != null) - .collect(Collectors.toMap(spec -> spec.operator, spec -> spec.impl)); - } - /** * Looks up a function spec by name across all categories. * @@ -139,40 +129,7 @@ public boolean contains(UnifiedFunctionSpec spec) { } /** Entry point for the function spec builder DSL. */ - public static FunctionSpecBuilder function(String name) { + private static FunctionSpecBuilder function(String name) { return new FunctionSpecBuilder(name); } - - /** - * Custom operand metadata that bypasses Calcite's built-in type checking. Calcite's {@code - * FamilyOperandTypeChecker} rejects variadic calls (CALCITE-5366), so this implementation accepts - * any operand types and delegates validation to pushdown. - */ - record VariadicOperandMetadata(List paramNames) implements SqlOperandMetadata { - - @Override - public List paramNames() { - return paramNames; - } - - @Override - public List paramTypes(RelDataTypeFactory tf) { - return List.of(); - } - - @Override - public boolean checkOperandTypes(SqlCallBinding binding, boolean throwOnFailure) { - return true; // Bypass: CALCITE-5366 breaks optional argument type checking - } - - @Override - public SqlOperandCountRange getOperandCountRange() { - return SqlOperandCountRanges.from(paramNames.size()); - } - - @Override - public String getAllowedSignatures(SqlOperator op, String opName) { - return opName + "(" + String.join(", ", paramNames) + "[, option=value ...])"; - } - } } diff --git a/api/src/main/java/org/opensearch/sql/api/spec/UnifiedSqlSpec.java b/api/src/main/java/org/opensearch/sql/api/spec/UnifiedSqlSpec.java index 021da58247d..28eeaa89abf 100644 --- a/api/src/main/java/org/opensearch/sql/api/spec/UnifiedSqlSpec.java +++ b/api/src/main/java/org/opensearch/sql/api/spec/UnifiedSqlSpec.java @@ -16,6 +16,7 @@ import org.apache.calcite.sql.parser.babel.SqlBabelParserImpl; import org.apache.calcite.sql.validate.SqlConformanceEnum; import org.apache.calcite.sql.validate.SqlValidator; +import org.opensearch.sql.api.spec.core.CoreExtension; import org.opensearch.sql.api.spec.search.SearchExtension; /** diff --git a/api/src/main/java/org/opensearch/sql/api/spec/core/CoreExtension.java b/api/src/main/java/org/opensearch/sql/api/spec/core/CoreExtension.java new file mode 100644 index 00000000000..17aa8a20bee --- /dev/null +++ b/api/src/main/java/org/opensearch/sql/api/spec/core/CoreExtension.java @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.api.spec.core; + +import java.util.List; +import org.apache.calcite.rel.RelShuttle; +import org.apache.calcite.sql.SqlOperatorTable; +import org.opensearch.sql.api.spec.LanguageSpec; +import org.opensearch.sql.api.spec.UnifiedFunctionSpec; + +/** + * Core extension that extends the default language spec with additional functions and capabilities. + */ +public class CoreExtension implements LanguageSpec.LanguageExtension { + + @Override + public SqlOperatorTable operators() { + return UnifiedFunctionSpec.SCALAR.operatorTable(); + } + + @Override + public List preCompilationRules() { + return List.of(new LateBindingFunctionRule()); + } +} diff --git a/api/src/main/java/org/opensearch/sql/api/spec/core/LateBindingFunctionRule.java b/api/src/main/java/org/opensearch/sql/api/spec/core/LateBindingFunctionRule.java new file mode 100644 index 00000000000..3294d21a241 --- /dev/null +++ b/api/src/main/java/org/opensearch/sql/api/spec/core/LateBindingFunctionRule.java @@ -0,0 +1,49 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.api.spec.core; + +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.stream.Collectors; +import org.apache.calcite.rel.RelHomogeneousShuttle; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.rex.RexShuttle; +import org.apache.calcite.sql.SqlOperator; +import org.opensearch.sql.api.spec.UnifiedFunctionSpec; + +/** + * Binds custom function implementations at compilation time by rewriting to executable Calcite + * expressions. + */ +class LateBindingFunctionRule extends RelHomogeneousShuttle { + + /** Operator-to-impl mappings collected from all function specs. */ + private final Map> bindings = + UnifiedFunctionSpec.ALL_SPECS.values().stream() + .filter(spec -> spec.getImpl() != null) + .collect( + Collectors.toMap(UnifiedFunctionSpec::getOperator, UnifiedFunctionSpec::getImpl)); + + @Override + public RelNode visit(RelNode node) { + RelNode visited = super.visit(node); + RexBuilder rexBuilder = node.getCluster().getRexBuilder(); + return visited.accept( + new RexShuttle() { + @Override + public RexNode visitCall(RexCall call) { + RexCall visited = (RexCall) super.visitCall(call); + return Optional.ofNullable(bindings.get(visited.getOperator())) + .map(impl -> impl.apply(rexBuilder, visited)) + .orElse(visited); + } + }); + } +} diff --git a/api/src/test/java/org/opensearch/sql/api/UnifiedFunctionLibraryTest.java b/api/src/test/java/org/opensearch/sql/api/UnifiedFunctionLibraryTest.java deleted file mode 100644 index 9f65fc31350..00000000000 --- a/api/src/test/java/org/opensearch/sql/api/UnifiedFunctionLibraryTest.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.opensearch.sql.api; - -import static java.sql.Types.INTEGER; -import static java.sql.Types.VARCHAR; -import static org.junit.Assert.assertTrue; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import org.apache.calcite.rel.RelNode; -import org.junit.Before; -import org.junit.Test; -import org.opensearch.sql.api.compiler.UnifiedQueryCompiler; -import org.opensearch.sql.api.spec.UnifiedFunctionSpec; -import org.opensearch.sql.executor.QueryType; - -/** - * Tests for library functions registered in {@link UnifiedFunctionSpec#LIBRARY}. Verifies both - * planning (function resolves correctly) and execution (produces correct results in-memory). - */ -public class UnifiedFunctionLibraryTest extends UnifiedQueryTestBase implements ResultSetAssertion { - - private UnifiedQueryCompiler compiler; - - @Override - protected QueryType queryType() { - return QueryType.SQL; - } - - @Before - public void setUp() { - super.setUp(); - compiler = new UnifiedQueryCompiler(context); - } - - @Test - public void testLengthPlanning() { - givenQuery("SELECT LENGTH(name) AS len FROM catalog.employees") - .assertPlanContains("LENGTH($1)"); - } - - @Test - public void testLengthExecution() throws Exception { - RelNode plan = planner.plan("SELECT LENGTH(name) AS len FROM catalog.employees"); - try (PreparedStatement stmt = compiler.compile(plan)) { - ResultSet rs = stmt.executeQuery(); - verify(rs) - .expectSchema(col("len", INTEGER)) - .expectData(row(5), row(3), row(7), row(5)); // Alice, Bob, Charlie, Diana - } - } - - @Test - public void testRegexpReplacePlanning() { - givenQuery("SELECT REGEXP_REPLACE(name, 'A.*', 'X') AS replaced FROM catalog.employees") - .assertPlanContains("REGEXP_REPLACE($1, 'A.*', 'X')"); - } - - @Test - public void testRegexpReplaceExecution() throws Exception { - RelNode plan = - planner.plan("SELECT REGEXP_REPLACE(name, '^A.*', 'Replaced') FROM catalog.employees"); - try (PreparedStatement stmt = compiler.compile(plan)) { - ResultSet rs = stmt.executeQuery(); - verify(rs) - .expectSchema(col("EXPR$0", VARCHAR)) - .expectData(row("Replaced"), row("Bob"), row("Charlie"), row("Diana")); - } - } - - @Test - public void testDateTruncPlanning() { - // Plan preserves DATE_TRUNC (late binding — not rewritten until compilation) - givenQuery( - "SELECT DATE_TRUNC('minute', TIMESTAMP '2023-01-01 12:34:56') AS truncated" - + " FROM catalog.employees") - .assertPlanContains("DATE_TRUNC('minute'"); - } - - @Test - public void testFunctionSpecLookup() { - assertTrue(UnifiedFunctionSpec.of("length").isPresent()); - assertTrue(UnifiedFunctionSpec.of("regexp_replace").isPresent()); - assertTrue(UnifiedFunctionSpec.of("date_trunc").isPresent()); - } -} diff --git a/api/src/test/java/org/opensearch/sql/api/UnifiedFunctionSpecTest.java b/api/src/test/java/org/opensearch/sql/api/UnifiedFunctionSpecTest.java new file mode 100644 index 00000000000..a16fa116b42 --- /dev/null +++ b/api/src/test/java/org/opensearch/sql/api/UnifiedFunctionSpecTest.java @@ -0,0 +1,79 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Timestamp; +import org.apache.calcite.rel.RelNode; +import org.junit.Before; +import org.junit.Test; +import org.opensearch.sql.api.compiler.UnifiedQueryCompiler; +import org.opensearch.sql.api.spec.UnifiedFunctionSpec; +import org.opensearch.sql.executor.QueryType; + +/** + * Tests for scalar functions registered in {@link UnifiedFunctionSpec#SCALAR}. Verifies planning + * (function resolves correctly) and execution (produces correct results in-memory). + */ +public class UnifiedFunctionSpecTest extends UnifiedQueryTestBase { + + private UnifiedQueryCompiler compiler; + + @Override + protected QueryType queryType() { + return QueryType.SQL; + } + + @Before + public void setUp() { + super.setUp(); + compiler = new UnifiedQueryCompiler(context); + } + + @Test + public void testLength() throws Exception { + assertEquals(5, eval("LENGTH('hello')")); + assertEquals(0, eval("LENGTH('')")); + } + + @Test + public void testRegexpReplace() throws Exception { + assertEquals("XbcXbc", eval("REGEXP_REPLACE('abcabc', 'a', 'X')")); + assertEquals("hello", eval("REGEXP_REPLACE('hello', 'xyz', 'X')")); + } + + @Test + public void testDateTrunc() throws Exception { + // Plan preserves DATE_TRUNC (late binding — not rewritten until compilation) + givenQuery( + "SELECT DATE_TRUNC('minute', TIMESTAMP '2023-01-01 12:34:56') FROM catalog.employees") + .assertPlanContains("DATE_TRUNC('minute', 2023-01-01 12:34:56)"); + + // Execution rewrites to FLOOR and produces truncated timestamp + Object result = eval("DATE_TRUNC('hour', TIMESTAMP '2023-07-15 14:30:45')"); + assertEquals(Timestamp.valueOf("2023-07-15 14:00:00"), result); + } + + @Test + public void testFunctionSpecLookup() { + assertTrue(UnifiedFunctionSpec.of("length").isPresent()); + assertTrue(UnifiedFunctionSpec.of("regexp_replace").isPresent()); + assertTrue(UnifiedFunctionSpec.of("date_trunc").isPresent()); + } + + private Object eval(String expr) throws Exception { + RelNode plan = planner.plan("SELECT " + expr + " AS v FROM (VALUES (0)) AS t(dummy)"); + try (PreparedStatement stmt = compiler.compile(plan); + ResultSet rs = stmt.executeQuery()) { + assertTrue(rs.next()); + return rs.getObject(1); + } + } +}