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([], [])


Reply via email to