Repository: apache/flink
Pull request #26553: [FLINK-37789][3/N] introduce ml builtin sql functions and sql validator changes
Author: login: lihaosky
Labels: name: component=<none>
Created: 2025-05-13T22:11:16Z | Merged: 2025-05-23T13:33:32Z

## What is the purpose of the change

Add Builtin `SqlTableFunction` for `ml_predict` and make sql validator pass


## Brief change log

* Add `SqlMlTableFunction` and `SqlMlPredictTableFunction`
* Make sql validator pass


## Verifying this change

Skeleton. Will add test in following up PRs during integration

## Does this pull request potentially affect one of the following parts:

  - Dependencies (does it add or upgrade a dependency): (no)
  - The public API, i.e., is any changed class annotated with `@Public(Evolving)`: (no)
  - The serializers: (no)
  - The runtime per-record code paths (performance sensitive): (no)
  - Anything that affects deployment or recovery: JobManager (and its components), Checkpointing, Kubernetes/Yarn, ZooKeeper: (no)
  - The S3 file system connector: (no)

## Documentation

  - Does this pull request introduce a new feature? (yes)
  - If yes, how is the feature documented? (JavaDocs)



Diff:
diff --git a/flink-table/flink-sql-parser/src/main/codegen/data/Parser.tdd b/flink-table/flink-sql-parser/src/main/codegen/data/Parser.tdd
index 5f282381c3b46..b8689bbc6de9e 100644
--- a/flink-table/flink-sql-parser/src/main/codegen/data/Parser.tdd
+++ b/flink-table/flink-sql-parser/src/main/codegen/data/Parser.tdd
@@ -574,6 +574,7 @@
     "JSON_EXECUTION_PLAN"
     "PLAN_ADVICE"
     "METADATA"
+    "MODEL"
     "OVERWRITE"
     "OVERWRITING"
     "PARTITIONED"
diff --git a/flink-table/flink-sql-parser/src/main/java/org/apache/calcite/sql/SqlExplicitModelCall.java b/flink-table/flink-sql-parser/src/main/java/org/apache/calcite/sql/SqlExplicitModelCall.java
index 0bb5585b48378..214677c5f7c8a 100644
--- a/flink-table/flink-sql-parser/src/main/java/org/apache/calcite/sql/SqlExplicitModelCall.java
+++ b/flink-table/flink-sql-parser/src/main/java/org/apache/calcite/sql/SqlExplicitModelCall.java
@@ -32,4 +32,8 @@ public SqlExplicitModelCall(
             @Nullable SqlLiteral functionQualifier) {
         super(operator, operandList, pos, functionQualifier);
     }
+
+    public SqlIdentifier getModelIdentifier() {
+        return operand(0);
+    }
 }
diff --git a/flink-table/flink-sql-parser/src/main/java/org/apache/calcite/sql/SqlExplicitModelOperator.java b/flink-table/flink-sql-parser/src/main/java/org/apache/calcite/sql/SqlExplicitModelOperator.java
index 4579b51c9333a..30773b99a0132 100644
--- a/flink-table/flink-sql-parser/src/main/java/org/apache/calcite/sql/SqlExplicitModelOperator.java
+++ b/flink-table/flink-sql-parser/src/main/java/org/apache/calcite/sql/SqlExplicitModelOperator.java
@@ -17,10 +17,15 @@
 
 package org.apache.calcite.sql;
 
+import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.sql.parser.SqlParserPos;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
 import org.apache.calcite.util.ImmutableNullableList;
 import org.checkerframework.checker.nullness.qual.Nullable;
 
+import static org.apache.calcite.util.Static.RESOURCE;
+
 /** SqlExplicitModelOperator is a SQL operator that represents an explicit model. */
 public class SqlExplicitModelOperator extends SqlPrefixOperator {
 
@@ -37,4 +42,20 @@ public SqlCall createCall(
         return new SqlExplicitModelCall(
                 this, ImmutableNullableList.copyOf(operands), pos, functionQualifier);
     }
+
+    @Override
+    public void validateCall(
+            SqlCall call,
+            SqlValidator validator,
+            SqlValidatorScope scope,
+            SqlValidatorScope operandScope) {
+        throw SqlUtil.newContextException(
+                call.pos, RESOURCE.objectNotFound(call.operand(0).toString()));
+    }
+
+    @Override
+    public RelDataType deriveType(SqlValidator validator, SqlValidatorScope scope, SqlCall call) {
+        throw SqlUtil.newContextException(
+                call.pos, RESOURCE.objectNotFound(call.operand(0).toString()));
+    }
 }
diff --git a/flink-table/flink-sql-parser/src/test/java/org/apache/flink/sql/parser/FlinkSqlParserImplTest.java b/flink-table/flink-sql-parser/src/test/java/org/apache/flink/sql/parser/FlinkSqlParserImplTest.java
index a28728ec3e0e3..a1f9c594ccd5e 100644
--- a/flink-table/flink-sql-parser/src/test/java/org/apache/flink/sql/parser/FlinkSqlParserImplTest.java
+++ b/flink-table/flink-sql-parser/src/test/java/org/apache/flink/sql/parser/FlinkSqlParserImplTest.java
@@ -3376,6 +3376,14 @@ void testModelInFunctionWithoutTable() {
                                 + "FROM TABLE(`FUNC`((TABLE `MY_TABLE`), MODEL `CAT`.`DB`.`MY_MODEL`))");
     }
 
+    @Test
+    void testModelInFunctionNamedArgs() {
+        sql("select * from table(ml_predict(INPUT => TABLE my_table, model => MODEL my_model))")
+                .ok(
+                        "SELECT *\n"
+                                + "FROM TABLE(`ML_PREDICT`(`INPUT` => (TABLE `MY_TABLE`), `MODEL` => (MODEL `MY_MODEL`)))");
+    }
+
     /*
      * This test was backported from Calcite 1.38 (CALCITE-6266).
      * Remove it together with upgrade to Calcite 1.38.
diff --git a/flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/SqlModelCall.java b/flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/SqlModelCall.java
new file mode 100644
index 0000000000000..dc151792535ed
--- /dev/null
+++ b/flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/SqlModelCall.java
@@ -0,0 +1,76 @@
+/*
+ * 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.sql;
+
+import org.apache.flink.table.planner.catalog.CatalogSchemaModel;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+
+import static java.util.Objects.requireNonNull;
+
+/** SqlModelCall to fetch and reference model based on identifier. */
+public class SqlModelCall extends SqlBasicCall {
+
+    private final CatalogSchemaModel model;
+
+    public SqlModelCall(SqlExplicitModelCall modelCall, CatalogSchemaModel model) {
+        super(
+                new SqlModelOperator(model),
+                modelCall.getOperandList(),
+                modelCall.getParserPosition(),
+                modelCall.getFunctionQuantifier());
+        this.model = requireNonNull(model);
+    }
+
+    @Override
+    public void validate(SqlValidator validator, SqlValidatorScope scope) {
+        // Do nothing here, override to avoid identifier validation which will be treated as column
+    }
+
+    public RelDataType getInputType(SqlValidator validator) {
+        return model.getInputRowType(validator.getTypeFactory());
+    }
+
+    public RelDataType getOutputType(SqlValidator validator) {
+        return model.getOutputRowType(validator.getTypeFactory());
+    }
+
+    /**
+     * A custom SqlOperator to handle model identifier.
+     *
+     * <p>It is used to derive the type of the model based on the identifier.
+     */
+    private static class SqlModelOperator extends SqlPrefixOperator {
+
+        CatalogSchemaModel model;
+
+        private SqlModelOperator(CatalogSchemaModel model) {
+            super("MODEL", SqlKind.OTHER_FUNCTION, 2, null, null, null);
+            this.model = model;
+        }
+
+        @Override
+        public RelDataType deriveType(
+                SqlValidator validator, SqlValidatorScope scope, SqlCall call) {
+            return model.getOutputRowType(validator.getTypeFactory());
+        }
+    }
+}
diff --git a/flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/validate/ProcedureNamespace.java b/flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/validate/ProcedureNamespace.java
index fabcac7552292..73f8ae07c738d 100644
--- a/flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/validate/ProcedureNamespace.java
+++ b/flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/validate/ProcedureNamespace.java
@@ -18,6 +18,7 @@
 
 import org.apache.flink.annotation.Internal;
 import org.apache.flink.table.planner.calcite.FlinkSqlCallBinding;
+import org.apache.flink.table.planner.functions.sql.ml.SqlMLTableFunction;
 
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.sql.SqlCall;
@@ -61,7 +62,7 @@ public RelDataType validateImpl(RelDataType targetRowType) {
         final SqlOperator operator = call.getOperator();
         final SqlCallBinding callBinding = new FlinkSqlCallBinding(validator, scope, call);
         final SqlCall permutedCall = callBinding.permutedCall();
-        if (operator instanceof SqlWindowTableFunction) {
+        if (operator instanceof SqlWindowTableFunction || operator instanceof SqlMLTableFunction) {
             permutedCall.validate(validator, scope);
         }
 
diff --git a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/FlinkCalciteSqlValidator.java b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/FlinkCalciteSqlValidator.java
index 594754c348a92..799cc9277e695 100644
--- a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/FlinkCalciteSqlValidator.java
+++ b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/FlinkCalciteSqlValidator.java
@@ -26,7 +26,10 @@
 import org.apache.flink.table.catalog.Column;
 import org.apache.flink.table.catalog.ResolvedSchema;
 import org.apache.flink.table.data.TimestampData;
+import org.apache.flink.table.planner.catalog.CatalogSchemaModel;
 import org.apache.flink.table.planner.catalog.CatalogSchemaTable;
+import org.apache.flink.table.planner.functions.sql.ml.SqlMLTableFunction;
+import org.apache.flink.table.planner.plan.FlinkCalciteCatalogReader;
 import org.apache.flink.table.planner.plan.utils.FlinkRexUtil;
 import org.apache.flink.table.planner.utils.ShortcutUtils;
 import org.apache.flink.table.types.logical.DecimalType;
@@ -43,12 +46,14 @@
 import org.apache.calcite.sql.SqlAsOperator;
 import org.apache.calcite.sql.SqlBasicCall;
 import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlExplicitModelCall;
 import org.apache.calcite.sql.SqlFunction;
 import org.apache.calcite.sql.SqlFunctionCategory;
 import org.apache.calcite.sql.SqlIdentifier;
 import org.apache.calcite.sql.SqlJoin;
 import org.apache.calcite.sql.SqlKind;
 import org.apache.calcite.sql.SqlLiteral;
+import org.apache.calcite.sql.SqlModelCall;
 import org.apache.calcite.sql.SqlNode;
 import org.apache.calcite.sql.SqlNodeList;
 import org.apache.calcite.sql.SqlOperator;
@@ -371,7 +376,21 @@ protected void addToSelectList(
         final SqlBasicCall call = (SqlBasicCall) node;
         final SqlOperator operator = call.getOperator();
 
-        if (operator instanceof SqlWindowTableFunction) {
+        if (node instanceof SqlExplicitModelCall) {
+            // Convert it so that model can be accessed in planner. SqlExplicitModelCall
+            // from parser can't access model.
+            SqlExplicitModelCall modelCall = (SqlExplicitModelCall) node;
+            SqlIdentifier modelIdentifier = modelCall.getModelIdentifier();
+            FlinkCalciteCatalogReader catalogReader =
+                    (FlinkCalciteCatalogReader) getCatalogReader();
+            CatalogSchemaModel model = catalogReader.getModel(modelIdentifier.names);
+            if (model != null) {
+                return new SqlModelCall(modelCall, model);
+            }
+        }
+
+        // TODO (FLINK-37819): add test for SqlMLTableFunction
+        if (operator instanceof SqlWindowTableFunction || operator instanceof SqlMLTableFunction) {
             if (tableArgs.stream().allMatch(Objects::isNull)) {
                 return rewritten;
             }
diff --git a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/FlinkSqlOperatorTable.java b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/FlinkSqlOperatorTable.java
index 13f7107011320..2ad8e19055057 100644
--- a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/FlinkSqlOperatorTable.java
+++ b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/FlinkSqlOperatorTable.java
@@ -21,6 +21,7 @@
 import org.apache.flink.table.api.TableException;
 import org.apache.flink.table.planner.calcite.FlinkTypeFactory;
 import org.apache.flink.table.planner.functions.sql.internal.SqlAuxiliaryGroupAggFunction;
+import org.apache.flink.table.planner.functions.sql.ml.SqlMLPredictTableFunction;
 import org.apache.flink.table.planner.plan.type.FlinkReturnTypes;
 import org.apache.flink.table.planner.plan.type.NumericExceptFirstOperandChecker;
 
@@ -1341,6 +1342,9 @@ public List<SqlGroupedWindowFunction> getAuxiliaryFunctions() {
     public static final SqlFunction CUMULATE = new SqlCumulateTableFunction();
     public static final SqlFunction SESSION = new SqlSessionTableFunction();
 
+    // MODEL TABLE FUNCTIONS
+    public static final SqlFunction ML_PREDICT = new SqlMLPredictTableFunction();
+
     // Catalog Functions
     public static final SqlFunction CURRENT_DATABASE =
             BuiltInSqlFunction.newBuilder()
diff --git a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMLPredictTableFunction.java b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMLPredictTableFunction.java
new file mode 100644
index 0000000000000..2bc3516ecefcc
--- /dev/null
+++ b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMLPredictTableFunction.java
@@ -0,0 +1,116 @@
+/*
+ * 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.flink.table.planner.functions.sql.ml;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.sql.SqlCallBinding;
+import org.apache.calcite.sql.SqlOperandCountRange;
+import org.apache.calcite.sql.SqlOperator;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.type.SqlOperandCountRanges;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlTypeName;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * SqlMlPredictTableFunction implements an operator for prediction.
+ *
+ * <p>It allows four parameters:
+ *
+ * <ol>
+ *   <li>a table
+ *   <li>a model name
+ *   <li>a descriptor to provide a column name from the input table
+ *   <li>an optional config map
+ * </ol>
+ */
+public class SqlMLPredictTableFunction extends SqlMLTableFunction {
+
+    public SqlMLPredictTableFunction() {
+        super("ML_PREDICT", new PredictOperandMetadata());
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * <p>Overrides because the first parameter of table-value function windowing is an explicit
+     * TABLE parameter, which is not scalar.
+     */
+    @Override
+    public boolean argumentMustBeScalar(int ordinal) {
+        return ordinal != 0;
+    }
+
+    @Override
+    protected RelDataType inferRowType(SqlOperatorBinding opBinding) {
+        // TODO: FLINK-37780 output type based on table schema and model output schema
+        // model output schema to be available after integrated with SqlExplicitModelCall
+        return opBinding.getOperandType(1);
+    }
+
+    private static class PredictOperandMetadata implements SqlOperandMetadata {
+        private static final List<String> PARAM_NAMES =
+                List.of(PARAM_INPUT, PARAM_MODEL, PARAM_COLUMN, PARAM_CONFIG);
+        private static final List<String> MANDATORY_PARAM_NAMES =
+                List.of(PARAM_INPUT, PARAM_MODEL, PARAM_COLUMN);
+
+        PredictOperandMetadata() {}
+
+        @Override
+        public List<RelDataType> paramTypes(RelDataTypeFactory typeFactory) {
+            return Collections.nCopies(
+                    PARAM_NAMES.size(), typeFactory.createSqlType(SqlTypeName.ANY));
+        }
+
+        @Override
+        public List<String> paramNames() {
+            return PARAM_NAMES;
+        }
+
+        @Override
+        public boolean checkOperandTypes(SqlCallBinding callBinding, boolean throwOnFailure) {
+            // TODO: FLINK-37780 Check operand types after integrated with SqlExplicitModelCall in
+            // validator
+            return false;
+        }
+
+        @Override
+        public SqlOperandCountRange getOperandCountRange() {
+            return SqlOperandCountRanges.between(MANDATORY_PARAM_NAMES.size(), PARAM_NAMES.size());
+        }
+
+        @Override
+        public Consistency getConsistency() {
+            return Consistency.NONE;
+        }
+
+        @Override
+        public boolean isOptional(int i) {
+            return i > getOperandCountRange().getMin() && i <= getOperandCountRange().getMax();
+        }
+
+        @Override
+        public String getAllowedSignatures(SqlOperator op, String opName) {
+            return opName
+                    + "(TABLE table_name, MODEL model_name, DESCRIPTOR(input_columns), [MAP[]]";
+        }
+    }
+}
diff --git a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMLTableFunction.java b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMLTableFunction.java
new file mode 100644
index 0000000000000..aa8c46e7ab516
--- /dev/null
+++ b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMLTableFunction.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.flink.table.planner.functions.sql.ml;
+
+import org.apache.flink.table.api.ValidationException;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlFunction;
+import org.apache.calcite.sql.SqlFunctionCategory;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.SqlSelect;
+import org.apache.calcite.sql.SqlTableFunction;
+import org.apache.calcite.sql.type.ReturnTypes;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlReturnTypeInference;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+
+import java.util.List;
+
+/**
+ * Base class for a table-valued function that works with models. Examples include {@code
+ * ML_PREDICT}.
+ */
+public abstract class SqlMLTableFunction extends SqlFunction implements SqlTableFunction {
+
+    private static final String TABLE_INPUT_ERROR =
+            "SqlMLTableFunction must have only one table as first operand.";
+
+    protected static final String PARAM_INPUT = "INPUT";
+    protected static final String PARAM_MODEL = "MODEL";
+    protected static final String PARAM_COLUMN = "ARGS";
+    protected static final String PARAM_CONFIG = "CONFIG";
+
+    public SqlMLTableFunction(String name, SqlOperandMetadata operandMetadata) {
+        super(
+                name,
+                SqlKind.OTHER_FUNCTION,
+                ReturnTypes.CURSOR,
+                null,
+                operandMetadata,
+                SqlFunctionCategory.SYSTEM);
+    }
+
+    @Override
+    public void validateCall(
+            SqlCall call,
+            SqlValidator validator,
+            SqlValidatorScope scope,
+            SqlValidatorScope operandScope) {
+        assert call.getOperator() == this;
+        final List<SqlNode> operandList = call.getOperandList();
+
+        // ML table function should take only one table as input and use descriptor to reference
+        // columns in the table. The scope for descriptor validation should be the input table.
+        // Since the input table will be rewritten as select query. We get the select query's scope
+        // and use it for descriptor validation.
+        SqlValidatorScope selectScope = null;
+        boolean foundSelect = false;
+        for (SqlNode operand : operandList) {
+            if (operand.getKind().equals(SqlKind.DESCRIPTOR)) {
+                if (selectScope == null) {
+                    throw new ValidationException(TABLE_INPUT_ERROR);
+                }
+                // Set scope to table when validating descriptor columns
+                operand.validate(validator, selectScope);
+                continue;
+            }
+            if (operand.getKind().equals(SqlKind.SET_SEMANTICS_TABLE)) {
+                operand = ((SqlCall) operand).getOperandList().get(0);
+                if (foundSelect) {
+                    throw new ValidationException(TABLE_INPUT_ERROR);
+                }
+                foundSelect = true;
+                selectScope = validator.getSelectScope((SqlSelect) operand);
+            }
+
+            if (operand.getKind().equals(SqlKind.SELECT)) {
+                if (foundSelect) {
+                    throw new ValidationException(TABLE_INPUT_ERROR);
+                }
+                foundSelect = true;
+                selectScope = validator.getSelectScope((SqlSelect) operand);
+            }
+            operand.validate(validator, scope);
+        }
+    }
+
+    @Override
+    public SqlReturnTypeInference getRowTypeInference() {
+        return this::inferRowType;
+    }
+
+    protected abstract RelDataType inferRowType(SqlOperatorBinding opBinding);
+}
diff --git a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/ModelTableFunctionTest.java b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/ModelTableFunctionTest.java
new file mode 100644
index 0000000000000..74e5a284573b9
--- /dev/null
+++ b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/ModelTableFunctionTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.flink.table.planner.plan.stream.sql;
+
+import org.apache.flink.table.api.TableConfig;
+import org.apache.flink.table.api.ValidationException;
+import org.apache.flink.table.planner.utils.TableTestBase;
+import org.apache.flink.table.planner.utils.TableTestUtil;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for model table-valued function. */
+public class ModelTableFunctionTest extends TableTestBase {
+
+    private TableTestUtil util;
+
+    @BeforeEach
+    public void setup() {
+        util = streamTestUtil(TableConfig.getDefault());
+
+        // Create test table
+        util.tableEnv()
+                .executeSql(
+                        "CREATE TABLE MyTable (\n"
+                                + "  a INT,\n"
+                                + "  b BIGINT,\n"
+                                + "  c STRING,\n"
+                                + "  d DECIMAL(10, 3),\n"
+                                + "  rowtime TIMESTAMP(3),\n"
+                                + "  proctime as PROCTIME(),\n"
+                                + "  WATERMARK FOR rowtime AS rowtime - INTERVAL '1' SECOND\n"
+                                + ") with (\n"
+                                + "  'connector' = 'values'\n"
+                                + ")");
+
+        // Create test model
+        util.tableEnv()
+                .executeSql(
+                        "CREATE MODEL MyModel\n"
+                                + "INPUT (a INT, b BIGINT)\n"
+                                + "OUTPUT(c STRING, D ARRAY<INT>)\n"
+                                + "with (\n"
+                                + "  'provider' = 'openai'\n"
+                                + ")");
+    }
+
+    @Test
+    public void testMLPredictTVFWithNamedArguments() {
+        String sql =
+                "SELECT *\n"
+                        + "FROM TABLE(ML_PREDICT(INPUT => TABLE MyTable, "
+                        + "MODEL => MODEL MyModel, "
+                        + "ARGS  => DESCRIPTOR(a, b)))";
+        assertReachesRelConverter(sql);
+    }
+
+    @Test
+    public void testMLPredictTVFWithOptionalNamedArguments() {
+        String sql =
+                "SELECT *\n"
+                        + "FROM TABLE(ML_PREDICT(INPUT => TABLE MyTable, "
+                        + "MODEL  => MODEL MyModel, "
+                        + "ARGS   => DESCRIPTOR(a, b),"
+                        + "CONFIG => MAP['key', 'value']))";
+        assertReachesRelConverter(sql);
+    }
+
+    @Test
+    public void testMLPredictTVF() {
+        String sql =
+                "SELECT *\n"
+                        + "FROM TABLE(ML_PREDICT(TABLE MyTable, MODEL MyModel, DESCRIPTOR(a, b)))";
+        assertReachesRelConverter(sql);
+    }
+
+    @Test
+    public void testMLPredictTVFWithTooManyArguments() {
+        String sql =
+                "SELECT *\n"
+                        + "FROM TABLE(ML_PREDICT(TABLE MyTable, MODEL MyModel, DESCRIPTOR(a, b), MAP['key', 'value'], 'arg0'))";
+        assertThatThrownBy(() -> util.verifyRelPlan(sql))
+                .hasMessageContaining(
+                        "Invalid number of arguments to function 'ML_PREDICT'. Was expecting 3 arguments");
+    }
+
+    @Test
+    public void testMLPredictTVFWithNonExistModel() {
+        String sql =
+                "SELECT *\n"
+                        + "FROM TABLE(ML_PREDICT(TABLE MyTable, MODEL NonExistModel, DESCRIPTOR(a, b), MAP['key', 'value'], 'arg0'))";
+        assertThatThrownBy(() -> util.verifyRelPlan(sql))
+                .isInstanceOf(ValidationException.class)
+                .hasMessageContaining("Object 'NonExistModel' not found");
+    }
+
+    @Test
+    public void testMLPredictTVFWithNonExistColumn() {
+        String sql =
+                "SELECT *\n"
+                        + "FROM TABLE(ML_PREDICT(TABLE MyTable, MODEL MyModel, DESCRIPTOR(no_col)))";
+        assertThatThrownBy(() -> util.verifyRelPlan(sql))
+                .isInstanceOf(ValidationException.class)
+                .hasMessageContaining("Column 'no_col' not found in any table");
+    }
+
+    private void assertReachesRelConverter(String sql) {
+        assertThatThrownBy(() -> util.verifyRelPlan(sql))
+                .hasMessageContaining("while converting MODEL");
+    }
+}


Reviews:
user: login: davidradl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:45:53Z
user: login: davidradl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:49:13Z
user: login: fsk119
body: Thanks for your contribution. I left some comments
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T11:04:13Z
user: login: lihaosky
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T18:40:53Z
user: login: lihaosky
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T18:41:37Z
user: login: lihaosky
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T23:18:24Z
user: login: fsk119
body: Thanks for your update.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T08:01:48Z
user: login: fsk119
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T08:03:28Z
user: login: fsk119
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T08:10:16Z
user: login: lihaosky
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T19:28:27Z
user: login: fsk119
body: LGTM in general, I left some suggestions.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T06:24:21Z
user: login: lihaosky
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T06:48:44Z
user: login: lihaosky
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T06:50:09Z
user: login: lihaosky
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T06:56:55Z
user: login: fsk119
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T07:23:58Z
user: login: fsk119
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T07:53:42Z
user: login: fsk119
body: Thanks for your update. LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-23T13:31:58Z

Review comments:
diff_hunk: @@ -0,0 +1,96 @@
+/*
+ * 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.flink.table.planner.functions.sql.ml;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.sql.SqlCallBinding;
+import org.apache.calcite.sql.SqlOperandCountRange;
+import org.apache.calcite.sql.SqlOperator;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.type.SqlOperandCountRanges;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlTypeName;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * SqlMlPredictTableFunction implements an operator for prediction.
+ *
+ * <p>It allows four parameters:
+ *
+ * <ol>
+ *   <li>a table
+ *   <li>a model name
+ *   <li>a descriptor to provide a column name from the input table
+ *   <li>an optional config map
+ * </ol>
+ */
+public class SqlMlPredictTableFunction extends SqlMlTableFunction {
+
+    public SqlMlPredictTableFunction(String name) {
+        super(name, new PredictOperandMetadata());
+    }
+
+    @Override
+    protected RelDataType inferRowType(SqlOperatorBinding opBinding) {
+        // TODO: output type based on table schema and model output schema
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMlPredictTableFunction.java
user: login: davidradl
body: nit: could we point to a Jira to track the TODOs please.
created_at: 2025-05-15T14:45:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,96 @@
+/*
+ * 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.flink.table.planner.functions.sql.ml;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.sql.SqlCallBinding;
+import org.apache.calcite.sql.SqlOperandCountRange;
+import org.apache.calcite.sql.SqlOperator;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.type.SqlOperandCountRanges;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlTypeName;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * SqlMlPredictTableFunction implements an operator for prediction.
+ *
+ * <p>It allows four parameters:
+ *
+ * <ol>
+ *   <li>a table
+ *   <li>a model name
+ *   <li>a descriptor to provide a column name from the input table
+ *   <li>an optional config map
+ * </ol>
+ */
+public class SqlMlPredictTableFunction extends SqlMlTableFunction {
+
+    public SqlMlPredictTableFunction(String name) {
+        super(name, new PredictOperandMetadata());
+    }
+
+    @Override
+    protected RelDataType inferRowType(SqlOperatorBinding opBinding) {
+        // TODO: output type based on table schema and model output schema
+        // model output schema to be available after integrated with SqlExplicitModelCall
+        return null;
+    }
+
+    private static class PredictOperandMetadata implements SqlOperandMetadata {
+        private final List<String> paramNames;
+        private final int mandatoryParamCount;
+
+        PredictOperandMetadata() {
+            paramNames = List.of(PARAM_DATA, PARAM_MODEL, PARAM_COLUMN, PARAM_CONFIG);
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMlPredictTableFunction.java
user: login: davidradl
body: nit: we could have a constant list of mandatory parameters then get the count as the length,  a separate list for the optionals and a derived paramNames constant list. 
created_at: 2025-05-15T14:49:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,116 @@
+/*
+ * 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.flink.table.planner.functions.sql.ml;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.sql.SqlCallBinding;
+import org.apache.calcite.sql.SqlOperandCountRange;
+import org.apache.calcite.sql.SqlOperator;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.type.SqlOperandCountRanges;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlTypeName;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * SqlMlPredictTableFunction implements an operator for prediction.
+ *
+ * <p>It allows four parameters:
+ *
+ * <ol>
+ *   <li>a table
+ *   <li>a model name
+ *   <li>a descriptor to provide a column name from the input table
+ *   <li>an optional config map
+ * </ol>
+ */
+public class SqlMlPredictTableFunction extends SqlMlTableFunction {
+
+    public SqlMlPredictTableFunction() {
+        super("ML_PREDICT", new PredictOperandMetadata());
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * <p>Overrides because the first parameter of table-value function windowing is an explicit
+     * TABLE parameter, which is not scalar.
+     */
+    @Override
+    public boolean argumentMustBeScalar(int ordinal) {
+        return ordinal != 0;
+    }
+
+    @Override
+    protected RelDataType inferRowType(SqlOperatorBinding opBinding) {
+        // TODO: FLINK-37780 output type based on table schema and model output schema
+        // model output schema to be available after integrated with SqlExplicitModelCall
+        return opBinding.getOperandType(1);
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMlPredictTableFunction.java
user: login: fsk119
body: I think the output schema of the ML_PREDICT combines input table schema and the model output schema. For example, if the input table schema is <a INT, b INT> and the model output schema is <c INT>, then the output schema of the ML_PREDICT is <a INT, b INT, c INT>. But the current implementation only takes model output schema into the consideration.
created_at: 2025-05-19T09:11:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,89 @@
+/*
+ * 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.flink.table.planner.functions.sql.ml;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlFunction;
+import org.apache.calcite.sql.SqlFunctionCategory;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.SqlTableFunction;
+import org.apache.calcite.sql.type.ReturnTypes;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlReturnTypeInference;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+
+import java.util.List;
+
+/**
+ * Base class for a table-valued function that works with models. Examples include {@code
+ * ML_PREDICT}.
+ */
+public abstract class SqlMlTableFunction extends SqlFunction implements SqlTableFunction {
+
+    protected static final String PARAM_DATA = "data";
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMlTableFunction.java
user: login: fsk119
body: In the FLIP 437, we agree to use the following statement. Hope we can have the same arugment names as the proposal in FLIP.

```
SELECT * FROM ML_EVALUATE(
  input => `eval_data`,
  model => `classifier_model`,
  args => DESCRIPTOR(f1, f2)
)
```
created_at: 2025-05-19T09:15:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,89 @@
+/*
+ * 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.flink.table.planner.functions.sql.ml;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlFunction;
+import org.apache.calcite.sql.SqlFunctionCategory;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.SqlTableFunction;
+import org.apache.calcite.sql.type.ReturnTypes;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlReturnTypeInference;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+
+import java.util.List;
+
+/**
+ * Base class for a table-valued function that works with models. Examples include {@code
+ * ML_PREDICT}.
+ */
+public abstract class SqlMlTableFunction extends SqlFunction implements SqlTableFunction {
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMlTableFunction.java
user: login: fsk119
body: MLTableFunction? I think ML is better than Ml. WDYT?
created_at: 2025-05-19T09:23:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,89 @@
+/*
+ * 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.flink.table.planner.functions.sql.ml;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlFunction;
+import org.apache.calcite.sql.SqlFunctionCategory;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.SqlTableFunction;
+import org.apache.calcite.sql.type.ReturnTypes;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlReturnTypeInference;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+
+import java.util.List;
+
+/**
+ * Base class for a table-valued function that works with models. Examples include {@code
+ * ML_PREDICT}.
+ */
+public abstract class SqlMlTableFunction extends SqlFunction implements SqlTableFunction {
+
+    protected static final String PARAM_DATA = "data";
+    protected static final String PARAM_MODEL = "input_model";
+    protected static final String PARAM_COLUMN = "input_column";
+    protected static final String PARAM_CONFIG = "config";
+
+    public SqlMlTableFunction(String name, SqlOperandMetadata operandMetadata) {
+        super(
+                name,
+                SqlKind.OTHER_FUNCTION,
+                ReturnTypes.CURSOR,
+                null,
+                operandMetadata,
+                SqlFunctionCategory.SYSTEM);
+    }
+
+    @Override
+    public void validateCall(
+            SqlCall call,
+            SqlValidator validator,
+            SqlValidatorScope scope,
+            SqlValidatorScope operandScope) {
+        assert call.getOperator() == this;
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMlTableFunction.java
user: login: fsk119
body:         super.validateCall(call, validator, scope, operandScope);

created_at: 2025-05-19T09:47:51Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,127 @@
+/*
+ * 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.sql;
+
+import org.apache.flink.table.planner.catalog.CatalogSchemaModel;
+import org.apache.flink.table.planner.plan.FlinkCalciteCatalogReader;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorCatalogReader;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import static org.apache.calcite.util.Static.RESOURCE;
+
+/** SqlModelCall to fetch and reference model based on identifier. */
+public class SqlModelCall extends SqlBasicCall {
+
+    private @Nullable CatalogSchemaModel model = null;
+
+    public SqlModelCall(SqlExplicitModelCall modelCall) {
+        super(
+                SqlModelOperator.create(
path: flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/SqlModelCall.java
user: login: fsk119
body: We can introduce a static field for this operator, e.g.
```
    private static final SqlOperator OPERATOR = new SqlModelCallOperator();

```
created_at: 2025-05-19T11:03:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,116 @@
+/*
+ * 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.flink.table.planner.functions.sql.ml;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.sql.SqlCallBinding;
+import org.apache.calcite.sql.SqlOperandCountRange;
+import org.apache.calcite.sql.SqlOperator;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.type.SqlOperandCountRanges;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlTypeName;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * SqlMlPredictTableFunction implements an operator for prediction.
+ *
+ * <p>It allows four parameters:
+ *
+ * <ol>
+ *   <li>a table
+ *   <li>a model name
+ *   <li>a descriptor to provide a column name from the input table
+ *   <li>an optional config map
+ * </ol>
+ */
+public class SqlMlPredictTableFunction extends SqlMlTableFunction {
+
+    public SqlMlPredictTableFunction() {
+        super("ML_PREDICT", new PredictOperandMetadata());
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * <p>Overrides because the first parameter of table-value function windowing is an explicit
+     * TABLE parameter, which is not scalar.
+     */
+    @Override
+    public boolean argumentMustBeScalar(int ordinal) {
+        return ordinal != 0;
+    }
+
+    @Override
+    protected RelDataType inferRowType(SqlOperatorBinding opBinding) {
+        // TODO: FLINK-37780 output type based on table schema and model output schema
+        // model output schema to be available after integrated with SqlExplicitModelCall
+        return opBinding.getOperandType(1);
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMlPredictTableFunction.java
user: login: lihaosky
body: Right. It's in the TODO comment. I'm going to do type checking/validation etc in FLINK-37780. It's a placeholder here to pass sql validator
created_at: 2025-05-19T18:32:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+/*
+ * 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.flink.table.planner.functions.sql.ml;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlFunction;
+import org.apache.calcite.sql.SqlFunctionCategory;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.SqlTableFunction;
+import org.apache.calcite.sql.type.ReturnTypes;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlReturnTypeInference;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+
+import java.util.List;
+
+/**
+ * Base class for a table-valued function that works with models. Examples include {@code
+ * ML_PREDICT}.
+ */
+public abstract class SqlMlTableFunction extends SqlFunction implements SqlTableFunction {
+
+    protected static final String PARAM_DATA = "data";
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMlTableFunction.java
user: login: lihaosky
body: I thought the names are not fixed when I wrote FLIP 437, just realized it's fixed when implementing this... I copied the names mostly from https://github.com/apache/calcite/blob/main/core/src/main/java/org/apache/calcite/sql/SqlWindowTableFunction.java#L51-L54. 

* `input` for `eval_data` is fine. 
* I have some problem with `model` as name in parser, maybe because it's a keyword. So I changed it to `input_model`. Not sure if there's a workaround.
* I feel `input_column` is more accurate than `args`. Maybe call it `INPUTCOL` as `TIMECOL` in https://github.com/apache/calcite/blob/main/core/src/main/java/org/apache/calcite/sql/SqlWindowTableFunction.java#L54? `args` isn't very descriptive and too generic.

wdyt?





created_at: 2025-05-19T18:39:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+/*
+ * 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.sql;
+
+import org.apache.flink.table.planner.catalog.CatalogSchemaModel;
+import org.apache.flink.table.planner.plan.FlinkCalciteCatalogReader;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorCatalogReader;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import static org.apache.calcite.util.Static.RESOURCE;
+
+/** SqlModelCall to fetch and reference model based on identifier. */
+public class SqlModelCall extends SqlBasicCall {
+
+    private @Nullable CatalogSchemaModel model = null;
+
+    public SqlModelCall(SqlExplicitModelCall modelCall) {
+        super(
+                SqlModelOperator.create(
path: flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/SqlModelCall.java
user: login: lihaosky
body: Good point!
created_at: 2025-05-19T18:41:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+/*
+ * 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.flink.table.planner.functions.sql.ml;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlFunction;
+import org.apache.calcite.sql.SqlFunctionCategory;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.SqlTableFunction;
+import org.apache.calcite.sql.type.ReturnTypes;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlReturnTypeInference;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+
+import java.util.List;
+
+/**
+ * Base class for a table-valued function that works with models. Examples include {@code
+ * ML_PREDICT}.
+ */
+public abstract class SqlMlTableFunction extends SqlFunction implements SqlTableFunction {
+
+    protected static final String PARAM_DATA = "data";
+    protected static final String PARAM_MODEL = "input_model";
+    protected static final String PARAM_COLUMN = "input_column";
+    protected static final String PARAM_CONFIG = "config";
+
+    public SqlMlTableFunction(String name, SqlOperandMetadata operandMetadata) {
+        super(
+                name,
+                SqlKind.OTHER_FUNCTION,
+                ReturnTypes.CURSOR,
+                null,
+                operandMetadata,
+                SqlFunctionCategory.SYSTEM);
+    }
+
+    @Override
+    public void validateCall(
+            SqlCall call,
+            SqlValidator validator,
+            SqlValidatorScope scope,
+            SqlValidatorScope operandScope) {
+        assert call.getOperator() == this;
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMlTableFunction.java
user: login: lihaosky
body: Can't really call `super.validateCall(call, validator, scope, operandScope);` because the scope of descriptor column is messed up. 

This is the same as https://github.com/apache/flink/blob/master/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/SqlWindowTableFunction.java#L101
created_at: 2025-05-19T23:18:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2573,7 +2575,7 @@ private SqlNode registerFrom(
                 if (operand instanceof SqlBasicCall) {
                     final SqlBasicCall call1 = (SqlBasicCall) operand;
                     final SqlOperator op = call1.getOperator();
-                    if (op instanceof SqlWindowTableFunction
+                    if ((op instanceof SqlWindowTableFunction || op instanceof SqlMLTableFunction)
path: flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
user: login: fsk119
body: I am confuesd about the change and I can not produce a case have the same error as SqlWindowFunction. I am not sure FLINK-35619 is a good solution here, which tries to validate a node when validating namespace. However, in most cases, validator should register the namespace for the node and then validate. 

```
    private SqlNode validateScopedExpression(SqlNode topNode, SqlValidatorScope scope) {
        SqlNode outermostNode = performUnconditionalRewrites(topNode, false);
        cursorSet.add(outermostNode);
        top = outermostNode;
        TRACER.trace("After unconditional rewrite: {}", outermostNode);
        if (outermostNode.isA(SqlKind.TOP_LEVEL)) {
           // 1. REGISTER namespace
            registerQuery(scope, null, outermostNode, outermostNode, null, false);
        }
       // 2. VALIDATE node
        outermostNode.validate(this, scope);
        if (!outermostNode.isA(SqlKind.TOP_LEVEL)) {
            // force type derivation so that we can provide it to the
            // caller later without needing the scope
            deriveType(scope, outermostNode);
        }
        TRACER.trace("After validation: {}", outermostNode);
        return outermostNode;
    }

```

How about removing the change here? 
created_at: 2025-05-20T07:37:02Z
author_association: MEMBER
diff_hunk: @@ -371,7 +374,14 @@ protected void addToSelectList(
         final SqlBasicCall call = (SqlBasicCall) node;
         final SqlOperator operator = call.getOperator();
 
-        if (operator instanceof SqlWindowTableFunction) {
+        if (node instanceof SqlExplicitModelCall) {
+            // Convert it so that model can be accessed in planner. SqlExplicitModelCall
+            // from parser can't access model.
+            SqlExplicitModelCall modelCall = (SqlExplicitModelCall) node;
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/FlinkCalciteSqlValidator.java
user: login: fsk119
body: How about we only rewrite the call if the model is not null here. For example, `SqlValidatorImpl#performUnconditionalRewrites` override the operator if the function is found.

```
if (call.getOperator() instanceof SqlUnresolvedFunction) {
            assert call instanceof SqlBasicCall;
            final SqlUnresolvedFunction function = (SqlUnresolvedFunction) call.getOperator();
            // This function hasn't been resolved yet.  Perform
            // a half-hearted resolution now in case it's a
            // builtin function requiring special casing.  If it's
            // not, we'll handle it later during overload resolution.
            final List<SqlOperator> overloads = new ArrayList<>();
            opTab.lookupOperatorOverloads(
                    function.getNameAsId(),
                    function.getFunctionType(),
                    SqlSyntax.FUNCTION,
                    overloads,
                    catalogReader.nameMatcher());
            if (overloads.size() == 1) {
                ((SqlBasicCall) call).setOperator(overloads.get(0));
            }
        }

```

I think we can have the same logic here
```

        if (node instanceof SqlExplicitModelCall) {
            // Convert it so that model can be accessed in planner. SqlExplicitModelCall
            // from parser can't access model.
            FlinkCalciteCatalogReader catalogReader =
                    (FlinkCalciteCatalogReader) getCatalogReader();
            CatalogSchemaModel model = catalogReader.getModel(((SqlExplicitModelCall) node).getIdentifier());
            if (model != null) {
                return new SqlModelCall(model);
            }
        }
```
created_at: 2025-05-20T08:00:31Z
author_association: MEMBER
diff_hunk: @@ -371,7 +374,14 @@ protected void addToSelectList(
         final SqlBasicCall call = (SqlBasicCall) node;
         final SqlOperator operator = call.getOperator();
 
-        if (operator instanceof SqlWindowTableFunction) {
+        if (node instanceof SqlExplicitModelCall) {
+            // Convert it so that model can be accessed in planner. SqlExplicitModelCall
+            // from parser can't access model.
+            SqlExplicitModelCall modelCall = (SqlExplicitModelCall) node;
+            return new SqlModelCall(modelCall);
+        }
+
+        if (operator instanceof SqlWindowTableFunction || operator instanceof SqlMLTableFunction) {
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/FlinkCalciteSqlValidator.java
user: login: fsk119
body: Can we add a case to verify the change here? 
created_at: 2025-05-20T08:01:04Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,89 @@
+/*
+ * 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.flink.table.planner.functions.sql.ml;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlFunction;
+import org.apache.calcite.sql.SqlFunctionCategory;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.SqlTableFunction;
+import org.apache.calcite.sql.type.ReturnTypes;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlReturnTypeInference;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+
+import java.util.List;
+
+/**
+ * Base class for a table-valued function that works with models. Examples include {@code
+ * ML_PREDICT}.
+ */
+public abstract class SqlMlTableFunction extends SqlFunction implements SqlTableFunction {
+
+    protected static final String PARAM_DATA = "data";
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMlTableFunction.java
user: login: fsk119
body: I perfer to be consistent with FLIP.
created_at: 2025-05-20T08:03:28Z
author_association: MEMBER
diff_hunk: @@ -371,7 +374,14 @@ protected void addToSelectList(
         final SqlBasicCall call = (SqlBasicCall) node;
         final SqlOperator operator = call.getOperator();
 
-        if (operator instanceof SqlWindowTableFunction) {
+        if (node instanceof SqlExplicitModelCall) {
+            // Convert it so that model can be accessed in planner. SqlExplicitModelCall
+            // from parser can't access model.
+            SqlExplicitModelCall modelCall = (SqlExplicitModelCall) node;
+            return new SqlModelCall(modelCall);
+        }
+
+        if (operator instanceof SqlWindowTableFunction || operator instanceof SqlMLTableFunction) {
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/FlinkCalciteSqlValidator.java
user: login: fsk119
body: If it is not easy to add tests here, I perfer to add a TODO here and finish this part later.
created_at: 2025-05-20T08:10:16Z
author_association: MEMBER
diff_hunk: @@ -2573,7 +2575,7 @@ private SqlNode registerFrom(
                 if (operand instanceof SqlBasicCall) {
                     final SqlBasicCall call1 = (SqlBasicCall) operand;
                     final SqlOperator op = call1.getOperator();
-                    if (op instanceof SqlWindowTableFunction
+                    if ((op instanceof SqlWindowTableFunction || op instanceof SqlMLTableFunction)
path: flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
user: login: lihaosky
body: Yeah. This one doesn't seem to have effect. The first select scope should be used when validating descriptor's operands. I made changes there
created_at: 2025-05-20T18:03:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -371,7 +374,14 @@ protected void addToSelectList(
         final SqlBasicCall call = (SqlBasicCall) node;
         final SqlOperator operator = call.getOperator();
 
-        if (operator instanceof SqlWindowTableFunction) {
+        if (node instanceof SqlExplicitModelCall) {
+            // Convert it so that model can be accessed in planner. SqlExplicitModelCall
+            // from parser can't access model.
+            SqlExplicitModelCall modelCall = (SqlExplicitModelCall) node;
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/FlinkCalciteSqlValidator.java
user: login: lihaosky
body: Getting model here should be fine. But `SqlExplicitModelCall` with model identifier expects the model to exist so we should enforce that here. If there are cases where model doesn't need to exist in other cases, we can revisit?
created_at: 2025-05-20T18:03:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -371,7 +374,14 @@ protected void addToSelectList(
         final SqlBasicCall call = (SqlBasicCall) node;
         final SqlOperator operator = call.getOperator();
 
-        if (operator instanceof SqlWindowTableFunction) {
+        if (node instanceof SqlExplicitModelCall) {
+            // Convert it so that model can be accessed in planner. SqlExplicitModelCall
+            // from parser can't access model.
+            SqlExplicitModelCall modelCall = (SqlExplicitModelCall) node;
+            return new SqlModelCall(modelCall);
+        }
+
+        if (operator instanceof SqlWindowTableFunction || operator instanceof SqlMLTableFunction) {
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/FlinkCalciteSqlValidator.java
user: login: lihaosky
body: Sure. Will add a test to `flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/ColumnExpansionTest.java` after working E2E. Created https://issues.apache.org/jira/browse/FLINK-37819
created_at: 2025-05-20T19:28:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -61,7 +62,7 @@ public RelDataType validateImpl(RelDataType targetRowType) {
         final SqlOperator operator = call.getOperator();
         final SqlCallBinding callBinding = new FlinkSqlCallBinding(validator, scope, call);
         final SqlCall permutedCall = callBinding.permutedCall();
-        if (operator instanceof SqlWindowTableFunction) {
+        if (operator instanceof SqlWindowTableFunction || operator instanceof SqlMLTableFunction) {
path: flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/validate/ProcedureNamespace.java
user: login: fsk119
body: Calcite have already validated this call at `SqlValidatorImpl#validateCall`, I think we don't need to validate it again.
created_at: 2025-05-22T06:07:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,81 @@
+/*
+ * 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.sql;
+
+import org.apache.flink.table.planner.catalog.CatalogSchemaModel;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+
+import static org.apache.calcite.util.Static.RESOURCE;
+
+/** SqlModelCall to fetch and reference model based on identifier. */
+public class SqlModelCall extends SqlBasicCall {
+
+    private final CatalogSchemaModel model;
+
+    public SqlModelCall(SqlExplicitModelCall modelCall, CatalogSchemaModel model) {
+        super(
+                new SqlModelOperator(model),
+                modelCall.getOperandList(),
+                modelCall.getParserPosition(),
+                modelCall.getFunctionQuantifier());
+        this.model = model;
+    }
+
+    @Override
+    public void validate(SqlValidator validator, SqlValidatorScope scope) {
+        SqlIdentifier modelIdentifier = (SqlIdentifier) getOperandList().get(0);
+        if (model == null) {
path: flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/SqlModelCall.java
user: login: fsk119
body: Remove this check. It is useless now.
created_at: 2025-05-22T06:09:31Z
author_association: MEMBER
diff_hunk: @@ -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.flink.table.planner.functions.sql.ml;
+
+import org.apache.flink.table.api.ValidationException;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlFunction;
+import org.apache.calcite.sql.SqlFunctionCategory;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.SqlSelect;
+import org.apache.calcite.sql.SqlTableFunction;
+import org.apache.calcite.sql.type.ReturnTypes;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlReturnTypeInference;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+
+import java.util.List;
+
+/**
+ * Base class for a table-valued function that works with models. Examples include {@code
+ * ML_PREDICT}.
+ */
+public abstract class SqlMLTableFunction extends SqlFunction implements SqlTableFunction {
+
+    private static final String TABLE_INPUT_ERROR =
+            "SqlMLTableFunction must have only one table as first operand.";
+
+    protected static final String PARAM_INPUT = "INPUT";
+    protected static final String PARAM_MODEL = "MODEL";
+    protected static final String PARAM_COLUMN = "ARGS";
+    protected static final String PARAM_CONFIG = "CONFIG";
+
+    public SqlMLTableFunction(String name, SqlOperandMetadata operandMetadata) {
+        super(
+                name,
+                SqlKind.OTHER_FUNCTION,
+                ReturnTypes.CURSOR,
+                null,
+                operandMetadata,
+                SqlFunctionCategory.SYSTEM);
+    }
+
+    @Override
+    public void validateCall(
+            SqlCall call,
+            SqlValidator validator,
+            SqlValidatorScope scope,
+            SqlValidatorScope operandScope) {
+        assert call.getOperator() == this;
+        final List<SqlNode> operandList = call.getOperandList();
+
+        // ML table function should take only one table as input and use descriptor to reference
+        // columns in the table. The scope for descriptor validation should be the input table.
+        // Since the input table will be rewritten as select query. We get the select query's scope
+        // and use it for descriptor validation.
+        SqlValidatorScope selectScope = null;
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMLTableFunction.java
user: login: fsk119
body: Why don't validate the operand type in PredictOperandMetadata#checkOperandTypes? According to the javadoc, I think we should delay the check later.

```
  /**
   * Validates a call to this operator.
   *
   * <p>This method should not perform type-derivation or perform validation
   * related related to types. That is done later, by
   * {@link #deriveType(SqlValidator, SqlValidatorScope, SqlCall)}. This method
   * should focus on structural validation.
   *
   * <p>A typical implementation of this method first validates the operands,
   * then performs some operator-specific logic. The default implementation
   * just validates the operands.
   *
   * <p>This method is the default implementation of {@link SqlCall#validate};
   * but note that some sub-classes of {@link SqlCall} never call this method.
   *
   * @param call         the call to this operator
   * @param validator    the active validator
   * @param scope        validator scope
   * @param operandScope validator scope in which to validate operands to this
   *                     call; usually equal to scope, but not always because
   *                     some operators introduce new scopes
   * @see SqlNode#validateExpr(SqlValidator, SqlValidatorScope)
   * @see #deriveType(SqlValidator, SqlValidatorScope, SqlCall)
   */
  public void validateCall(
      SqlCall call,
      SqlValidator validator,
      SqlValidatorScope scope,
      SqlValidatorScope operandScope) {
    assert call.getOperator() == this;
    for (SqlNode operand : call.getOperandList()) {
      operand.validateExpr(validator, operandScope);
    }
  }

```
I think we don't need to override this method.
created_at: 2025-05-22T06:20:57Z
author_association: MEMBER
diff_hunk: @@ -61,7 +62,7 @@ public RelDataType validateImpl(RelDataType targetRowType) {
         final SqlOperator operator = call.getOperator();
         final SqlCallBinding callBinding = new FlinkSqlCallBinding(validator, scope, call);
         final SqlCall permutedCall = callBinding.permutedCall();
-        if (operator instanceof SqlWindowTableFunction) {
+        if (operator instanceof SqlWindowTableFunction || operator instanceof SqlMLTableFunction) {
path: flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/validate/ProcedureNamespace.java
user: login: lihaosky
body: `SqlValidatorImpl#validateCall` is not called if this is removed
created_at: 2025-05-22T06:48:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+/*
+ * 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.sql;
+
+import org.apache.flink.table.planner.catalog.CatalogSchemaModel;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+
+import static org.apache.calcite.util.Static.RESOURCE;
+
+/** SqlModelCall to fetch and reference model based on identifier. */
+public class SqlModelCall extends SqlBasicCall {
+
+    private final CatalogSchemaModel model;
+
+    public SqlModelCall(SqlExplicitModelCall modelCall, CatalogSchemaModel model) {
+        super(
+                new SqlModelOperator(model),
+                modelCall.getOperandList(),
+                modelCall.getParserPosition(),
+                modelCall.getFunctionQuantifier());
+        this.model = model;
+    }
+
+    @Override
+    public void validate(SqlValidator validator, SqlValidatorScope scope) {
+        SqlIdentifier modelIdentifier = (SqlIdentifier) getOperandList().get(0);
+        if (model == null) {
path: flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/SqlModelCall.java
user: login: lihaosky
body: We need to override this and avoid validating identifier since it will be treated as table column. So I just put a check here since we didn't check null in constructor
created_at: 2025-05-22T06:50:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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.flink.table.planner.functions.sql.ml;
+
+import org.apache.flink.table.api.ValidationException;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlFunction;
+import org.apache.calcite.sql.SqlFunctionCategory;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.SqlSelect;
+import org.apache.calcite.sql.SqlTableFunction;
+import org.apache.calcite.sql.type.ReturnTypes;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlReturnTypeInference;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+
+import java.util.List;
+
+/**
+ * Base class for a table-valued function that works with models. Examples include {@code
+ * ML_PREDICT}.
+ */
+public abstract class SqlMLTableFunction extends SqlFunction implements SqlTableFunction {
+
+    private static final String TABLE_INPUT_ERROR =
+            "SqlMLTableFunction must have only one table as first operand.";
+
+    protected static final String PARAM_INPUT = "INPUT";
+    protected static final String PARAM_MODEL = "MODEL";
+    protected static final String PARAM_COLUMN = "ARGS";
+    protected static final String PARAM_CONFIG = "CONFIG";
+
+    public SqlMLTableFunction(String name, SqlOperandMetadata operandMetadata) {
+        super(
+                name,
+                SqlKind.OTHER_FUNCTION,
+                ReturnTypes.CURSOR,
+                null,
+                operandMetadata,
+                SqlFunctionCategory.SYSTEM);
+    }
+
+    @Override
+    public void validateCall(
+            SqlCall call,
+            SqlValidator validator,
+            SqlValidatorScope scope,
+            SqlValidatorScope operandScope) {
+        assert call.getOperator() == this;
+        final List<SqlNode> operandList = call.getOperandList();
+
+        // ML table function should take only one table as input and use descriptor to reference
+        // columns in the table. The scope for descriptor validation should be the input table.
+        // Since the input table will be rewritten as select query. We get the select query's scope
+        // and use it for descriptor validation.
+        SqlValidatorScope selectScope = null;
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMLTableFunction.java
user: login: lihaosky
body: Later check in `PredictOperandMetadata` doesn't get scope to validate scope information? It only validates first param node type is row and descriptor column exist in the row field etc. More in https://github.com/apache/flink/pull/26583
created_at: 2025-05-22T06:56:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+/*
+ * 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.sql;
+
+import org.apache.flink.table.planner.catalog.CatalogSchemaModel;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+
+import static org.apache.calcite.util.Static.RESOURCE;
+
+/** SqlModelCall to fetch and reference model based on identifier. */
+public class SqlModelCall extends SqlBasicCall {
+
+    private final CatalogSchemaModel model;
+
+    public SqlModelCall(SqlExplicitModelCall modelCall, CatalogSchemaModel model) {
+        super(
+                new SqlModelOperator(model),
+                modelCall.getOperandList(),
+                modelCall.getParserPosition(),
+                modelCall.getFunctionQuantifier());
+        this.model = model;
+    }
+
+    @Override
+    public void validate(SqlValidator validator, SqlValidatorScope scope) {
+        SqlIdentifier modelIdentifier = (SqlIdentifier) getOperandList().get(0);
+        if (model == null) {
path: flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/SqlModelCall.java
user: login: fsk119
body: I think we can override this method, but we don't need to check model is null or not.
created_at: 2025-05-22T07:23:58Z
author_association: MEMBER
diff_hunk: @@ -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.flink.table.planner.functions.sql.ml;
+
+import org.apache.flink.table.api.ValidationException;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlFunction;
+import org.apache.calcite.sql.SqlFunctionCategory;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlOperatorBinding;
+import org.apache.calcite.sql.SqlSelect;
+import org.apache.calcite.sql.SqlTableFunction;
+import org.apache.calcite.sql.type.ReturnTypes;
+import org.apache.calcite.sql.type.SqlOperandMetadata;
+import org.apache.calcite.sql.type.SqlReturnTypeInference;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+
+import java.util.List;
+
+/**
+ * Base class for a table-valued function that works with models. Examples include {@code
+ * ML_PREDICT}.
+ */
+public abstract class SqlMLTableFunction extends SqlFunction implements SqlTableFunction {
+
+    private static final String TABLE_INPUT_ERROR =
+            "SqlMLTableFunction must have only one table as first operand.";
+
+    protected static final String PARAM_INPUT = "INPUT";
+    protected static final String PARAM_MODEL = "MODEL";
+    protected static final String PARAM_COLUMN = "ARGS";
+    protected static final String PARAM_CONFIG = "CONFIG";
+
+    public SqlMLTableFunction(String name, SqlOperandMetadata operandMetadata) {
+        super(
+                name,
+                SqlKind.OTHER_FUNCTION,
+                ReturnTypes.CURSOR,
+                null,
+                operandMetadata,
+                SqlFunctionCategory.SYSTEM);
+    }
+
+    @Override
+    public void validateCall(
+            SqlCall call,
+            SqlValidator validator,
+            SqlValidatorScope scope,
+            SqlValidatorScope operandScope) {
+        assert call.getOperator() == this;
+        final List<SqlNode> operandList = call.getOperandList();
+
+        // ML table function should take only one table as input and use descriptor to reference
+        // columns in the table. The scope for descriptor validation should be the input table.
+        // Since the input table will be rewritten as select query. We get the select query's scope
+        // and use it for descriptor validation.
+        SqlValidatorScope selectScope = null;
path: flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/sql/ml/SqlMLTableFunction.java
user: login: fsk119
body: Ok. I understand. Thanks for your clarify.
created_at: 2025-05-22T07:53:42Z
author_association: MEMBER

PR comments:
user: login: flinkbot
created_at: 2025-05-13T22:14:57Z
body: <!--
Meta data
{
  "version" : 1,
  "metaDataEntries" : [ {
    "hash" : "64363a9bdf98a16df0ce8f749b9a27025695fcd5",
    "status" : "DELETED",
    "url" : "https://dev.azure.com/apache-flink/98463496-1af2-4620-8eab-a2ecc1a2e6fe/_build/results?buildId=67610",
    "triggerID" : "64363a9bdf98a16df0ce8f749b9a27025695fcd5",
    "triggerType" : "PUSH"
  }, {
    "hash" : "d48945e1ee6c60f2d67d4c5f40a5af261dfd649a",
    "status" : "DELETED",
    "url" : "https://dev.azure.com/apache-flink/98463496-1af2-4620-8eab-a2ecc1a2e6fe/_build/results?buildId=67670",
    "triggerID" : "d48945e1ee6c60f2d67d4c5f40a5af261dfd649a",
    "triggerType" : "PUSH"
  }, {
    "hash" : "539ce03148d45ab34e1aee4b87c0f25123820c3b",
    "status" : "DELETED",
    "url" : "https://dev.azure.com/apache-flink/98463496-1af2-4620-8eab-a2ecc1a2e6fe/_build/results?buildId=67686",
    "triggerID" : "539ce03148d45ab34e1aee4b87c0f25123820c3b",
    "triggerType" : "PUSH"
  }, {
    "hash" : "18011a9f2d5b1142060fe6688c4f1af0c5200b33",
    "status" : "DELETED",
    "url" : "https://dev.azure.com/apache-flink/98463496-1af2-4620-8eab-a2ecc1a2e6fe/_build/results?buildId=67687",
    "triggerID" : "18011a9f2d5b1142060fe6688c4f1af0c5200b33",
    "triggerType" : "PUSH"
  }, {
    "hash" : "5aa380303f40e98ef33ded1ffc3573d8490f62c1",
    "status" : "DELETED",
    "url" : "https://dev.azure.com/apache-flink/98463496-1af2-4620-8eab-a2ecc1a2e6fe/_build/results?buildId=67702",
    "triggerID" : "5aa380303f40e98ef33ded1ffc3573d8490f62c1",
    "triggerType" : "PUSH"
  }, {
    "hash" : "c311f98db7417dcba3b504968f8e8a74eeaa07a3",
    "status" : "UNKNOWN",
    "url" : "TBD",
    "triggerID" : "c311f98db7417dcba3b504968f8e8a74eeaa07a3",
    "triggerType" : "PUSH"
  }, {
    "hash" : "91ddf1e6a17496b91e33a29d7d20d0996fa5d5f5",
    "status" : "DELETED",
    "url" : "https://dev.azure.com/apache-flink/98463496-1af2-4620-8eab-a2ecc1a2e6fe/_build/results?buildId=67716",
    "triggerID" : "91ddf1e6a17496b91e33a29d7d20d0996fa5d5f5",
    "triggerType" : "PUSH"
  }, {
    "hash" : "7fb90cb1878ce052eb49b8430d1274912e46eede",
    "status" : "UNKNOWN",
    "url" : "TBD",
    "triggerID" : "7fb90cb1878ce052eb49b8430d1274912e46eede",
    "triggerType" : "PUSH"
  }, {
    "hash" : "c328aefad7230cf95fc701557f6de9c13dae3c8c",
    "status" : "DELETED",
    "url" : "https://dev.azure.com/apache-flink/98463496-1af2-4620-8eab-a2ecc1a2e6fe/_build/results?buildId=67721",
    "triggerID" : "c328aefad7230cf95fc701557f6de9c13dae3c8c",
    "triggerType" : "PUSH"
  }, {
    "hash" : "b29913d037473dd93a3ea715b90e97a2c0cd1b3b",
    "status" : "DELETED",
    "url" : "https://dev.azure.com/apache-flink/98463496-1af2-4620-8eab-a2ecc1a2e6fe/_build/results?buildId=67711",
    "triggerID" : "b29913d037473dd93a3ea715b90e97a2c0cd1b3b",
    "triggerType" : "PUSH"
  }, {
    "hash" : "e53b63a832ac505ae1f2fa5bdd93b2243e4278ed",
    "status" : "DELETED",
    "url" : "https://dev.azure.com/apache-flink/98463496-1af2-4620-8eab-a2ecc1a2e6fe/_build/results?buildId=67727",
    "triggerID" : "e53b63a832ac505ae1f2fa5bdd93b2243e4278ed",
    "triggerType" : "PUSH"
  }, {
    "hash" : "8f6fb96c2afbc2525f1fed8b7fb7c60d5ae589ac",
    "status" : "DELETED",
    "url" : "https://dev.azure.com/apache-flink/98463496-1af2-4620-8eab-a2ecc1a2e6fe/_build/results?buildId=67736",
    "triggerID" : "8f6fb96c2afbc2525f1fed8b7fb7c60d5ae589ac",
    "triggerType" : "PUSH"
  }, {
    "hash" : "02e0d6ba10987ffa9424dd0ca88bee6338add0bb",
    "status" : "DELETED",
    "url" : "https://dev.azure.com/apache-flink/98463496-1af2-4620-8eab-a2ecc1a2e6fe/_build/results?buildId=67762",
    "triggerID" : "02e0d6ba10987ffa9424dd0ca88bee6338add0bb",
    "triggerType" : "PUSH"
  }, {
    "hash" : "7ab2b7032b602dfc975bed3d8d87c9f6775b77d8",
    "status" : "SUCCESS",
    "url" : "https://dev.azure.com/apache-flink/98463496-1af2-4620-8eab-a2ecc1a2e6fe/_build/results?buildId=67765",
    "triggerID" : "7ab2b7032b602dfc975bed3d8d87c9f6775b77d8",
    "triggerType" : "PUSH"
  } ]
}-->
## CI report:

* c311f98db7417dcba3b504968f8e8a74eeaa07a3 UNKNOWN
* 7fb90cb1878ce052eb49b8430d1274912e46eede UNKNOWN
* 7ab2b7032b602dfc975bed3d8d87c9f6775b77d8 Azure: [SUCCESS](https://dev.azure.com/apache-flink/98463496-1af2-4620-8eab-a2ecc1a2e6fe/_build/results?buildId=67765) 

<details>
<summary>Bot commands</summary>
  The @flinkbot bot supports the following commands:

 - `@flinkbot run azure` re-run the last Azure build
</details>
author_association: COLLABORATOR
user: login: davidradl
created_at: 2025-05-15T14:44:23Z
body: @lihaosky could we add unit tests please for the new methods please
author_association: CONTRIBUTOR