This is an automated email from the ASF dual-hosted git repository. jerryshao pushed a commit to branch branch-1.3 in repository https://gitbox.apache.org/repos/asf/gravitino.git
commit 071bb7c9d7c1c5fb1c98b3549f4e7adb641a192c Author: Zhiguo Wu <[email protected]> AuthorDate: Fri Jul 17 16:53:52 2026 +0800 [#12039] feat(client-python): Add view API definitions (#12040) ### What changes were proposed in this pull request? This PR adds the public Python API definitions required for view support. The changes include: - Add `View`, `ViewCatalog`, and `ViewChange`. - Add `Representation`, `SQLRepresentation`, and predefined SQL dialects. - Add `Catalog.as_view_catalog()`. - Add view-specific exception types. - Add unit tests for view representations and changes. ### Why are the changes needed? The Python client currently lacks the public API contracts required to represent and manage views. Fix: #12039 ### Does this PR introduce _any_ user-facing change? Yes. It adds public Python API definitions for views. ### How was this patch tested? Added unit tests for `View`, `ViewChange`, and `SQLRepresentation`. The formatting, lint, and unit test checks passed. (cherry picked from commit c10925adb789d5f9c1ed9519faad4bcfc46bc15a) --- clients/client-python/gravitino/api/catalog.py | 10 + .../client-python/gravitino/api/rel/dialects.py | 25 +++ .../gravitino/api/rel/representation.py | 28 +++ .../gravitino/api/rel/sql_representation.py | 47 +++++ clients/client-python/gravitino/api/rel/view.py | 68 +++++++ .../gravitino/api/rel/view_catalog.py | 149 ++++++++++++++ .../client-python/gravitino/api/rel/view_change.py | 214 +++++++++++++++++++++ clients/client-python/gravitino/exceptions/base.py | 8 + .../unittests/api/rel/test_sql_representation.py | 54 ++++++ .../tests/unittests/api/rel/test_view.py | 69 +++++++ .../tests/unittests/api/rel/test_view_change.py | 150 +++++++++++++++ 11 files changed, 822 insertions(+) diff --git a/clients/client-python/gravitino/api/catalog.py b/clients/client-python/gravitino/api/catalog.py index 5e97fe8455..27d6688415 100644 --- a/clients/client-python/gravitino/api/catalog.py +++ b/clients/client-python/gravitino/api/catalog.py @@ -158,6 +158,16 @@ class Catalog(Auditable): """ raise UnsupportedOperationException("Catalog does not support table operations") + def as_view_catalog(self) -> "ViewCatalog": # noqa: F821 + """ + Raises: + UnsupportedOperationException if the catalog does not support view operations. + + Returns: + the {@link ViewCatalog} if the catalog supports view operations. + """ + raise UnsupportedOperationException("Catalog does not support view operations") + def as_fileset_catalog(self) -> "FilesetCatalog": # noqa: F821 """ Raises: diff --git a/clients/client-python/gravitino/api/rel/dialects.py b/clients/client-python/gravitino/api/rel/dialects.py new file mode 100644 index 0000000000..7588a81f09 --- /dev/null +++ b/clients/client-python/gravitino/api/rel/dialects.py @@ -0,0 +1,25 @@ +# 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. + + +class Dialects: + """Predefined SQL dialect identifiers.""" + + TRINO = "trino" + SPARK = "spark" + HIVE = "hive" + FLINK = "flink" diff --git a/clients/client-python/gravitino/api/rel/representation.py b/clients/client-python/gravitino/api/rel/representation.py new file mode 100644 index 0000000000..1803185a58 --- /dev/null +++ b/clients/client-python/gravitino/api/rel/representation.py @@ -0,0 +1,28 @@ +# 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 abc import ABC, abstractmethod + + +class Representation(ABC): + """A representation of a view definition.""" + + TYPE_SQL = "sql" + + @abstractmethod + def type(self) -> str: + """Returns the representation type.""" diff --git a/clients/client-python/gravitino/api/rel/sql_representation.py b/clients/client-python/gravitino/api/rel/sql_representation.py new file mode 100644 index 0000000000..00d5fb8e4a --- /dev/null +++ b/clients/client-python/gravitino/api/rel/sql_representation.py @@ -0,0 +1,47 @@ +# 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 dataclasses import dataclass + +from gravitino.api.rel.representation import Representation +from gravitino.utils.precondition import Precondition + + +@dataclass(frozen=True) +class SQLRepresentation(Representation): + """A SQL-based representation of a view definition.""" + + _dialect: str + _sql: str + + def __post_init__(self): + Precondition.check_string_not_empty( + self._dialect, "dialect must not be null or empty" + ) + Precondition.check_string_not_empty(self._sql, "sql must not be null or empty") + + def type(self) -> str: + """Returns the representation type.""" + return Representation.TYPE_SQL + + def dialect(self) -> str: + """Returns the SQL dialect.""" + return self._dialect + + def sql(self) -> str: + """Returns the SQL text.""" + return self._sql diff --git a/clients/client-python/gravitino/api/rel/view.py b/clients/client-python/gravitino/api/rel/view.py new file mode 100644 index 0000000000..7db72a6750 --- /dev/null +++ b/clients/client-python/gravitino/api/rel/view.py @@ -0,0 +1,68 @@ +# 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 abc import abstractmethod +from typing import Optional + +from gravitino.api.auditable import Auditable +from gravitino.api.rel.column import Column +from gravitino.api.rel.representation import Representation +from gravitino.api.rel.sql_representation import SQLRepresentation + + +class View(Auditable): + """An interface representing a logical view in a namespace.""" + + @abstractmethod + def name(self) -> str: + """Returns the view name.""" + + def comment(self) -> Optional[str]: + """Returns the view comment.""" + return None + + @abstractmethod + def columns(self) -> list[Column]: + """Returns the output columns of the view.""" + + @abstractmethod + def representations(self) -> list[Representation]: + """Returns the representations of the view.""" + + def default_catalog(self) -> Optional[str]: + """Returns the default catalog used to resolve unqualified identifiers.""" + return None + + def default_schema(self) -> Optional[str]: + """Returns the default schema used to resolve unqualified identifiers.""" + return None + + def sql_for(self, dialect: Optional[str]) -> Optional[SQLRepresentation]: + """Returns the SQL representation for the given dialect.""" + if dialect is None: + return None + for representation in self.representations(): + if ( + isinstance(representation, SQLRepresentation) + and representation.dialect().lower() == dialect.lower() + ): + return representation + return None + + def properties(self) -> dict[str, str]: + """Returns the view properties.""" + return {} diff --git a/clients/client-python/gravitino/api/rel/view_catalog.py b/clients/client-python/gravitino/api/rel/view_catalog.py new file mode 100644 index 0000000000..39da01e85c --- /dev/null +++ b/clients/client-python/gravitino/api/rel/view_catalog.py @@ -0,0 +1,149 @@ +# 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 abc import ABC, abstractmethod +from typing import Optional + +from gravitino.api.rel.column import Column +from gravitino.api.rel.representation import Representation +from gravitino.api.rel.view import View +from gravitino.api.rel.view_change import ViewChange +from gravitino.exceptions.base import NoSuchViewException +from gravitino.name_identifier import NameIdentifier +from gravitino.namespace import Namespace + + +class ViewCatalog(ABC): + """The `ViewCatalog` interface defines the public API for managing views in a schema. + + If the catalog implementation supports views, it must implement this interface. + """ + + @abstractmethod + def list_views(self, namespace: Namespace) -> list[NameIdentifier]: + """List the views in a namespace from the catalog. + + Args: + namespace (Namespace): A namespace. + + Returns: + list[NameIdentifier]: An array of view identifiers in the namespace. + + Raises: + NoSuchSchemaException: If the schema does not exist. + """ + + @abstractmethod + def load_view(self, identifier: NameIdentifier) -> View: + """Load view metadata by `NameIdentifier` from the catalog. + + Args: + identifier (NameIdentifier): A view identifier. + + Returns: + View: The view metadata. + + Raises: + NoSuchViewException: If the view does not exist. + """ + + def view_exists(self, identifier: NameIdentifier) -> bool: + """Check if a view with the given name exists in the catalog. + + Args: + identifier: The view identifier. + + Returns: + True if the view exists, False otherwise. + """ + try: + self.load_view(identifier) + return True + except NoSuchViewException: + return False + + @abstractmethod + def create_view( + self, + identifier: NameIdentifier, + columns: list[Column], + representations: list[Representation], + comment: Optional[str] = None, + default_catalog: Optional[str] = None, + default_schema: Optional[str] = None, + properties: Optional[dict[str, str]] = None, + ) -> View: + """Create a view in the catalog. + + Args: + identifier (NameIdentifier): + A view identifier. + columns (list[Column]): + The columns of the new view. + representations (list[Representation]): + The representations of the view. At least one representation is expected. + comment (str, optional): + The view comment. Defaults to `None`. + default_catalog (str, optional): + The default catalog name used to resolve unqualified objects in the view. + Defaults to `None`. + default_schema (str, optional): + The default schema name used to resolve unqualified objects in the view. + Defaults to `None`. + properties (dict[str, str], optional): + The view properties. Defaults to `None`. + + Returns: + View: + The created view metadata. + + Raises: + NoSuchSchemaException: + If the schema does not exist. + ViewAlreadyExistsException: + If the view already exists. + """ + + @abstractmethod + def alter_view(self, identifier: NameIdentifier, *changes: ViewChange) -> View: + """Alter a view in the catalog. + + Args: + identifier (NameIdentifier): A view identifier. + *changes: + View changes to apply to the view. + + Returns: + View: The updated view metadata. + + Raises: + NoSuchViewException: + If the view does not exist. + IllegalArgumentException: + If the change is rejected by the implementation. + """ + + @abstractmethod + def drop_view(self, identifier: NameIdentifier) -> bool: + """Drop a view from the catalog. + + Args: + identifier (NameIdentifier): A view identifier. + + Returns: + bool: `True` if the view is dropped, `False` if the view does not exist. + """ diff --git a/clients/client-python/gravitino/api/rel/view_change.py b/clients/client-python/gravitino/api/rel/view_change.py new file mode 100644 index 0000000000..7397b5f9fc --- /dev/null +++ b/clients/client-python/gravitino/api/rel/view_change.py @@ -0,0 +1,214 @@ +# 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 abc import ABC +from dataclasses import dataclass +from typing import Optional + +from gravitino.api.rel.column import Column +from gravitino.api.rel.representation import Representation +from gravitino.utils.precondition import Precondition + + +class ViewChange(ABC): + """Defines changes that can be applied to a view.""" + + @staticmethod + def rename(new_name: str) -> "RenameView": + """Create a change for renaming a view.""" + return RenameView(new_name) + + @staticmethod + def set_property(property_name: str, value: str) -> "SetProperty": + """Create a change for setting a view property.""" + return SetProperty(property_name, value) + + @staticmethod + def remove_property(property_name: str) -> "RemoveProperty": + """Create a change for removing a view property.""" + return RemoveProperty(property_name) + + @staticmethod + def replace_view( + columns: list[Column], + representations: list[Representation], + default_catalog: Optional[str] = None, + default_schema: Optional[str] = None, + comment: Optional[str] = None, + ) -> "ReplaceView": + """Create a change for replacing the view body.""" + return ReplaceView( + columns, representations, default_catalog, default_schema, comment + ) + + +@dataclass(frozen=True) +class RenameView(ViewChange): + """A ViewChange to rename a view.""" + + _new_name: str + + def __post_init__(self): + Precondition.check_string_not_empty( + self._new_name, "newName must not be null or empty" + ) + + def new_name(self) -> str: + """Returns the new view name.""" + return self._new_name + + def __eq__(self, other: object) -> bool: + if not isinstance(other, RenameView): + return False + return self._new_name == other.new_name() + + def __hash__(self) -> int: + return hash(self._new_name) + + def __str__(self) -> str: + return f"RENAMEVIEW {self._new_name}" + + +@dataclass(frozen=True) +class SetProperty(ViewChange): + """A ViewChange to set a view property.""" + + _property: str + _value: str + + def __post_init__(self): + Precondition.check_string_not_empty( + self._property, "property must not be null or empty" + ) + + def property(self) -> str: + """Returns the property name.""" + return self._property + + def value(self) -> str: + """Returns the property value.""" + return self._value + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SetProperty): + return False + return self._property == other.property() and self._value == other.value() + + def __hash__(self) -> int: + return hash((self._property, self._value)) + + def __str__(self) -> str: + return f"SETPROPERTY {self._property} {self._value}" + + +@dataclass(frozen=True) +class RemoveProperty(ViewChange): + """A ViewChange to remove a view property.""" + + _property: str + + def __post_init__(self): + Precondition.check_string_not_empty( + self._property, "property must not be null or empty" + ) + + def property(self) -> str: + """Returns the property name.""" + return self._property + + def __eq__(self, other: object) -> bool: + if not isinstance(other, RemoveProperty): + return False + return self._property == other.property() + + def __hash__(self) -> int: + return hash(self._property) + + def __str__(self) -> str: + return f"REMOVEPROPERTY {self._property}" + + +@dataclass(frozen=True) +class ReplaceView(ViewChange): + """A ViewChange to replace the view body.""" + + _columns: list[Column] + _representations: list[Representation] + _default_catalog: Optional[str] = None + _default_schema: Optional[str] = None + _comment: Optional[str] = None + + def __post_init__(self): + Precondition.check_argument( + self._columns is not None, "columns must not be null" + ) + Precondition.check_argument( + self._representations is not None and len(self._representations) > 0, + "representations must not be null or empty", + ) + object.__setattr__(self, "_columns", list(self._columns)) + object.__setattr__(self, "_representations", list(self._representations)) + + def columns(self) -> list[Column]: + """Returns the new output columns.""" + return list(self._columns) + + def representations(self) -> list[Representation]: + """Returns the new representations.""" + return list(self._representations) + + def default_catalog(self) -> Optional[str]: + """Returns the new default catalog.""" + return self._default_catalog + + def default_schema(self) -> Optional[str]: + """Returns the new default schema.""" + return self._default_schema + + def comment(self) -> Optional[str]: + """Returns the new comment.""" + return self._comment + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ReplaceView): + return False + return ( + self._columns == other.columns() + and self._representations == other.representations() + and self._default_catalog == other.default_catalog() + and self._default_schema == other.default_schema() + and self._comment == other.comment() + ) + + def __hash__(self) -> int: + return hash( + ( + tuple(self._columns), + tuple(self._representations), + self._default_catalog, + self._default_schema, + self._comment, + ) + ) + + def __str__(self) -> str: + return ( + f"REPLACEVIEW columns={self._columns}, " + f"representations={self._representations}, " + f"defaultCatalog={self._default_catalog}, " + f"defaultSchema={self._default_schema}, comment={self._comment}" + ) diff --git a/clients/client-python/gravitino/exceptions/base.py b/clients/client-python/gravitino/exceptions/base.py index 023f5d490e..b22c59c443 100644 --- a/clients/client-python/gravitino/exceptions/base.py +++ b/clients/client-python/gravitino/exceptions/base.py @@ -217,6 +217,10 @@ class NoSuchTableException(NotFoundException): """An exception thrown when a table with specified name is not existed.""" +class NoSuchViewException(NotFoundException): + """An exception thrown when a view with specified name is not found.""" + + class NoSuchPartitionException(NotFoundException): """An exception thrown when a partition with specified name is not existed.""" @@ -229,6 +233,10 @@ class TableAlreadyExistsException(AlreadyExistsException): """An exception thrown when a table already exists.""" +class ViewAlreadyExistsException(AlreadyExistsException): + """An exception thrown when a view already exists.""" + + class NoSuchFunctionException(NotFoundException): """An exception thrown when a function with specified name is not found.""" diff --git a/clients/client-python/tests/unittests/api/rel/test_sql_representation.py b/clients/client-python/tests/unittests/api/rel/test_sql_representation.py new file mode 100644 index 0000000000..af7c64fb2e --- /dev/null +++ b/clients/client-python/tests/unittests/api/rel/test_sql_representation.py @@ -0,0 +1,54 @@ +# 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 gravitino.api.rel.dialects import Dialects +from gravitino.api.rel.representation import Representation +from gravitino.api.rel.sql_representation import SQLRepresentation + + +class TestSQLRepresentation(unittest.TestCase): + def test_predefined_dialects(self): + self.assertEqual("trino", Dialects.TRINO) + self.assertEqual("spark", Dialects.SPARK) + self.assertEqual("hive", Dialects.HIVE) + self.assertEqual("flink", Dialects.FLINK) + + def test_sql_representation(self): + representation = SQLRepresentation(Dialects.TRINO, "SELECT 1") + + self.assertEqual(Representation.TYPE_SQL, representation.type()) + self.assertEqual(Dialects.TRINO, representation.dialect()) + self.assertEqual("SELECT 1", representation.sql()) + + def test_sql_representation_equal_and_hash(self): + representation = SQLRepresentation(Dialects.TRINO, "SELECT 1") + equal_representation = SQLRepresentation(Dialects.TRINO, "SELECT 1") + + self.assertEqual(representation, equal_representation) + self.assertEqual(hash(representation), hash(equal_representation)) + self.assertNotEqual( + representation, SQLRepresentation(Dialects.SPARK, "SELECT 1") + ) + self.assertNotEqual(representation, "invalid_representation") + + def test_sql_representation_validation(self): + with self.assertRaisesRegex(ValueError, "dialect must not be null or empty"): + SQLRepresentation("", "SELECT 1") + with self.assertRaisesRegex(ValueError, "sql must not be null or empty"): + SQLRepresentation(Dialects.TRINO, "") diff --git a/clients/client-python/tests/unittests/api/rel/test_view.py b/clients/client-python/tests/unittests/api/rel/test_view.py new file mode 100644 index 0000000000..19fa1769d7 --- /dev/null +++ b/clients/client-python/tests/unittests/api/rel/test_view.py @@ -0,0 +1,69 @@ +# 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 Mock + +from gravitino.api.rel.dialects import Dialects +from gravitino.api.rel.representation import Representation +from gravitino.api.rel.sql_representation import SQLRepresentation +from gravitino.api.rel.view import View + + +class TestView(unittest.TestCase): + def test_sql_for_returns_matching_representation(self): + trino_representation = SQLRepresentation(Dialects.TRINO, "SELECT 1") + spark_representation = SQLRepresentation(Dialects.SPARK, "SELECT 2") + view = Mock(spec=View) + view.representations.return_value = [ + trino_representation, + spark_representation, + ] + + self.assertEqual( + trino_representation, + View.sql_for(view, Dialects.TRINO.upper()), + ) + + def test_sql_for_returns_none_when_dialect_is_missing(self): + view = Mock(spec=View) + view.representations.return_value = [ + SQLRepresentation(Dialects.SPARK, "SELECT 1") + ] + + self.assertIsNone(View.sql_for(view, Dialects.TRINO)) + + def test_sql_for_returns_none_when_dialect_is_none(self): + view = Mock(spec=View) + + self.assertIsNone(View.sql_for(view, None)) + view.representations.assert_not_called() + + def test_sql_for_ignores_non_sql_representations(self): + representation = Mock(spec=Representation) + view = Mock(spec=View) + view.representations.return_value = [representation] + + self.assertIsNone(View.sql_for(view, Dialects.TRINO)) + + def test_default_values(self): + view = Mock(spec=View) + + self.assertIsNone(View.comment(view)) + self.assertIsNone(View.default_catalog(view)) + self.assertIsNone(View.default_schema(view)) + self.assertEqual({}, View.properties(view)) diff --git a/clients/client-python/tests/unittests/api/rel/test_view_change.py b/clients/client-python/tests/unittests/api/rel/test_view_change.py new file mode 100644 index 0000000000..77eda67ed7 --- /dev/null +++ b/clients/client-python/tests/unittests/api/rel/test_view_change.py @@ -0,0 +1,150 @@ +# 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 gravitino.api.rel.column import Column +from gravitino.api.rel.dialects import Dialects +from gravitino.api.rel.sql_representation import SQLRepresentation +from gravitino.api.rel.types.types import Types +from gravitino.api.rel.view_change import ( + RemoveProperty, + RenameView, + ReplaceView, + SetProperty, + ViewChange, +) + + +class TestViewChange(unittest.TestCase): + def test_rename_view(self): + change = ViewChange.rename("new_view") + equal_change = ViewChange.rename("new_view") + + self.assertIsInstance(change, RenameView) + self.assertEqual("new_view", change.new_name()) + self.assertEqual("RENAMEVIEW new_view", str(change)) + self.assertEqual(change, equal_change) + self.assertEqual(hash(change), hash(equal_change)) + self.assertNotEqual(change, ViewChange.rename("another_view")) + self.assertNotEqual(change, "invalid_change") + + with self.assertRaisesRegex(ValueError, "newName must not be null or empty"): + ViewChange.rename("") + + def test_set_property(self): + change = ViewChange.set_property("key", "value") + equal_change = ViewChange.set_property("key", "value") + + self.assertIsInstance(change, SetProperty) + self.assertEqual("key", change.property()) + self.assertEqual("value", change.value()) + self.assertEqual("SETPROPERTY key value", str(change)) + self.assertEqual(change, equal_change) + self.assertEqual(hash(change), hash(equal_change)) + self.assertNotEqual(change, ViewChange.set_property("key", "another_value")) + self.assertNotEqual(change, "invalid_change") + + with self.assertRaisesRegex(ValueError, "property must not be null or empty"): + ViewChange.set_property("", "value") + + def test_remove_property(self): + change = ViewChange.remove_property("key") + equal_change = ViewChange.remove_property("key") + + self.assertIsInstance(change, RemoveProperty) + self.assertEqual("key", change.property()) + self.assertEqual("REMOVEPROPERTY key", str(change)) + self.assertEqual(change, equal_change) + self.assertEqual(hash(change), hash(equal_change)) + self.assertNotEqual(change, ViewChange.remove_property("another_key")) + self.assertNotEqual(change, "invalid_change") + + with self.assertRaisesRegex(ValueError, "property must not be null or empty"): + ViewChange.remove_property("") + + def test_replace_view(self): + columns = [Column.of("id", Types.IntegerType.get())] + representations = [SQLRepresentation(Dialects.TRINO, "SELECT id FROM tbl")] + change = ViewChange.replace_view( + columns, + representations, + default_catalog="catalog", + default_schema="schema", + comment="comment", + ) + equal_change = ViewChange.replace_view( + [Column.of("id", Types.IntegerType.get())], + [SQLRepresentation(Dialects.TRINO, "SELECT id FROM tbl")], + "catalog", + "schema", + "comment", + ) + + self.assertIsInstance(change, ReplaceView) + self.assertEqual(columns, change.columns()) + self.assertEqual(representations, change.representations()) + self.assertEqual("catalog", change.default_catalog()) + self.assertEqual("schema", change.default_schema()) + self.assertEqual("comment", change.comment()) + self.assertEqual( + f"REPLACEVIEW columns={columns}, representations={representations}, " + "defaultCatalog=catalog, defaultSchema=schema, comment=comment", + str(change), + ) + self.assertEqual(change, equal_change) + self.assertEqual(hash(change), hash(equal_change)) + self.assertNotEqual( + change, + ViewChange.replace_view( + columns, representations, "another_catalog", "schema", "comment" + ), + ) + self.assertNotEqual(change, "invalid_change") + + def test_replace_view_defensively_copies_lists(self): + columns = [Column.of("id", Types.IntegerType.get())] + representations = [SQLRepresentation(Dialects.TRINO, "SELECT id FROM tbl")] + change = ViewChange.replace_view(columns, representations) + original_hash = hash(change) + + columns[0] = Column.of("name", Types.StringType.get()) + representations[0] = SQLRepresentation(Dialects.SPARK, "SELECT name FROM tbl") + + self.assertEqual("id", change.columns()[0].name()) + self.assertEqual(Dialects.TRINO, change.representations()[0].dialect()) + + returned_columns = change.columns() + returned_representations = change.representations() + returned_columns[0] = Column.of("age", Types.IntegerType.get()) + returned_representations[0] = SQLRepresentation( + Dialects.HIVE, "SELECT age FROM tbl" + ) + + self.assertEqual("id", change.columns()[0].name()) + self.assertEqual(Dialects.TRINO, change.representations()[0].dialect()) + self.assertEqual(original_hash, hash(change)) + + def test_replace_view_validation(self): + representations = [SQLRepresentation(Dialects.TRINO, "SELECT 1")] + + with self.assertRaisesRegex(ValueError, "columns must not be null"): + ViewChange.replace_view(None, representations) + with self.assertRaisesRegex( + ValueError, "representations must not be null or empty" + ): + ViewChange.replace_view([], [])
