This is an automated email from the ASF dual-hosted git repository.
dianfu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new 7c0c43eeca9 [FLINK-40427][python] Add catalog management and catalog
table I/O to DataFrame API
7c0c43eeca9 is described below
commit 7c0c43eeca9ea1057b9c2722e1793b23c4c7724c
Author: Federico Dolce <[email protected]>
AuthorDate: Tue Sep 15 16:00:21 2026 +0200
[FLINK-40427][python] Add catalog management and catalog table I/O to
DataFrame API
This closes #29262.
---
.../pyflink.dataframe/{index.rst => catalog.rst} | 51 ++-
.../docs/reference/pyflink.dataframe/index.rst | 1 +
.../docs/reference/pyflink.dataframe/io.rst | 2 +
flink-python/pyflink/dataframe/__init__.py | 21 +-
flink-python/pyflink/dataframe/catalog.py | 254 ++++++++++++++
flink-python/pyflink/dataframe/dataframe.py | 55 ++-
flink-python/pyflink/dataframe/errors.py | 48 +++
flink-python/pyflink/dataframe/io.py | 175 +++++++++-
.../pyflink/dataframe/tests/test_catalog.py | 177 ++++++++++
.../pyflink/dataframe/tests/test_filesystem_io.py | 4 +-
flink-python/pyflink/dataframe/tests/test_io.py | 373 ++++++++++++++++++++-
11 files changed, 1118 insertions(+), 43 deletions(-)
diff --git a/flink-python/docs/reference/pyflink.dataframe/index.rst
b/flink-python/docs/reference/pyflink.dataframe/catalog.rst
similarity index 57%
copy from flink-python/docs/reference/pyflink.dataframe/index.rst
copy to flink-python/docs/reference/pyflink.dataframe/catalog.rst
index 547cb0f2202..cf22cc65c1e 100644
--- a/flink-python/docs/reference/pyflink.dataframe/index.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/catalog.rst
@@ -16,20 +16,37 @@
limitations under the License.
################################################################################
-==================
-PyFlink DataFrame
-==================
-
-This page gives an overview of all public PyFlink DataFrame APIs.
-
-.. toctree::
- :maxdepth: 1
-
- dataframe
- udf
- creation
- io
- sql
- datatype
- environment
- config
+========
+Catalogs
+========
+
+Functions for creating catalogs and navigating catalogs and databases. Setting
the current
+catalog and database lets :func:`~pyflink.dataframe.read_catalog_table` and
+:meth:`~pyflink.dataframe.DataFrame.write_catalog_table` reference tables by
short paths instead
+of the full ``catalog_name.db_name.table_name``.
+
+Catalog Management
+------------------
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+ :toctree: api/
+
+ create_catalog
+ get_catalog
+ use_catalog
+ get_current_catalog
+ list_catalogs
+
+Database Navigation
+-------------------
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+ :toctree: api/
+
+ use_database
+ get_current_database
+ list_databases
diff --git a/flink-python/docs/reference/pyflink.dataframe/index.rst
b/flink-python/docs/reference/pyflink.dataframe/index.rst
index 547cb0f2202..d92c806a027 100644
--- a/flink-python/docs/reference/pyflink.dataframe/index.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/index.rst
@@ -29,6 +29,7 @@ This page gives an overview of all public PyFlink DataFrame
APIs.
udf
creation
io
+ catalog
sql
datatype
environment
diff --git a/flink-python/docs/reference/pyflink.dataframe/io.rst
b/flink-python/docs/reference/pyflink.dataframe/io.rst
index a5c34a926ab..a92bd5a13fd 100644
--- a/flink-python/docs/reference/pyflink.dataframe/io.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/io.rst
@@ -32,6 +32,7 @@ Readers
.. autosummary::
:toctree: api/
+ read_catalog_table
read_generic
read_json
read_parquet
@@ -44,6 +45,7 @@ Writers
.. autosummary::
:toctree: api/
+ DataFrame.write_catalog_table
DataFrame.write_generic
DataFrame.write_json
DataFrame.write_parquet
diff --git a/flink-python/pyflink/dataframe/__init__.py
b/flink-python/pyflink/dataframe/__init__.py
index 94697f87029..2ac311e66b4 100644
--- a/flink-python/pyflink/dataframe/__init__.py
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -38,6 +38,16 @@ Example::
<Row(1, 'Alice', 31)>
"""
+from pyflink.dataframe.catalog import (
+ create_catalog,
+ get_catalog,
+ get_current_catalog,
+ get_current_database,
+ list_catalogs,
+ list_databases,
+ use_catalog,
+ use_database,
+)
from pyflink.dataframe.convert import (
from_arrow,
from_dict,
@@ -54,7 +64,7 @@ from pyflink.dataframe.context import (
from pyflink.dataframe.dataframe import DataFrame, GroupedDataFrame, col, lit
from pyflink.dataframe.dataframe_config import config
from pyflink.dataframe.datatype import DataType
-from pyflink.dataframe.io import read_generic, read_json, read_parquet
+from pyflink.dataframe.io import read_catalog_table, read_generic, read_json,
read_parquet
from pyflink.dataframe.sql import sql
from pyflink.dataframe.udf import udf
from pyflink.dataframe.udtf import udtf
@@ -73,9 +83,18 @@ __all__ = [
"from_records",
"from_table",
"range",
+ "read_catalog_table",
"read_generic",
"read_json",
"read_parquet",
+ "create_catalog",
+ "get_catalog",
+ "use_catalog",
+ "get_current_catalog",
+ "list_catalogs",
+ "use_database",
+ "get_current_database",
+ "list_databases",
"sql",
"config",
"set_table_environment",
diff --git a/flink-python/pyflink/dataframe/catalog.py
b/flink-python/pyflink/dataframe/catalog.py
new file mode 100644
index 00000000000..33dab3fc4fe
--- /dev/null
+++ b/flink-python/pyflink/dataframe/catalog.py
@@ -0,0 +1,254 @@
+################################################################################
+# 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.
+################################################################################
+
+from typing import Dict, List, Optional
+
+from pyflink.common import Configuration
+from pyflink.dataframe.context import get_or_create_table_environment
+from pyflink.dataframe.errors import _raise_as_value_error
+from pyflink.table.catalog import Catalog, CatalogDescriptor
+from pyflink.util.api_stability_decorators import PublicEvolving
+
+__all__ = [
+ "create_catalog",
+ "get_catalog",
+ "use_catalog",
+ "get_current_catalog",
+ "list_catalogs",
+ "use_database",
+ "get_current_database",
+ "list_databases",
+]
+
+
+def _validate_name(name: str, what: str) -> None:
+ if not isinstance(name, str):
+ raise TypeError(f"{what} must be a string")
+ if not name:
+ raise ValueError(f"{what} must not be empty")
+
+
+def _validate_catalog_options(options: Dict[str, str]) -> None:
+ if not isinstance(options, dict):
+ raise TypeError("options must be a dict of string keys and values")
+ for key, value in options.items():
+ if not isinstance(key, str):
+ raise TypeError("option keys must be strings")
+ if not key:
+ raise ValueError("option keys must not be empty")
+ if not isinstance(value, str):
+ raise TypeError(f"option {key!r} must have a string value")
+
+
+@PublicEvolving()
+def create_catalog(name: str, options: Dict[str, str]) -> None:
+ """
+ Create a catalog in the environment used by DataFrame operations.
+
+ The ``type`` option picks which kind of catalog to create, for example
``generic_in_memory``.
+ Flink has to know about that type, either built in or on the classpath.
Every other option is
+ passed through to the catalog. This does the same as ``CREATE CATALOG`` in
SQL.
+
+ :param name: Name under which the catalog is created.
+ :param options: Catalog options, including the ``type`` option.
+ :raises TypeError: If ``name`` is not a string or ``options`` is not a
dict of strings.
+ :raises ValueError: If ``name`` or an option key is empty, or if Flink
cannot create a catalog
+ from ``options``.
+ :raises ~pyflink.util.exceptions.CatalogException: If a catalog named
``name`` already exists,
+ or if the catalog reports an error while being created.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> pf.create_catalog("my_catalog", {"type": "generic_in_memory"})
+ >>> pf.list_catalogs()
+ ['default_catalog', 'my_catalog']
+
+ .. versionadded:: 2.4.0
+ """
+ _validate_name(name, "name")
+ _validate_catalog_options(options)
+ configuration = Configuration()
+ for key, value in options.items():
+ configuration.set_string(key, value)
+ descriptor = CatalogDescriptor.of(name, configuration)
+ try:
+ get_or_create_table_environment().create_catalog(name, descriptor)
+ except Exception as error:
+ _raise_as_value_error(error)
+
+
+@PublicEvolving()
+def get_catalog(name: str) -> Optional[Catalog]:
+ """
+ Return a registered catalog by name.
+
+ :param name: Name of the catalog.
+ :return: The :class:`~pyflink.table.catalog.Catalog`, or ``None`` if no
catalog is registered
+ under ``name``.
+ :raises TypeError: If ``name`` is not a string.
+ :raises ValueError: If ``name`` is empty.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> pf.create_catalog("my_catalog", {"type": "generic_in_memory"})
+ >>> pf.get_catalog("my_catalog").list_databases()
+ ['default']
+ >>> pf.get_catalog("missing") is None
+ True
+
+ .. versionadded:: 2.4.0
+ """
+ _validate_name(name, "name")
+ return get_or_create_table_environment().get_catalog(name)
+
+
+@PublicEvolving()
+def use_catalog(name: str) -> None:
+ """
+ Set the current catalog.
+
+ Table paths without a catalog part, such as ``my_table`` or
``my_database.my_table``, are
+ resolved against the current catalog. Switching catalogs also moves you to
that catalog's
+ default database.
+
+ :param name: Name of a registered catalog.
+ :raises TypeError: If ``name`` is not a string.
+ :raises ValueError: If ``name`` is empty.
+ :raises ~pyflink.util.exceptions.CatalogException: If no catalog named
``name`` exists.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> pf.create_catalog("my_catalog", {"type": "generic_in_memory"})
+ >>> pf.use_catalog("my_catalog")
+ >>> pf.get_current_catalog()
+ 'my_catalog'
+
+ .. versionadded:: 2.4.0
+ """
+ _validate_name(name, "name")
+ get_or_create_table_environment().use_catalog(name)
+
+
+@PublicEvolving()
+def get_current_catalog() -> Optional[str]:
+ """
+ Return the name of the current catalog.
+
+ :return: The current catalog name, or ``None`` if no current catalog is
set.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> pf.get_current_catalog()
+ 'default_catalog'
+
+ .. versionadded:: 2.4.0
+ """
+ return get_or_create_table_environment().get_current_catalog()
+
+
+@PublicEvolving()
+def list_catalogs() -> List[str]:
+ """
+ Return the names of all registered catalogs.
+
+ :return: Catalog names.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> pf.list_catalogs()
+ ['default_catalog']
+
+ .. versionadded:: 2.4.0
+ """
+ return get_or_create_table_environment().list_catalogs()
+
+
+@PublicEvolving()
+def use_database(name: str) -> None:
+ """
+ Set the current database within the current catalog.
+
+ Table paths without a database part, such as ``my_table``, are resolved
against the current
+ database.
+
+ :param name: Name of a database in the current catalog.
+ :raises TypeError: If ``name`` is not a string.
+ :raises ValueError: If ``name`` is empty.
+ :raises ~pyflink.util.exceptions.CatalogException: If the current catalog
has no database
+ named ``name``.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> pf.use_database("default_database")
+ >>> pf.get_current_database()
+ 'default_database'
+
+ .. versionadded:: 2.4.0
+ """
+ _validate_name(name, "name")
+ get_or_create_table_environment().use_database(name)
+
+
+@PublicEvolving()
+def get_current_database() -> Optional[str]:
+ """
+ Return the name of the current database.
+
+ :return: The current database name, or ``None`` if no current database is
set.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> pf.get_current_database()
+ 'default_database'
+
+ .. versionadded:: 2.4.0
+ """
+ return get_or_create_table_environment().get_current_database()
+
+
+@PublicEvolving()
+def list_databases() -> List[str]:
+ """
+ Return the names of all databases in the current catalog.
+
+ :return: Database names.
+ :raises ValueError: If no current catalog is set.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> pf.list_databases()
+ ['default_database']
+
+ .. versionadded:: 2.4.0
+ """
+ table_environment = get_or_create_table_environment()
+ # TableEnvironment.list_databases() fails with a bare
NoSuchElementException in this case.
+ if table_environment.get_current_catalog() is None:
+ raise ValueError(
+ "A current catalog has not been set. Set one with
pf.use_catalog(), or list the "
+ "databases of a specific catalog with
pf.get_catalog(name).list_databases()."
+ )
+ return table_environment.list_databases()
diff --git a/flink-python/pyflink/dataframe/dataframe.py
b/flink-python/pyflink/dataframe/dataframe.py
index a3635f876b4..c48f8af4397 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -51,6 +51,7 @@ from pyflink.table.expressions import (
)
from pyflink.table.table import Table
from pyflink.table.types import ArrayType, MapType, MultisetType, RowType
+from pyflink.table.table_descriptor import TableDescriptor
from pyflink.util.api_stability_decorators import PublicEvolving
__all__ = ["DataFrame", "GroupedDataFrame", "col", "lit"]
@@ -1963,8 +1964,8 @@ class DataFrame:
:param connector: Factory identifier used as the ``connector`` Table
option.
:param options: Connector options, excluding the reserved
``connector`` option.
:raises TypeError: If an argument has an invalid type.
- :raises ValueError: If the connector or an option key is empty, or if
``options`` contains
- the reserved ``connector`` key.
+ :raises ValueError: If the connector or an option key is empty, if
``options`` contains
+ the reserved ``connector`` key, or if Flink rejects the connector
or its options.
Example::
@@ -1990,6 +1991,7 @@ class DataFrame:
mode: str = "append",
partition_by: Optional[Union[str, List[str]]] = None,
) -> None:
+ from pyflink.dataframe.errors import _raise_as_value_error
from pyflink.dataframe.io import _build_generic_descriptor
if not isinstance(mode, str):
@@ -1997,7 +1999,54 @@ class DataFrame:
if mode not in ("append", "overwrite"):
raise ValueError("mode must be 'append' or 'overwrite'")
descriptor = _build_generic_descriptor(connector, options,
partition_by=partition_by)
- result = self._table.execute_insert(descriptor, overwrite=mode ==
"overwrite")
+ try:
+ self._execute_insert(descriptor, mode == "overwrite")
+ except Exception as error:
+ _raise_as_value_error(error)
+
+ @PublicEvolving()
+ def write_catalog_table(self, path: str, *, overwrite: bool = False) ->
None:
+ """
+ Write this DataFrame to a table registered in a catalog.
+
+ ``path`` is ``table_name``, ``db_name.table_name``, or
``catalog_name.db_name.table_name``.
+ Missing parts are resolved against the current catalog and database,
see
+ :func:`~pyflink.dataframe.use_catalog` and
:func:`~pyflink.dataframe.use_database`. The
+ write runs right away. On a local or MiniCluster setup the call blocks
until the write is
+ done.
+
+ :param path: Path of the catalog table.
+ :param overwrite: Whether existing data should be replaced, like
``INSERT OVERWRITE``.
+ Not every connector supports overwriting.
+ :raises TypeError: If ``path`` is not a string or ``overwrite`` is not
a bool.
+ :raises ValueError: If ``path`` is empty, malformed, or does not name
a table, or if the
+ DataFrame's columns do not match the table.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> events = pf.from_records([(1, "login")], schema=["id",
"event"])
+ >>> events.write_catalog_table("my_catalog.my_database.events")
+ >>> pf.use_catalog("my_catalog")
+ >>> events.write_catalog_table("my_database.events",
overwrite=True)
+
+ .. versionadded:: 2.4.0
+ """
+ from pyflink.dataframe.catalog import _validate_name
+ from pyflink.dataframe.errors import _raise_as_value_error
+
+ _validate_name(path, "path")
+ if not isinstance(overwrite, bool):
+ raise TypeError("overwrite must be a bool")
+ try:
+ self._execute_insert(path, overwrite)
+ except Exception as error:
+ _raise_as_value_error(error)
+
+ def _execute_insert(
+ self, target: Union[str, TableDescriptor], overwrite: bool = False
+ ) -> None:
+ result = self._table.execute_insert(target, overwrite=overwrite)
execution_target = self._table._t_env.get_config().get(
"execution.target", None
)
diff --git a/flink-python/pyflink/dataframe/errors.py
b/flink-python/pyflink/dataframe/errors.py
new file mode 100644
index 00000000000..79b2ba73d48
--- /dev/null
+++ b/flink-python/pyflink/dataframe/errors.py
@@ -0,0 +1,48 @@
+################################################################################
+# 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.
+################################################################################
+
+from typing import NoReturn
+
+from py4j.protocol import Py4JJavaError
+
+from pyflink.util.java_utils import is_instance_of
+
+_USER_ERROR_CLASSES = (
+ "org.apache.flink.table.api.ValidationException",
+ "org.apache.flink.table.api.SqlParserException",
+)
+
+
+def _is_user_error(j_exception) -> bool:
+ return any(is_instance_of(j_exception, j_class) for j_class in
_USER_ERROR_CLASSES)
+
+
+def _raise_as_value_error(error: Exception) -> NoReturn:
+ """
+ Re-raise ``error`` from a Flink call, turning user mistakes into
:class:`ValueError`.
+
+ When the user gets something wrong, an unknown table, a bad path, invalid
options, Flink
+ throws ``ValidationException`` or ``SqlParserException``. PyFlink hands
those to us as a bare
+ ``Py4JJavaError``. This turns them into a :class:`ValueError` with Flink's
message, chained to
+ the original so the Java trace is still there. Anything else is re-raised
as is, including
+ exceptions PyFlink already maps to Python classes such as
+ :class:`~pyflink.util.exceptions.CatalogException`.
+ """
+ if isinstance(error, Py4JJavaError) and
_is_user_error(error.java_exception):
+ raise ValueError(error.java_exception.getMessage()) from error
+ raise error
diff --git a/flink-python/pyflink/dataframe/io.py
b/flink-python/pyflink/dataframe/io.py
index 0f629674690..14b2ba206a7 100644
--- a/flink-python/pyflink/dataframe/io.py
+++ b/flink-python/pyflink/dataframe/io.py
@@ -18,13 +18,16 @@
from typing import Dict, List, Optional, Tuple, Union
+from pyflink.dataframe.catalog import _validate_name
from pyflink.dataframe.context import get_or_create_table_environment
from pyflink.dataframe.dataframe import DataFrame, _normalize_subset
from pyflink.dataframe.datatype import DataType
-from pyflink.table import Schema, TableDescriptor
+from pyflink.dataframe.errors import _raise_as_value_error
+from pyflink.java_gateway import get_gateway
+from pyflink.table import Schema, Table, TableDescriptor
from pyflink.util.api_stability_decorators import PublicEvolving
-__all__ = ["read_generic", "read_json", "read_parquet"]
+__all__ = ["read_catalog_table", "read_generic", "read_json", "read_parquet"]
def _build_filesystem_options(
@@ -296,9 +299,7 @@ def _validate_options(options: Dict[str, str]) -> None:
raise TypeError(f"option {key!r} must have a string value")
-def _validate_computed_columns(
- computed_columns: Optional[Dict[str, str]], physical_columns: Dict[str,
DataType]
-) -> Dict[str, str]:
+def _validate_computed_columns(computed_columns: Optional[Dict[str, str]]) ->
Dict[str, str]:
if computed_columns is None:
return {}
if not isinstance(computed_columns, dict):
@@ -310,10 +311,6 @@ def _validate_computed_columns(
raise TypeError("computed column names must be strings")
if not name:
raise ValueError("computed column names must not be empty")
- if name in physical_columns:
- raise ValueError(
- f"computed column {name!r} conflicts with a physical column"
- )
if not isinstance(expression, str):
raise TypeError(f"computed column {name!r} must use a string
expression")
if not expression:
@@ -358,9 +355,7 @@ def _build_source_schema(
raise TypeError(f"schema column {name!r} must use a DataType
value")
schema_builder.column(name, data_type._to_table_data_type())
- for name, expression in _validate_computed_columns(
- computed_columns, schema
- ).items():
+ for name, expression in
_validate_computed_columns(computed_columns).items():
schema_builder.column_by_expression(name, expression)
validated_watermark = _validate_watermark(watermark)
@@ -408,7 +403,11 @@ def _read(
descriptor = _build_generic_descriptor(
connector, options, schema=source_schema, partition_by=partition_by)
table_environment = get_or_create_table_environment()
- return DataFrame(table_environment.from_descriptor(descriptor))
+ try:
+ table = table_environment.from_descriptor(descriptor)
+ except Exception as error:
+ _raise_as_value_error(error)
+ return DataFrame(table)
@PublicEvolving()
@@ -435,7 +434,8 @@ def read_generic(
:return: A DataFrame backed by the configured source.
:raises TypeError: If an argument has an invalid type.
:raises ValueError: If a connector, schema, option key, computed column,
or watermark value is
- empty, or if a computed column conflicts with a physical column.
+ empty, or if Flink rejects the schema, such as a computed column that
duplicates a
+ physical column or an invalid computed column or watermark expression.
Example::
@@ -461,3 +461,150 @@ def read_generic(
connector, schema=schema, options=options,
computed_columns=computed_columns, watermark=watermark,
)
+
+
+def _extend_catalog_table(
+ table: Table,
+ computed_columns: Dict[str, str],
+ watermark: Optional[Tuple[str, str]],
+) -> Table:
+ """
+ Read the catalog table behind ``table`` with extra computed columns and,
optionally, a
+ different watermark.
+
+ Flink has no way to add columns to an existing table at read time, so this
builds a nameless
+ copy of the table definition with the new schema and reads from that
instead. Everything else
+ is copied over unchanged: options, partition keys, distribution, snapshot,
connection and
+ comment. The connector sees the same table it always did, only with more
columns. The real
+ catalog table is not touched.
+
+ This is the same thing ``TableEnvironment.from(TableDescriptor)`` does in
Java. We build the
+ ``CatalogTable`` ourselves because ``TableDescriptor`` cannot take a
distribution or snapshot
+ object directly.
+
+ Tables whose catalog supplies the connector, such as Paimon or Hive,
cannot be extended this
+ way: the copy has no catalog to ask, so it would not know which connector
to use. That is why
+ the ``connector`` option is required.
+ """
+ jvm = get_gateway().jvm
+ catalog = jvm.org.apache.flink.table.catalog
+
+ source = table._j_table.getQueryOperation().getContextResolvedTable()
+ identifier = source.getIdentifier().asSummaryString()
+ source_table = source.getTable()
+ table_kind = source_table.getTableKind()
+ if table_kind != catalog.CatalogBaseTable.TableKind.TABLE:
+ raise ValueError(
+ f"{identifier} is a {table_kind.name().lower()}; computed columns
and watermarks can "
+ f"only be added to tables"
+ )
+ if not source_table.getOptions().get("connector"):
+ raise ValueError(
+ f"{identifier} has no 'connector' option, so its connector comes
from the catalog and "
+ f"cannot be reused for an extended copy. Read it without
computed_columns and "
+ f"watermark, or use read_generic."
+ )
+
+ source_schema = source_table.getUnresolvedSchema()
+
+ schema_builder = jvm.org.apache.flink.table.api.Schema.newBuilder()
+ if watermark is None:
+ schema_builder.fromSchema(source_schema)
+ else:
+ # A schema can only have one watermark. Copy the columns and primary
key by hand so the
+ # table's own watermark is left out, then add the new one below.
+ schema_builder.fromColumns(source_schema.getColumns())
+ primary_key = source_schema.getPrimaryKey()
+ if primary_key.isPresent():
+ schema_builder.primaryKeyNamed(
+ primary_key.get().getConstraintName(),
primary_key.get().getColumnNames()
+ )
+ for name, expression in computed_columns.items():
+ schema_builder.columnByExpression(name, expression)
+ if watermark is not None:
+ schema_builder.watermark(*watermark)
+
+ catalog_table = (
+ catalog.CatalogTable.newBuilder()
+ .schema(schema_builder.build())
+ .options(source_table.getOptions())
+ .partitionKeys(source_table.getPartitionKeys())
+ .distribution(source_table.getDistribution().orElse(None))
+ .snapshot(source_table.getSnapshot().orElse(None))
+ .connection(source_table.getConnection().orElse(None))
+ .comment(source_table.getComment())
+ .build()
+ )
+
+ j_table_environment = table._t_env._j_tenv
+ try:
+ resolved_table =
j_table_environment.getCatalogManager().resolveCatalogTable(catalog_table)
+ except Exception as error:
+ _raise_as_value_error(error)
+ query_operation =
jvm.org.apache.flink.table.operations.SourceQueryOperation(
+ catalog.ContextResolvedTable.anonymous(resolved_table)
+ )
+ return Table(j_table_environment.createTable(query_operation),
table._t_env)
+
+
+@PublicEvolving()
+def read_catalog_table(
+ path: str,
+ *,
+ computed_columns: Optional[Dict[str, str]] = None,
+ watermark: Optional[Tuple[str, str]] = None,
+) -> DataFrame:
+ """
+ Read a table registered in a catalog.
+
+ ``path`` is ``table_name``, ``db_name.table_name``, or
``catalog_name.db_name.table_name``.
+ Missing parts are resolved against the current catalog and database, see
+ :func:`~pyflink.dataframe.use_catalog` and
:func:`~pyflink.dataframe.use_database`. Names that
+ are reserved keywords or contain dots must be escaped with backticks.
+
+ Use ``computed_columns`` and ``watermark`` to add columns or change the
watermark for this read
+ only. The new columns go after the table's own columns, in the order
given. The watermark
+ replaces whatever the table declares and may use the new columns. Nothing
is written back to
+ the catalog.
+
+ This only works for tables that set the ``connector`` option. Tables from
catalogs that supply
+ their own connector, such as Paimon or Hive, can still be read, just
without these two
+ arguments.
+
+ :param path: Path of the catalog table.
+ :param computed_columns: Optional SQL expressions keyed by computed column
name.
+ :param watermark: Optional ``(column, expression)`` watermark declaration.
+ :return: A DataFrame backed by the catalog table.
+ :raises TypeError: If ``path`` is not a string or another argument has an
invalid type.
+ :raises ValueError: If ``path`` is empty, malformed, or does not name a
table. Also if
+ ``computed_columns`` or ``watermark`` contain empty strings, are used
on a view or on a
+ table without a ``connector`` option, or if Flink rejects the
resulting schema, for example
+ a duplicate column name or an expression that does not compile.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> pf.create_catalog("my_catalog", {"type": "generic_in_memory"})
+ >>> orders = pf.read_catalog_table("my_catalog.default.orders")
+ >>> pf.use_catalog("my_catalog")
+ >>> orders = pf.read_catalog_table("default.orders")
+ >>> pf.use_database("default")
+ >>> orders = pf.read_catalog_table(
+ ... "orders",
+ ... computed_columns={"order_time": "TO_TIMESTAMP_LTZ(ts_millis,
3)"},
+ ... watermark=("order_time", "order_time - INTERVAL '5' SECOND"),
+ ... )
+
+ .. versionadded:: 2.4.0
+ """
+ _validate_name(path, "path")
+ table_environment = get_or_create_table_environment()
+ try:
+ table = table_environment.from_path(path)
+ except Exception as error:
+ _raise_as_value_error(error)
+ validated_columns = _validate_computed_columns(computed_columns)
+ validated_watermark = _validate_watermark(watermark)
+ if validated_columns or validated_watermark is not None:
+ table = _extend_catalog_table(table, validated_columns,
validated_watermark)
+ return DataFrame(table)
diff --git a/flink-python/pyflink/dataframe/tests/test_catalog.py
b/flink-python/pyflink/dataframe/tests/test_catalog.py
new file mode 100644
index 00000000000..3360a7a7dd7
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_catalog.py
@@ -0,0 +1,177 @@
+################################################################################
+# 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.
+################################################################################
+
+import unittest
+from unittest.mock import patch
+
+from py4j.protocol import Py4JJavaError
+
+import pyflink.dataframe as pf
+from pyflink.java_gateway import get_gateway
+from pyflink.table.catalog import Catalog
+from pyflink.testing.test_case_utils import PyFlinkDataFrameUTTestCase
+from pyflink.util.exceptions import CatalogException
+
+
+class CatalogTests(PyFlinkDataFrameUTTestCase):
+ def test_create_catalog_builds_descriptor(self):
+ with patch.object(
+ self.t_env, "create_catalog", wraps=self.t_env.create_catalog
+ ) as create_catalog:
+ pf.create_catalog(
+ "my_catalog",
+ {"type": "generic_in_memory", "default-database":
"my_database"},
+ )
+
+ name, descriptor = create_catalog.call_args.args
+ self.assertEqual(name, "my_catalog")
+ options = descriptor._j_catalog_descriptor.getConfiguration().toMap()
+ self.assertEqual(
+ dict(options),
+ {"type": "generic_in_memory", "default-database": "my_database"},
+ )
+ self.assertIn("my_catalog", pf.list_catalogs())
+ self.assertEqual(
+ pf.get_catalog("my_catalog").get_default_database(), "my_database"
+ )
+
+ def test_create_catalog_rejects_invalid_arguments(self):
+ cases = [
+ (None, {"type": "generic_in_memory"}, TypeError, "name must be a
string"),
+ ("", {"type": "generic_in_memory"}, ValueError, "name must not be
empty"),
+ ("my_catalog", ["type"], TypeError, "options must be a dict"),
+ ("my_catalog", {1: "x"}, TypeError, "option keys must be strings"),
+ ("my_catalog", {"": "x"}, ValueError, "option keys must not be
empty"),
+ (
+ "my_catalog",
+ {"type": "generic_in_memory", "flag": True},
+ TypeError,
+ "option 'flag' must have a string value",
+ ),
+ ]
+
+ for name, options, error_type, message in cases:
+ with self.subTest(name=name, options=options):
+ with self.assertRaisesRegex(error_type, message):
+ pf.create_catalog(name, options)
+
+ self.assertNotIn("my_catalog", pf.list_catalogs())
+
+ def test_create_catalog_rejects_existing_catalog(self):
+ with self.assertRaisesRegex(CatalogException, "Catalog default_catalog
already exists"):
+ pf.create_catalog("default_catalog", {"type": "generic_in_memory"})
+
+ def test_create_catalog_translates_flink_errors(self):
+ with self.assertRaises(ValueError) as context:
+ pf.create_catalog("my_catalog", {"type": "no_such_catalog_type"})
+ self.assertIn("Unable to create catalog 'my_catalog'",
str(context.exception))
+ self.assertIn("'type'='no_such_catalog_type'", str(context.exception))
+ self.assertIsInstance(context.exception.__cause__, Py4JJavaError)
+ self.assertNotIn("my_catalog", pf.list_catalogs())
+
+ def test_get_catalog(self):
+ self.assertIsNone(pf.get_catalog("missing"))
+
+ pf.create_catalog("my_catalog", {"type": "generic_in_memory"})
+ catalog = pf.get_catalog("my_catalog")
+ self.assertIsInstance(catalog, Catalog)
+ self.assertEqual(catalog.list_databases(), ["default"])
+
+ def test_catalog_navigation(self):
+ self.assertEqual(pf.get_current_catalog(), "default_catalog")
+ self.assertEqual(pf.get_current_database(), "default_database")
+ self.assertEqual(pf.list_catalogs(), ["default_catalog"])
+ self.assertEqual(pf.list_databases(), ["default_database"])
+
+ pf.create_catalog("my_catalog", {"type": "generic_in_memory"})
+ self.assertEqual(pf.list_catalogs(), ["default_catalog", "my_catalog"])
+
+ pf.use_catalog("my_catalog")
+ self.assertEqual(pf.get_current_catalog(), "my_catalog")
+ self.assertEqual(pf.get_current_database(), "default")
+ self.assertEqual(pf.list_databases(), ["default"])
+
+ self.t_env.execute_sql("CREATE DATABASE my_database")
+ self.assertEqual(sorted(pf.list_databases()), ["default",
"my_database"])
+
+ pf.use_database("my_database")
+ self.assertEqual(pf.get_current_database(), "my_database")
+
+ pf.use_catalog("default_catalog")
+ self.assertEqual(pf.get_current_catalog(), "default_catalog")
+ self.assertEqual(pf.get_current_database(), "default_database")
+
+ def test_navigation_rejects_unknown_names(self):
+ with self.assertRaisesRegex(
+ CatalogException, r"A catalog with name \[missing\] does not exist"
+ ):
+ pf.use_catalog("missing")
+
+ with self.assertRaisesRegex(
+ CatalogException,
+ r"A database with name \[missing\] does not exist in the catalog:
\[default_catalog\]",
+ ):
+ pf.use_database("missing")
+
+ self.assertEqual(pf.get_current_catalog(), "default_catalog")
+ self.assertEqual(pf.get_current_database(), "default_database")
+
+ def test_navigation_without_current_catalog(self):
+ self.t_env.use_catalog(None)
+ self.assertIsNone(pf.get_current_catalog())
+ self.assertIsNone(pf.get_current_database())
+
+ with self.assertRaisesRegex(ValueError, "A current catalog has not
been set"):
+ pf.list_databases()
+ self.assertEqual(pf.get_catalog("default_catalog").list_databases(),
["default_database"])
+
+ pf.use_catalog("default_catalog")
+ self.assertEqual(pf.list_databases(), ["default_database"])
+
+ def test_unexpected_errors_are_not_translated(self):
+ gateway = get_gateway()
+ j_error = gateway.jvm.org.apache.flink.table.api.TableException("boom")
+ java_error = Py4JJavaError("An error occurred while calling
createCatalog.", j_error)
+ catalog_error = CatalogException("boom")
+ python_error = RuntimeError("boom")
+
+ for error in [java_error, catalog_error, python_error]:
+ with self.subTest(error=type(error).__name__):
+ with patch.object(self.t_env, "create_catalog",
side_effect=error):
+ with self.assertRaises(type(error)) as context:
+ pf.create_catalog("my_catalog", {"type":
"generic_in_memory"})
+ self.assertIs(context.exception, error)
+
+ def test_name_validation(self):
+ for function in [pf.get_catalog, pf.use_catalog, pf.use_database]:
+ with self.subTest(function=function.__name__):
+ with self.assertRaisesRegex(TypeError, "name must be a
string"):
+ function(None)
+ with self.assertRaisesRegex(ValueError, "name must not be
empty"):
+ function("")
+
+ def test_uses_dataframe_table_environment(self):
+ pf.create_catalog("my_catalog", {"type": "generic_in_memory"})
+ self.assertIn("my_catalog", self.t_env.list_catalogs())
+
+ pf.use_catalog("my_catalog")
+ self.assertEqual(self.t_env.get_current_catalog(), "my_catalog")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/flink-python/pyflink/dataframe/tests/test_filesystem_io.py
b/flink-python/pyflink/dataframe/tests/test_filesystem_io.py
index f1012ae9cd0..ef265be8015 100644
--- a/flink-python/pyflink/dataframe/tests/test_filesystem_io.py
+++ b/flink-python/pyflink/dataframe/tests/test_filesystem_io.py
@@ -217,12 +217,12 @@ class FilesystemIOTests(PyFlinkDataFrameUTTestCase):
def test_invalid_partition_columns_are_rejected_by_the_table_api(self):
for reader in (pf.read_json, pf.read_parquet):
with self.subTest(reader=reader.__name__):
- with self.assertRaisesRegex(Py4JJavaError, "missing"):
+ with self.assertRaisesRegex(ValueError, "missing"):
reader("/input", schema=self._SCHEMA,
partition_by="missing")
dataframe = pf.from_records([(1,)], schema=["id"])
for writer in (dataframe.write_json, dataframe.write_parquet):
with self.subTest(writer=writer.__name__):
- with self.assertRaisesRegex(Py4JJavaError, "missing"):
+ with self.assertRaisesRegex(ValueError, "missing"):
writer("/output", partition_by="missing")
def
test_writers_accept_raw_options_without_overriding_them_with_defaults(self):
diff --git a/flink-python/pyflink/dataframe/tests/test_io.py
b/flink-python/pyflink/dataframe/tests/test_io.py
index ef57cfde616..64c2e84a8de 100644
--- a/flink-python/pyflink/dataframe/tests/test_io.py
+++ b/flink-python/pyflink/dataframe/tests/test_io.py
@@ -22,10 +22,12 @@ from unittest.mock import MagicMock, patch
import pyflink.dataframe as pf
from pyflink.dataframe import DataType
+from pyflink.table import DataTypes
from pyflink.testing.test_case_utils import (
PyFlinkDataFrameUTTestCase,
PyFlinkStreamDataFrameTestCase,
)
+from py4j.protocol import Py4JJavaError
class GenericIOTests(PyFlinkDataFrameUTTestCase):
@@ -146,12 +148,6 @@ class GenericIOTests(PyFlinkDataFrameUTTestCase):
TypeError,
"computed_columns must be a dict",
),
- (
- "duplicate computed column",
- {"computed_columns": {"id": "id + 1"}},
- ValueError,
- "conflicts with a physical column",
- ),
(
"computed column name type",
{"computed_columns": {1: "id + 1"}},
@@ -276,8 +272,373 @@ class GenericIOTests(PyFlinkDataFrameUTTestCase):
with self.assertRaisesRegex(error_type, message):
dataframe.write_generic(connector, options=options)
+ def test_read_generic_translates_flink_errors(self):
+ with self.assertRaises(ValueError) as context:
+ pf.read_generic(
+ "datagen",
+ schema=self._SCHEMA,
+ options={},
+ computed_columns={"bad": "NO_SUCH_FUNCTION(id)"},
+ )
+ self.assertEqual(
+ str(context.exception), "Invalid expression for computed column
'bad'."
+ )
+ self.assertIsInstance(context.exception.__cause__, Py4JJavaError)
+
+ with self.assertRaises(ValueError) as context:
+ pf.read_generic(
+ "datagen",
+ schema=self._SCHEMA,
+ options={},
+ computed_columns={"id": "id + 1"},
+ )
+ self.assertEqual(
+ str(context.exception),
+ "Schema must not contain duplicate column names. Found duplicates:
[id]",
+ )
+ self.assertIsInstance(context.exception.__cause__, Py4JJavaError)
+
+ def test_write_generic_translates_flink_errors(self):
+ dataframe = pf.from_records([(1,)], schema=["id"])
+
+ with self.assertRaises(ValueError) as context:
+ dataframe.write_generic("no_such_connector", options={})
+ self.assertIn("no_such_connector", str(context.exception))
+ self.assertIsInstance(context.exception.__cause__, Py4JJavaError)
+
+
+class CatalogTableIOTests(PyFlinkDataFrameUTTestCase):
+ def setUp(self):
+ super().setUp()
+ pf.create_catalog("my_catalog", {"type": "generic_in_memory"})
+ self.t_env.execute_sql("CREATE DATABASE my_catalog.my_database")
+ self.t_env.execute_sql(
+ "CREATE TABLE my_catalog.my_database.events ("
+ " id BIGINT, name STRING"
+ ") WITH ('connector' = 'datagen', 'number-of-rows' = '1')"
+ )
+
+ def test_read_catalog_table_resolves_paths(self):
+ cases = [
+ ("default_catalog", "default_database",
"my_catalog.my_database.events"),
+ ("my_catalog", "default", "my_database.events"),
+ ("my_catalog", "my_database", "events"),
+ ]
+
+ for catalog, database, path in cases:
+ with self.subTest(path=path):
+ pf.use_catalog(catalog)
+ pf.use_database(database)
+ with patch.object(
+ self.t_env, "from_path", wraps=self.t_env.from_path
+ ) as from_path:
+ dataframe = pf.read_catalog_table(path)
+
+ from_path.assert_called_once_with(path)
+ self.assertIsInstance(dataframe, pf.DataFrame)
+ self.assert_dataframe_schema(
+ dataframe,
+ ["id", "name"],
+ [DataTypes.BIGINT(), DataTypes.STRING()],
+ )
+
+ def test_read_catalog_table_rejects_invalid_path(self):
+ with self.assertRaisesRegex(TypeError, "path must be a string"):
+ pf.read_catalog_table(None)
+ with self.assertRaisesRegex(ValueError, "path must not be empty"):
+ pf.read_catalog_table("")
+
+ def test_read_catalog_table_translates_flink_errors(self):
+ with self.assertRaises(ValueError) as context:
+ pf.read_catalog_table("my_catalog.my_database.missing")
+ self.assertEqual(
+ str(context.exception),
+ "Table `my_catalog`.`my_database`.`missing` was not found.",
+ )
+ self.assertIsInstance(context.exception.__cause__, Py4JJavaError)
+
+ with self.assertRaises(ValueError) as context:
+ pf.read_catalog_table("too.many.path.parts")
+ self.assertEqual(
+ str(context.exception), "Invalid SQL identifier
too.many.path.parts."
+ )
+ self.assertIsInstance(context.exception.__cause__, Py4JJavaError)
+
+ def _create_timed_table(self):
+ self.t_env.execute_sql(
+ "CREATE TABLE my_catalog.my_database.timed ("
+ " id BIGINT,"
+ " ts_millis BIGINT,"
+ " doubled AS id * 2,"
+ " old_ts AS TO_TIMESTAMP_LTZ(ts_millis, 3),"
+ " WATERMARK FOR old_ts AS old_ts,"
+ " PRIMARY KEY (id) NOT ENFORCED"
+ ") COMMENT 'timed events' PARTITIONED BY (id) WITH ("
+ " 'connector' = 'datagen', 'number-of-rows' = '1'"
+ ")"
+ )
+
+ def test_read_catalog_table_extends_schema(self):
+ self._create_timed_table()
+ extension = dict(
+ computed_columns={"event_time": "TO_TIMESTAMP_LTZ(ts_millis, 3)"},
+ watermark=("event_time", "event_time - INTERVAL '5' SECOND"),
+ )
+
+ dataframe = pf.read_catalog_table("my_catalog.my_database.timed",
**extension)
+
+ self.assert_dataframe_schema(
+ dataframe, ["id", "ts_millis", "doubled", "old_ts", "event_time"]
+ )
+ resolved_schema = dataframe._table.get_resolved_schema()
+ (watermark_spec,) = resolved_schema.get_watermark_specs()
+ self.assertEqual(watermark_spec.get_rowtime_attribute(), "event_time")
+
self.assertEqual(list(resolved_schema.get_primary_key().get_columns()), ["id"])
+
+ # The copy keeps everything the connector needs from the original
table.
+ j_source =
dataframe._table._j_table.getQueryOperation().getContextResolvedTable()
+ self.assertTrue(j_source.isAnonymous())
+ j_catalog_table = j_source.getTable()
+ self.assertEqual(
+ dict(j_catalog_table.getOptions()),
+ {"connector": "datagen", "number-of-rows": "1"},
+ )
+ self.assertEqual(list(j_catalog_table.getPartitionKeys()), ["id"])
+ self.assertEqual(j_catalog_table.getComment(), "timed events")
+
+ # The catalog table itself is left untouched.
+ catalog_watermarks = self.t_env.from_path(
+ "my_catalog.my_database.timed"
+ ).get_resolved_schema().get_watermark_specs()
+ self.assertEqual(
+ [spec.get_rowtime_attribute() for spec in catalog_watermarks],
["old_ts"]
+ )
+
+ def test_read_catalog_table_keeps_source_watermark_without_override(self):
+ self._create_timed_table()
+ dataframe = pf.read_catalog_table(
+ "my_catalog.my_database.timed", computed_columns={"tripled": "id *
3"}
+ )
+ self.assert_dataframe_schema(
+ dataframe, ["id", "ts_millis", "doubled", "old_ts", "tripled"]
+ )
+ resolved_schema = dataframe._table.get_resolved_schema()
+ self.assertEqual(
+ [spec.get_rowtime_attribute() for spec in
resolved_schema.get_watermark_specs()],
+ ["old_ts"],
+ )
+
+ def test_read_catalog_table_without_extension_keeps_catalog_identity(self):
+ cases = [{}, {"computed_columns": {}}]
+
+ for arguments in cases:
+ with self.subTest(arguments=arguments):
+ dataframe = pf.read_catalog_table(
+ "my_catalog.my_database.events", **arguments
+ )
+ j_source = (
+
dataframe._table._j_table.getQueryOperation().getContextResolvedTable()
+ )
+ self.assertFalse(j_source.isAnonymous())
+ self.assertEqual(
+ j_source.getIdentifier().asSummaryString(),
+ "my_catalog.my_database.events",
+ )
+
+ def test_read_catalog_table_rejects_invalid_extensions(self):
+ self.t_env.execute_sql("CREATE TABLE
my_catalog.my_database.no_connector (id BIGINT)")
+ self.t_env.execute_sql(
+ "CREATE VIEW my_catalog.my_database.events_view AS "
+ "SELECT id FROM my_catalog.my_database.events"
+ )
+ cases = [
+ (
+ "events",
+ {"computed_columns": ["id + 1"]},
+ TypeError,
+ "computed_columns must be a dict",
+ ),
+ (
+ "events",
+ {"computed_columns": []},
+ TypeError,
+ "computed_columns must be a dict",
+ ),
+ (
+ "events",
+ {"watermark": "id"},
+ TypeError,
+ "watermark must be a tuple",
+ ),
+ (
+ "events_view",
+ {"computed_columns": {"tripled": "id * 3"}},
+ ValueError,
+ "my_catalog.my_database.events_view is a view",
+ ),
+ (
+ "no_connector",
+ {"computed_columns": {"tripled": "id * 3"}},
+ ValueError,
+ "my_catalog.my_database.no_connector has no 'connector'
option",
+ ),
+ ]
+ for table, extension, error_type, message in cases:
+ with self.subTest(table=table, extension=extension):
+ with self.assertRaisesRegex(error_type, message):
+ pf.read_catalog_table(f"my_catalog.my_database.{table}",
**extension)
+
+ def test_read_catalog_table_translates_extension_errors(self):
+ cases = [
+ (
+ {"computed_columns": {"broken": "no_such_column + 1"}},
+ "Invalid expression for computed column 'broken'",
+ ),
+ (
+ {"computed_columns": {"id": "id + 1"}},
+ "Schema must not contain duplicate column names. Found
duplicates: [id]",
+ ),
+ (
+ {"watermark": ("no_such_column", "no_such_column")},
+ "Invalid column name 'no_such_column' for rowtime attribute",
+ ),
+ ]
+ for extension, message in cases:
+ with self.subTest(extension=extension):
+ with self.assertRaises(ValueError) as context:
+ pf.read_catalog_table("my_catalog.my_database.events",
**extension)
+ self.assertIn(message, str(context.exception))
+ self.assertIsInstance(context.exception.__cause__,
Py4JJavaError)
+
+ def test_write_catalog_table_translates_flink_errors(self):
+ dataframe = pf.from_records([(1, "a")], schema=["id", "name"])
+
+ with self.assertRaises(ValueError) as context:
+ dataframe.write_catalog_table("my_catalog.my_database.missing")
+ self.assertIn(
+ "Cannot find table '`my_catalog`.`my_database`.`missing`'",
+ str(context.exception),
+ )
+
+ self.t_env.execute_sql(
+ "CREATE TABLE my_catalog.my_database.sink (id BIGINT) "
+ "WITH ('connector' = 'blackhole')"
+ )
+ with self.assertRaises(ValueError) as context:
+ dataframe.write_catalog_table("my_catalog.my_database.sink")
+ self.assertIn("Column types of query result and sink",
str(context.exception))
+ self.assertIsInstance(context.exception.__cause__, Py4JJavaError)
+
+ def test_write_catalog_table_passes_path_and_overwrite(self):
+ dataframe = pf.from_records([(1, "a")], schema=["id", "name"])
+
+ for overwrite in [False, True]:
+ with self.subTest(overwrite=overwrite):
+ with patch.object(
+ dataframe._table, "execute_insert",
return_value=MagicMock()
+ ) as execute_insert:
+ result = dataframe.write_catalog_table(
+ "my_catalog.my_database.events", overwrite=overwrite
+ )
+
+ self.assertIsNone(result)
+ execute_insert.assert_called_once_with(
+ "my_catalog.my_database.events", overwrite=overwrite
+ )
+
+ def
test_write_catalog_table_waits_for_local_and_minicluster_execution(self):
+ dataframe = pf.from_records([(1,)], schema=["id"])
+
+ for execution_target, waits in [
+ ("local", True),
+ ("minicluster", True),
+ ("remote", False),
+ ]:
+ with self.subTest(execution_target=execution_target):
+ table_result = MagicMock()
+ table_config = MagicMock()
+ table_config.get.return_value = execution_target
+ with patch.object(
+ dataframe._table,
+ "execute_insert",
+ return_value=table_result,
+ ), patch.object(
+ dataframe._table._t_env,
+ "get_config",
+ return_value=table_config,
+ ):
+ dataframe.write_catalog_table("events")
+
+ if waits:
+ table_result.wait.assert_called_once_with()
+ else:
+ table_result.wait.assert_not_called()
+
+ def test_write_catalog_table_rejects_invalid_arguments(self):
+ dataframe = pf.from_records([(1,)], schema=["id"])
+ cases = [
+ (None, False, TypeError, "path must be a string"),
+ ("", False, ValueError, "path must not be empty"),
+ ("events", "yes", TypeError, "overwrite must be a bool"),
+ ]
+
+ for path, overwrite, error_type, message in cases:
+ with self.subTest(path=path, overwrite=overwrite):
+ with self.assertRaisesRegex(error_type, message):
+ dataframe.write_catalog_table(path, overwrite=overwrite)
+
class GenericIOITTests(PyFlinkStreamDataFrameTestCase):
+ def test_catalog_table_round_trip(self):
+ output_path = os.path.join(self.tempdir, "catalog_output")
+ pf.create_catalog("my_catalog", {"type": "generic_in_memory"})
+ self.addCleanup(pf.use_catalog, pf.get_current_catalog())
+ self.t_env.execute_sql("CREATE DATABASE my_catalog.my_database")
+ self.t_env.execute_sql(
+ "CREATE TABLE my_catalog.my_database.events ("
+ " id BIGINT, name STRING"
+ ") WITH ("
+ " 'connector' = 'filesystem',"
+ f" 'path' = '{output_path}',"
+ " 'format' = 'csv'"
+ ")"
+ )
+
+ pf.use_catalog("my_catalog")
+ pf.use_database("my_database")
+ source = pf.from_records([(1, "a"), (2, "b"), (3, "c")], schema=["id",
"name"])
+ source.write_catalog_table("events")
+
+ result = pf.read_catalog_table("events").collect()
+ self.assertEqual(
+ sorted(tuple(row) for row in result), [(1, "a"), (2, "b"), (3,
"c")]
+ )
+
+ def test_catalog_table_with_computed_columns_and_watermark(self):
+ input_path = os.path.join(self.tempdir, "timed.csv")
+ with open(input_path, "w", encoding="utf-8") as input_file:
+ input_file.write("1,1000\n2,2000\n3,3000\n")
+ pf.create_catalog("timed_catalog", {"type": "generic_in_memory"})
+ self.t_env.execute_sql(
+ "CREATE TABLE timed_catalog.`default`.timed ("
+ " id BIGINT, ts_millis BIGINT"
+ ") WITH ("
+ " 'connector' = 'filesystem',"
+ f" 'path' = '{input_path}',"
+ " 'format' = 'csv'"
+ ")"
+ )
+
+ result = pf.read_catalog_table(
+ "timed_catalog.`default`.timed",
+ computed_columns={
+ "ts_seconds": "ts_millis / 1000",
+ "event_time": "TO_TIMESTAMP_LTZ(ts_millis, 3)",
+ },
+ watermark=("event_time", "event_time - INTERVAL '1' SECOND"),
+ ).select("id", "ts_seconds").collect()
+ self.assertEqual(sorted(tuple(row) for row in result), [(1, 1), (2,
2), (3, 3)])
+
def test_filesystem_csv_round_trip(self):
input_path = os.path.join(self.tempdir, "input.csv")
with open(input_path, "w", encoding="utf-8") as input_file: