This is an automated email from the ASF dual-hosted git repository.
mchades pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new c10925adb7 [#12039] feat(client-python): Add view API definitions
(#12040)
c10925adb7 is described below
commit c10925adb789d5f9c1ed9519faad4bcfc46bc15a
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.
---
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([], [])