This is an automated email from the ASF dual-hosted git repository.

jerryshao 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 941f030f3e [#12116] improvement(client-python): Add view create/drop 
operations (#12117)
941f030f3e is described below

commit 941f030f3e0f624351fda392f39fd969c081c893
Author: Zhiguo Wu <[email protected]>
AuthorDate: Wed Jul 22 12:53:39 2026 +0800

    [#12116] improvement(client-python): Add view create/drop operations 
(#12117)
    
    ### What changes were proposed in this pull request?
    
    - Extend `RelationalCatalog` with `ViewCatalog`.
    - Add `as_view_catalog`, `create_view`, and `drop_view`.
    - Add request and response DTOs for view creation.
    - Add view-specific REST error handling.
    - Generalize the entity namespace helper for table and view operations.
    - Add corresponding unit and integration tests.
    
    ### Why are the changes needed?
    
    The Python client currently lacks support for view create/drop
    operations. These changes allow users to create and drop views through
    the Python client.
    
    Fix: #12116
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Python client users can now create and drop views through
    `RelationalCatalog.as_view_catalog()`.
    
    ### How was this patch tested?
    
    Added unit tests for request and response validation, REST error
    handling, and view create/drop operations.
    
    Added integration tests covering view creation, duplicate creation, view
    deletion, and deletion of nonexistent views.
---
 .../gravitino/client/relational_catalog.py         | 151 ++++++++++++++++++---
 .../gravitino/dto/requests/view_create_request.py  |  96 +++++++++++++
 .../gravitino/dto/responses/view_response.py       |  49 +++++++
 .../exceptions/handlers/view_error_handler.py      |  78 +++++++++++
 .../tests/integration/test_relational_catalog.py   |  86 ++++++++++++
 .../dto/requests/test_view_create_request.py       | 144 ++++++++++++++++++++
 .../unittests/dto/responses/test_view_response.py  |  51 +++++++
 .../tests/unittests/test_error_handler.py          |  83 +++++++++++
 .../tests/unittests/test_relational_catalog.py     |  93 +++++++++++++
 9 files changed, 811 insertions(+), 20 deletions(-)

diff --git a/clients/client-python/gravitino/client/relational_catalog.py 
b/clients/client-python/gravitino/client/relational_catalog.py
index 40fbc9914c..26562e1e9a 100644
--- a/clients/client-python/gravitino/client/relational_catalog.py
+++ b/clients/client-python/gravitino/client/relational_catalog.py
@@ -24,19 +24,28 @@ from 
gravitino.api.rel.expressions.distributions.distribution import Distributio
 from gravitino.api.rel.expressions.sorts.sort_order import SortOrder
 from gravitino.api.rel.expressions.transforms.transform import Transform
 from gravitino.api.rel.indexes.index import Index
+from gravitino.api.rel.representation import Representation
 from gravitino.api.rel.table import Table
 from gravitino.api.rel.table_catalog import TableCatalog
+from gravitino.api.rel.view import View
+from gravitino.api.rel.view_catalog import ViewCatalog
+from gravitino.api.rel.view_change import ViewChange
 from gravitino.client.base_schema_catalog import BaseSchemaCatalog
+from gravitino.client.generic_view import GenericView
 from gravitino.client.relational_table import RelationalTable
 from gravitino.dto.audit_dto import AuditDTO
 from gravitino.dto.rel.distribution_dto import DistributionDTO
 from gravitino.dto.requests.table_create_request import TableCreateRequest
 from gravitino.dto.requests.table_updates_request import TableUpdatesRequest
+from gravitino.dto.requests.view_create_request import ViewCreateRequest
 from gravitino.dto.responses.drop_response import DropResponse
 from gravitino.dto.responses.entity_list_response import EntityListResponse
 from gravitino.dto.responses.table_response import TableResponse
+from gravitino.dto.responses.view_response import ViewResponse
 from gravitino.dto.util.dto_converters import DTOConverters
+from gravitino.exceptions.base import UnsupportedOperationException
 from gravitino.exceptions.handlers.table_error_handler import 
TABLE_ERROR_HANDLER
+from gravitino.exceptions.handlers.view_error_handler import VIEW_ERROR_HANDLER
 from gravitino.name_identifier import NameIdentifier
 from gravitino.namespace import Namespace
 from gravitino.rest.rest_utils import encode_string
@@ -44,13 +53,13 @@ from gravitino.utils import HTTPClient
 
 
 class RelationalCatalog(
-    BaseSchemaCatalog, TableCatalog
+    BaseSchemaCatalog, TableCatalog, ViewCatalog
 ):  # pylint: disable=too-many-ancestors
     """Relational catalog is a catalog implementation
 
-    The `RelationalCatalog` supports relational database like metadata 
operations,
-    for example, schemas and tables list, creation, update and deletion. A 
Relational
-    catalog is under the metalake.
+    The `RelationalCatalog` supports relational metadata operations such as 
listing,
+    creating, updating, and deleting schemas, tables, and views. A relational 
catalog
+    belongs to a metalake.
     """
 
     PRIVILEGES: Final[str] = "privileges"
@@ -88,6 +97,32 @@ class RelationalCatalog(
         """
         return self
 
+    def as_view_catalog(self) -> ViewCatalog:
+        """Return this relational catalog as a :class:`ViewCatalog`.
+
+        This method returns ``self`` to provide access to view-related
+        operations defined by the :class:`ViewCatalog` interface.
+
+        Returns:
+            ViewCatalog: The current catalog instance as a ``ViewCatalog``.
+        """
+        return self
+
+    def _get_entity_full_namespace(self, entity_namespace: Namespace) -> 
Namespace:
+        """Get the full namespace of an entity with the given short namespace.
+
+        Args:
+            entity_namespace (Namespace): The entity's short namespace, which 
is the schema name.
+
+        Returns:
+            Namespace: full namespace of the entity, which is 
"metalake.catalog.schema" format.
+        """
+        return Namespace.of(
+            self._catalog_namespace.level(0),
+            self._name,
+            entity_namespace.level(0),
+        )
+
     def _check_table_name_identifier(self, identifier: NameIdentifier) -> None:
         """Check whether the `NameIdentifier` of a table is valid.
 
@@ -119,27 +154,51 @@ class RelationalCatalog(
             f"Table namespace must be non-null and have 1 level, the input 
namespace is {namespace}",
         )
 
-    def _get_table_full_namespace(self, table_namespace: Namespace) -> 
Namespace:
-        """Get the full namespace of the table with the given table's short 
namespace (schema name).
+    def _format_table_request_path(self, ns: Namespace) -> str:
+        schema_ns = Namespace.of(ns.level(0), ns.level(1))
+        return (
+            f"{BaseSchemaCatalog.format_schema_request_path(schema_ns)}"
+            f"/{encode_string(ns.level(2))}"
+            "/tables"
+        )
+
+    def _check_view_name_identifier(self, identifier: NameIdentifier) -> None:
+        """Check whether the `NameIdentifier` of a view is valid.
 
         Args:
-            table_namespace (Namespace): The table's short namespace, which is 
the schema name.
+            identifier (NameIdentifier):
+                The NameIdentifier to check, which should be "schema.view" 
format.
 
-        Returns:
-            Namespace: full namespace of the table, which is 
"metalake.catalog.schema" format.
+        Raises:
+            IllegalNameIdentifierException: If the Namespace is not valid.
         """
-        return Namespace.of(
-            self._catalog_namespace.level(0),
-            self._name,
-            table_namespace.level(0),
+        NameIdentifier.check(identifier is not None, "NameIdentifier must not 
be null")
+        NameIdentifier.check(
+            identifier.name() is not None and identifier.name() != "",
+            "NameIdentifier name must not be empty",
         )
+        self._check_view_namespace(identifier.namespace())
 
-    def _format_table_request_path(self, ns: Namespace) -> str:
+    def _check_view_namespace(self, namespace: Namespace) -> None:
+        """Check whether the namespace of a view is valid, which should be 
"schema".
+
+        Args:
+            namespace (Namespace): The namespace to check.
+
+        Raises:
+            IllegalNamespaceException: If the Namespace is not valid.
+        """
+        Namespace.check(
+            namespace is not None and namespace.length() == 1,
+            f"View namespace must be non-null and have 1 level, the input 
namespace is {namespace}",
+        )
+
+    def _format_view_request_path(self, ns: Namespace) -> str:
         schema_ns = Namespace.of(ns.level(0), ns.level(1))
         return (
             f"{BaseSchemaCatalog.format_schema_request_path(schema_ns)}"
             f"/{encode_string(ns.level(2))}"
-            "/tables"
+            "/views"
         )
 
     def create_table(
@@ -169,7 +228,7 @@ class RelationalCatalog(
             _indexes=DTOConverters.to_dtos(indexes),
         )
         req.validate()
-        full_namespace = self._get_table_full_namespace(identifier.namespace())
+        full_namespace = 
self._get_entity_full_namespace(identifier.namespace())
         resp = self.rest_client.post(
             self._format_table_request_path(full_namespace),
             json=req,
@@ -181,7 +240,7 @@ class RelationalCatalog(
 
     def list_tables(self, namespace: Namespace) -> list[NameIdentifier]:
         self._check_table_namespace(namespace)
-        full_namespace = self._get_table_full_namespace(namespace)
+        full_namespace = self._get_entity_full_namespace(namespace)
         resp = self.rest_client.get(
             self._format_table_request_path(full_namespace),
             error_handler=TABLE_ERROR_HANDLER,
@@ -207,7 +266,7 @@ class RelationalCatalog(
         required_privilege_names: Optional[set[Privilege.Name]] = None,
     ) -> Table:
         self._check_table_name_identifier(identifier)
-        full_namespace = self._get_table_full_namespace(identifier.namespace())
+        full_namespace = 
self._get_entity_full_namespace(identifier.namespace())
         query_params = (
             {
                 RelationalCatalog.PRIVILEGES: ",".join(
@@ -244,7 +303,7 @@ class RelationalCatalog(
 
     def alter_table(self, identifier: NameIdentifier, *changes) -> Table:
         self._check_table_name_identifier(identifier)
-        full_namespace = self._get_table_full_namespace(identifier.namespace())
+        full_namespace = 
self._get_entity_full_namespace(identifier.namespace())
         updates_request = TableUpdatesRequest(
             updates=[
                 DTOConverters.to_table_update_request(change) for change in 
changes
@@ -276,7 +335,7 @@ class RelationalCatalog(
 
     def _drop_table(self, identifier: NameIdentifier, purge: bool) -> bool:
         self._check_table_name_identifier(identifier)
-        full_namespace = self._get_table_full_namespace(identifier.namespace())
+        full_namespace = 
self._get_entity_full_namespace(identifier.namespace())
         resp = self.rest_client.delete(
             f"{self._format_table_request_path(full_namespace)}"
             f"/{encode_string(identifier.name())}",
@@ -286,3 +345,55 @@ class RelationalCatalog(
         drop_resp = DropResponse.from_json(resp.body, infer_missing=True)
         drop_resp.validate()
         return drop_resp.dropped()
+
+    def list_views(self, namespace: Namespace) -> list[NameIdentifier]:
+        raise UnsupportedOperationException("Listing views is not supported")
+
+    def load_view(self, identifier: NameIdentifier) -> View:
+        raise UnsupportedOperationException("Loading views is not supported")
+
+    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:
+        self._check_view_name_identifier(identifier)
+        req = ViewCreateRequest(
+            _name=identifier.name(),
+            _columns=DTOConverters.to_dtos(columns),
+            _representations=DTOConverters.to_dtos(representations),
+            _comment=comment,
+            _default_catalog=default_catalog,
+            _default_schema=default_schema,
+            _properties=properties,
+        )
+        req.validate()
+        full_namespace = 
self._get_entity_full_namespace(identifier.namespace())
+        resp = self.rest_client.post(
+            self._format_view_request_path(full_namespace),
+            json=req,
+            error_handler=VIEW_ERROR_HANDLER,
+        )
+        view_resp = ViewResponse.from_json(resp.body, infer_missing=True)
+        view_resp.validate()
+        return GenericView(view_resp.view())
+
+    def alter_view(self, identifier: NameIdentifier, *changes: ViewChange) -> 
View:
+        raise UnsupportedOperationException("View alteration is not supported")
+
+    def drop_view(self, identifier: NameIdentifier) -> bool:
+        self._check_view_name_identifier(identifier)
+        full_namespace = 
self._get_entity_full_namespace(identifier.namespace())
+        resp = self.rest_client.delete(
+            f"{self._format_view_request_path(full_namespace)}"
+            f"/{encode_string(identifier.name())}",
+            error_handler=VIEW_ERROR_HANDLER,
+        )
+        drop_resp = DropResponse.from_json(resp.body, infer_missing=True)
+        drop_resp.validate()
+        return drop_resp.dropped()
diff --git 
a/clients/client-python/gravitino/dto/requests/view_create_request.py 
b/clients/client-python/gravitino/dto/requests/view_create_request.py
new file mode 100644
index 0000000000..700ac5b9a6
--- /dev/null
+++ b/clients/client-python/gravitino/dto/requests/view_create_request.py
@@ -0,0 +1,96 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config
+
+from gravitino.dto.rel.column_dto import ColumnDTO
+from gravitino.dto.rel.json_serdes.representation_serdes import 
RepresentationSerdes
+from gravitino.dto.rel.representation_dto import RepresentationDTO
+from gravitino.dto.rel.sql_representation_dto import SQLRepresentationDTO
+from gravitino.rest.rest_message import RESTRequest
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass
+class ViewCreateRequest(RESTRequest):
+    """Represents a request to create a view."""
+
+    _name: str = field(metadata=config(field_name="name"))
+    _columns: Optional[list[ColumnDTO]] = field(
+        default=None, metadata=config(field_name="columns")
+    )
+    _representations: Optional[list[RepresentationDTO]] = field(
+        default=None,
+        metadata=config(
+            field_name="representations",
+            encoder=lambda items: [
+                RepresentationSerdes.serialize(item) for item in items
+            ],
+            decoder=lambda values: [
+                RepresentationSerdes.deserialize(value) for value in values
+            ],
+            exclude=lambda value: value is None,
+        ),
+    )
+    _comment: Optional[str] = field(default=None, 
metadata=config(field_name="comment"))
+    _default_catalog: Optional[str] = field(
+        default=None, metadata=config(field_name="defaultCatalog")
+    )
+    _default_schema: Optional[str] = field(
+        default=None, metadata=config(field_name="defaultSchema")
+    )
+    _properties: Optional[dict[str, str]] = field(
+        default=None, metadata=config(field_name="properties")
+    )
+
+    def validate(self) -> None:
+        Precondition.check_string_not_empty(
+            self._name, '"name" field is required and cannot be empty'
+        )
+        Precondition.check_argument(
+            self._representations is not None and len(self._representations) > 
0,
+            '"representations" field is required and cannot be empty',
+        )
+        for representation in self._representations:
+            Precondition.check_argument(
+                representation is not None, "representation must not be null"
+            )
+            representation.validate()
+        if self._columns:
+            for column in self._columns:
+                Precondition.check_argument(
+                    column is not None, "column must not be null"
+                )
+                column.validate()
+        ViewCreateRequest.validate_no_duplicate_dialects(self._representations)
+
+    @staticmethod
+    def validate_no_duplicate_dialects(
+        representations: list[RepresentationDTO],
+    ) -> None:
+        """Validate that SQL representations do not use duplicate dialects."""
+        seen_dialects = set()
+        for representation in representations:
+            if isinstance(representation, SQLRepresentationDTO):
+                Precondition.check_argument(
+                    representation.dialect() not in seen_dialects,
+                    f"Duplicate SQL representation dialect: 
{representation.dialect()}",
+                )
+                seen_dialects.add(representation.dialect())
diff --git a/clients/client-python/gravitino/dto/responses/view_response.py 
b/clients/client-python/gravitino/dto/responses/view_response.py
new file mode 100644
index 0000000000..63d2d2ca22
--- /dev/null
+++ b/clients/client-python/gravitino/dto/responses/view_response.py
@@ -0,0 +1,49 @@
+# 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, field
+
+from dataclasses_json import config
+
+from gravitino.dto.rel.view_dto import ViewDTO
+from gravitino.dto.responses.base_response import BaseResponse
+from gravitino.exceptions.base import IllegalArgumentException
+
+
+@dataclass
+class ViewResponse(BaseResponse):
+    """Response object for view-related operations."""
+
+    _view: ViewDTO = field(metadata=config(field_name="view"))
+
+    def view(self) -> ViewDTO:
+        """Returns the view DTO."""
+        return self._view
+
+    def validate(self):
+        """Validates the response data."""
+        super().validate()
+        if self._view is None:
+            raise IllegalArgumentException("view must not be null")
+        if not self._view.name():
+            raise IllegalArgumentException("view 'name' must not be null or 
empty")
+        if self._view.audit_info() is None:
+            raise IllegalArgumentException("view 'audit' must not be null")
+        if not self._view.representations():
+            raise IllegalArgumentException(
+                "view 'representations' must not be null or empty"
+            )
diff --git 
a/clients/client-python/gravitino/exceptions/handlers/view_error_handler.py 
b/clients/client-python/gravitino/exceptions/handlers/view_error_handler.py
new file mode 100644
index 0000000000..674438c144
--- /dev/null
+++ b/clients/client-python/gravitino/exceptions/handlers/view_error_handler.py
@@ -0,0 +1,78 @@
+# 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 gravitino.constants.error import ErrorConstants
+from gravitino.dto.responses.error_response import ErrorResponse
+from gravitino.exceptions.base import (
+    CatalogNotInUseException,
+    ForbiddenException,
+    IllegalArgumentException,
+    MetalakeNotInUseException,
+    NoSuchCatalogException,
+    NoSuchSchemaException,
+    NoSuchViewException,
+    NotFoundException,
+    NotInUseException,
+    UnsupportedOperationException,
+    ViewAlreadyExistsException,
+)
+from gravitino.exceptions.handlers.rest_error_handler import RestErrorHandler
+
+
+class ViewErrorHandler(RestErrorHandler):
+    """Error handler for view operations."""
+
+    def handle(self, error_response: ErrorResponse):
+        error_message = error_response.format_error_message()
+        code = ErrorConstants(error_response.code())
+        exception_type = error_response.type()
+
+        if code is ErrorConstants.ILLEGAL_ARGUMENTS_CODE:
+            raise IllegalArgumentException(error_message)
+
+        if code is ErrorConstants.NOT_FOUND_CODE:
+            if exception_type == NoSuchCatalogException.__name__:
+                raise NoSuchCatalogException(error_message)
+            if exception_type == NoSuchSchemaException.__name__:
+                raise NoSuchSchemaException(error_message)
+            if exception_type == NoSuchViewException.__name__:
+                raise NoSuchViewException(error_message)
+            raise NotFoundException(error_message)
+
+        if code is ErrorConstants.ALREADY_EXISTS_CODE:
+            raise ViewAlreadyExistsException(error_message)
+
+        if code is ErrorConstants.INTERNAL_ERROR_CODE:
+            raise RuntimeError(error_message)
+
+        if code is ErrorConstants.UNSUPPORTED_OPERATION_CODE:
+            raise UnsupportedOperationException(error_message)
+
+        if code is ErrorConstants.FORBIDDEN_CODE:
+            raise ForbiddenException(error_message)
+
+        if code is ErrorConstants.NOT_IN_USE_CODE:
+            if exception_type == CatalogNotInUseException.__name__:
+                raise CatalogNotInUseException(error_message)
+            if exception_type == MetalakeNotInUseException.__name__:
+                raise MetalakeNotInUseException(error_message)
+            raise NotInUseException(error_message)
+
+        super().handle(error_response)
+
+
+VIEW_ERROR_HANDLER = ViewErrorHandler()
diff --git a/clients/client-python/tests/integration/test_relational_catalog.py 
b/clients/client-python/tests/integration/test_relational_catalog.py
index 0dd75eb838..1723025514 100644
--- a/clients/client-python/tests/integration/test_relational_catalog.py
+++ b/clients/client-python/tests/integration/test_relational_catalog.py
@@ -17,6 +17,7 @@
 
 import logging
 from random import randint
+from uuid import uuid4
 
 from gravitino import (
     Catalog,
@@ -25,17 +26,21 @@ from gravitino import (
     NameIdentifier,
 )
 from gravitino.api.rel.column import Column
+from gravitino.api.rel.dialects import Dialects
 from gravitino.api.rel.expressions.distributions.distributions import 
Distributions
 from gravitino.api.rel.expressions.transforms.transforms import Transforms
 from gravitino.api.rel.indexes.indexes import Indexes
+from gravitino.api.rel.sql_representation import SQLRepresentation
 from gravitino.api.rel.table import Table
 from gravitino.api.rel.table_change import TableChange
 from gravitino.api.rel.types.types import Types
+from gravitino.api.rel.view import View
 from gravitino.client.relational_table import RelationalTable
 from gravitino.exceptions.base import (
     NoSuchSchemaException,
     NoSuchTableException,
     TableAlreadyExistsException,
+    ViewAlreadyExistsException,
 )
 from gravitino.namespace import Namespace
 from tests.integration.containers.hdfs_container import HDFSContainer
@@ -53,6 +58,8 @@ class TestRelationalCatalog(IntegrationTestEnv):
     TABLE_IDENT: NameIdentifier = NameIdentifier.of(SCHEMA_NAME, TABLE_NAME)
     TABLE_COMMENT: str = "Test table for relational catalog"
     TABLE_PROPERTIES = {"property1": "value1", "property2": "value2"}
+    VIEW_COMMENT: str = "Test view for relational catalog"
+    VIEW_PROPERTIES = {"view_property1": "value1", "view_property2": "value2"}
 
     @classmethod
     def setUpClass(cls):
@@ -94,6 +101,10 @@ class TestRelationalCatalog(IntegrationTestEnv):
         super().tearDownClass()
 
     def setUp(self):
+        self.view_name = f"test_view_{uuid4().hex}"
+        self.view_ident = NameIdentifier.of(
+            TestRelationalCatalog.SCHEMA_NAME, self.view_name
+        )
         # Create schema for each test
         TestRelationalCatalog.schema = (
             TestRelationalCatalog.catalog.as_schemas().create_schema(
@@ -137,6 +148,28 @@ class TestRelationalCatalog(IntegrationTestEnv):
             indexes=Indexes.EMPTY_INDEXES,
         )
 
+    def _create_test_view(self) -> View:
+        """Create a test view with basic columns."""
+
+        view_catalog = self.catalog.as_view_catalog()
+        columns = [
+            Column.of("id", Types.LongType.get(), "Primary key"),
+            Column.of("name", Types.StringType.get(), "Name column"),
+        ]
+
+        return view_catalog.create_view(
+            identifier=self.view_ident,
+            columns=columns,
+            representations=[
+                SQLRepresentation(
+                    Dialects.HIVE,
+                    f"SELECT id, name FROM {TestRelationalCatalog.TABLE_NAME}",
+                )
+            ],
+            comment=TestRelationalCatalog.VIEW_COMMENT,
+            properties=TestRelationalCatalog.VIEW_PROPERTIES,
+        )
+
     def test_relational_catalog_create_table(self):
         """Test creating a table in the relational catalog."""
         table = self._create_test_table()
@@ -311,3 +344,56 @@ class TestRelationalCatalog(IntegrationTestEnv):
             new_property_value,
         )
         self.assertNotIn("property2", altered_table.properties())
+
+    def test_relational_catalog_create_view(self):
+        """Test creating a view in the relational catalog."""
+        self._create_test_table()
+        view = self._create_test_view()
+        self.assertIsNotNone(view)
+        self.assertEqual(view.name(), self.view_name)
+        self.assertEqual(view.comment(), TestRelationalCatalog.VIEW_COMMENT)
+        self.assertEqual(view.properties().get("view_property1"), "value1")
+        self.assertEqual(view.properties().get("view_property2"), "value2")
+        self.assertEqual(len(view.columns()), 2)
+        self.assertEqual(view.columns()[0].name(), "id")
+        self.assertEqual(view.columns()[0].data_type(), Types.LongType.get())
+        self.assertEqual(view.columns()[1].name(), "name")
+        self.assertEqual(view.columns()[1].data_type(), Types.StringType.get())
+        self.assertEqual(
+            view.sql_for(Dialects.HIVE).sql(),
+            f"SELECT id, name FROM {TestRelationalCatalog.TABLE_NAME}",
+        )
+
+    def test_relational_catalog_create_view_already_exists(self):
+        """Test creating a view that already exists should raise exception."""
+        self._create_test_table()
+        self._create_test_view()
+        view_catalog = self.catalog.as_view_catalog()
+
+        with self.assertRaises(ViewAlreadyExistsException):
+            view_catalog.create_view(
+                identifier=self.view_ident,
+                columns=[Column.of("id", Types.LongType.get(), "Primary key")],
+                representations=[
+                    SQLRepresentation(
+                        Dialects.HIVE,
+                        f"SELECT id FROM {TestRelationalCatalog.TABLE_NAME}",
+                    )
+                ],
+            )
+
+    def test_relational_catalog_drop_view(self):
+        """Test dropping a view from the relational catalog."""
+        self._create_test_table()
+        self._create_test_view()
+        view_catalog = self.catalog.as_view_catalog()
+
+        is_dropped = view_catalog.drop_view(self.view_ident)
+        self.assertTrue(is_dropped)
+
+    def test_relational_catalog_drop_view_not_exists(self):
+        """Test dropping a view that doesn't exist should return False."""
+        view_catalog = self.catalog.as_view_catalog()
+
+        is_dropped = view_catalog.drop_view(self.view_ident)
+        self.assertFalse(is_dropped)
diff --git 
a/clients/client-python/tests/unittests/dto/requests/test_view_create_request.py
 
b/clients/client-python/tests/unittests/dto/requests/test_view_create_request.py
new file mode 100644
index 0000000000..358dc6c195
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/dto/requests/test_view_create_request.py
@@ -0,0 +1,144 @@
+# 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.types.types import Types
+from gravitino.dto.rel.column_dto import ColumnDTO
+from gravitino.dto.rel.sql_representation_dto import SQLRepresentationDTO
+from gravitino.dto.requests.view_create_request import ViewCreateRequest
+
+
+class TestViewCreateRequest(unittest.TestCase):
+    @staticmethod
+    def _request(
+        name: str = "test_view",
+        columns: list[ColumnDTO] = None,
+        representations: list[SQLRepresentationDTO] = None,
+    ) -> ViewCreateRequest:
+        return ViewCreateRequest(
+            _name=name,
+            _columns=(
+                columns
+                if columns is not None
+                else [
+                    ColumnDTO(
+                        _name="id",
+                        _data_type=Types.IntegerType.get(),
+                        _nullable=False,
+                    )
+                ]
+            ),
+            _representations=(
+                representations
+                if representations is not None
+                else [
+                    SQLRepresentationDTO(
+                        _dialect=Dialects.TRINO,
+                        _sql="SELECT id FROM test_table",
+                    )
+                ]
+            ),
+            _comment="comment",
+            _default_catalog="catalog",
+            _default_schema="schema",
+            _properties={"k1": "v1"},
+        )
+
+    def test_validate_and_serialize(self):
+        request = self._request()
+
+        request.validate()
+        json_str = request.to_json()
+        deserialized = ViewCreateRequest.from_json(json_str)
+        deserialized.validate()
+
+        self.assertEqual(request, deserialized)
+        self.assertIn('"name": "test_view"', json_str)
+        self.assertIn('"columns"', json_str)
+        self.assertIn('"representations"', json_str)
+        self.assertIn(f'"type": "{Representation.TYPE_SQL}"', json_str)
+        self.assertIn(f'"dialect": "{Dialects.TRINO}"', json_str)
+        self.assertIn('"sql": "SELECT id FROM test_table"', json_str)
+        self.assertIn('"defaultCatalog": "catalog"', json_str)
+        self.assertIn('"defaultSchema": "schema"', json_str)
+
+    def test_validate_rejects_invalid_name(self):
+        request = self._request(name="")
+
+        with self.assertRaises(ValueError):
+            request.validate()
+
+    def test_validate_rejects_empty_representations(self):
+        request = self._request(representations=[])
+
+        with self.assertRaises(ValueError):
+            request.validate()
+
+    def test_serialize_excludes_missing_representations(self):
+        request = ViewCreateRequest(_name="test_view")
+
+        self.assertNotIn('"representations"', request.to_json())
+
+    def test_validate_rejects_missing_representations(self):
+        request = ViewCreateRequest(_name="test_view")
+
+        with self.assertRaisesRegex(
+            ValueError, '"representations" field is required and cannot be 
empty'
+        ):
+            request.validate()
+
+    def test_validate_rejects_null_representation(self):
+        request = self._request(representations=[None])
+
+        with self.assertRaises(ValueError):
+            request.validate()
+
+    def test_validate_rejects_invalid_representation(self):
+        request = self._request(
+            representations=[SQLRepresentationDTO(_dialect="", _sql="SELECT 
1")]
+        )
+
+        with self.assertRaises(ValueError):
+            request.validate()
+
+    def test_validate_rejects_null_column(self):
+        request = self._request(columns=[None])
+
+        with self.assertRaises(ValueError):
+            request.validate()
+
+    def test_validate_rejects_invalid_column(self):
+        request = self._request(
+            columns=[ColumnDTO(_name="", _data_type=Types.IntegerType.get())]
+        )
+
+        with self.assertRaises(ValueError):
+            request.validate()
+
+    def test_validate_rejects_duplicate_dialect(self):
+        request = self._request(
+            representations=[
+                SQLRepresentationDTO(_dialect=Dialects.TRINO, _sql="SELECT 1"),
+                SQLRepresentationDTO(_dialect=Dialects.TRINO, _sql="SELECT 2"),
+            ]
+        )
+
+        with self.assertRaises(ValueError):
+            request.validate()
diff --git 
a/clients/client-python/tests/unittests/dto/responses/test_view_response.py 
b/clients/client-python/tests/unittests/dto/responses/test_view_response.py
new file mode 100644
index 0000000000..210383c57b
--- /dev/null
+++ b/clients/client-python/tests/unittests/dto/responses/test_view_response.py
@@ -0,0 +1,51 @@
+# 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.dto.audit_dto import AuditDTO
+from gravitino.dto.rel.sql_representation_dto import SQLRepresentationDTO
+from gravitino.dto.rel.view_dto import ViewDTO
+from gravitino.dto.responses.view_response import ViewResponse
+from gravitino.exceptions.base import IllegalArgumentException
+
+
+class TestViewResponse(unittest.TestCase):
+    @staticmethod
+    def _view_dto() -> ViewDTO:
+        return ViewDTO(
+            _name="test_view",
+            _representations=[
+                SQLRepresentationDTO(_dialect=Dialects.TRINO, _sql="SELECT 1")
+            ],
+            _audit=AuditDTO("creator"),
+        )
+
+    def test_view_response(self):
+        response = ViewResponse(_code=0, _view=self._view_dto())
+
+        response.validate()
+        deserialized = ViewResponse.from_json(response.to_json())
+
+        self.assertEqual("test_view", deserialized.view().name())
+        self.assertEqual("creator", deserialized.view().audit_info().creator())
+        self.assertEqual(1, len(deserialized.view().representations()))
+
+    def test_view_response_validate(self):
+        with self.assertRaises(IllegalArgumentException):
+            ViewResponse(_code=0, _view=None).validate()
diff --git a/clients/client-python/tests/unittests/test_error_handler.py 
b/clients/client-python/tests/unittests/test_error_handler.py
index a7e52b443f..64c190e43d 100644
--- a/clients/client-python/tests/unittests/test_error_handler.py
+++ b/clients/client-python/tests/unittests/test_error_handler.py
@@ -43,6 +43,7 @@ from gravitino.exceptions.base import (
     NoSuchSchemaException,
     NoSuchTableException,
     NoSuchUserException,
+    NoSuchViewException,
     NotEmptyException,
     NotFoundException,
     NotInUseException,
@@ -53,6 +54,7 @@ from gravitino.exceptions.base import (
     TableAlreadyExistsException,
     UnsupportedOperationException,
     UserAlreadyExistsException,
+    ViewAlreadyExistsException,
     GroupAlreadyExistsException,
 )
 from gravitino.exceptions.handlers.catalog_error_handler import 
CATALOG_ERROR_HANDLER
@@ -73,6 +75,7 @@ from gravitino.exceptions.handlers.role_error_handler import 
ROLE_ERROR_HANDLER
 from gravitino.exceptions.handlers.schema_error_handler import 
SCHEMA_ERROR_HANDLER
 from gravitino.exceptions.handlers.table_error_handler import 
TABLE_ERROR_HANDLER
 from gravitino.exceptions.handlers.user_error_handler import USER_ERROR_HANDLER
+from gravitino.exceptions.handlers.view_error_handler import VIEW_ERROR_HANDLER
 
 
 class TestErrorHandler(unittest.TestCase):
@@ -435,6 +438,86 @@ class TestErrorHandler(unittest.TestCase):
                 ErrorResponse.generate_error_response(Exception, "mock error")
             )
 
+    def test_view_error_handler(self):
+        with self.assertRaises(IllegalArgumentException):
+            VIEW_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    IllegalArgumentException, "mock error"
+                )
+            )
+
+        with self.assertRaises(NoSuchCatalogException):
+            VIEW_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    NoSuchCatalogException, "mock error"
+                )
+            )
+
+        with self.assertRaises(NoSuchSchemaException):
+            VIEW_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    NoSuchSchemaException, "mock error"
+                )
+            )
+
+        with self.assertRaises(NoSuchViewException):
+            VIEW_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(NoSuchViewException, 
"mock error")
+            )
+
+        with self.assertRaises(NotFoundException):
+            VIEW_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(NotFoundException, "mock 
error")
+            )
+
+        with self.assertRaises(ViewAlreadyExistsException):
+            VIEW_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    ViewAlreadyExistsException, "mock error"
+                )
+            )
+
+        with self.assertRaises(RuntimeError):
+            VIEW_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(RuntimeError, "mock 
error")
+            )
+
+        with self.assertRaises(UnsupportedOperationException):
+            VIEW_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    UnsupportedOperationException, "mock error"
+                )
+            )
+
+        with self.assertRaises(ForbiddenException):
+            VIEW_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(ForbiddenException, 
"mock error")
+            )
+
+        with self.assertRaises(CatalogNotInUseException):
+            VIEW_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    CatalogNotInUseException, "mock error"
+                )
+            )
+
+        with self.assertRaises(MetalakeNotInUseException):
+            VIEW_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    MetalakeNotInUseException, "mock error"
+                )
+            )
+
+        with self.assertRaises(NotInUseException):
+            VIEW_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(NotInUseException, "mock 
error")
+            )
+
+        with self.assertRaises(RESTException):
+            VIEW_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(Exception, "mock error")
+            )
+
     def test_user_error_handler(self):
         with self.assertRaises(NoSuchMetalakeException):
             USER_ERROR_HANDLER.handle(
diff --git a/clients/client-python/tests/unittests/test_relational_catalog.py 
b/clients/client-python/tests/unittests/test_relational_catalog.py
index 4a9fbecc71..316f5f83df 100644
--- a/clients/client-python/tests/unittests/test_relational_catalog.py
+++ b/clients/client-python/tests/unittests/test_relational_catalog.py
@@ -20,19 +20,28 @@ from http.client import HTTPResponse
 from unittest.mock import Mock, patch
 
 from gravitino.api.authorization.privileges import Privilege
+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.table_change import TableChange
+from gravitino.api.rel.types.types import Types
 from gravitino.client.relational_catalog import RelationalCatalog
 from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.rel.column_dto import ColumnDTO
 from gravitino.dto.rel.distribution_dto import DistributionDTO
+from gravitino.dto.rel.sql_representation_dto import SQLRepresentationDTO
 from gravitino.dto.rel.table_dto import TableDTO
+from gravitino.dto.rel.view_dto import ViewDTO
 from gravitino.dto.responses.drop_response import DropResponse
 from gravitino.dto.responses.entity_list_response import EntityListResponse
 from gravitino.dto.responses.table_response import TableResponse
+from gravitino.dto.responses.view_response import ViewResponse
 from gravitino.dto.util.dto_converters import DTOConverters
 from gravitino.exceptions.base import (
     NoSuchSchemaException,
     NoSuchTableException,
     TableAlreadyExistsException,
+    ViewAlreadyExistsException,
 )
 from gravitino.name_identifier import NameIdentifier
 from gravitino.namespace import Namespace
@@ -51,6 +60,8 @@ class TestRelationalCatalog(unittest.TestCase):
         cls.table_name = "test_table"
         cls.catalog_namespace = Namespace.of(cls.metalake_name)
         cls.table_identifier = NameIdentifier.of(cls.schema_name, 
cls.table_name)
+        cls.view_name = "test_view"
+        cls.view_identifier = NameIdentifier.of(cls.schema_name, cls.view_name)
         cls.rest_client = HTTPClient("http://localhost:8090";)
         cls.catalog = RelationalCatalog(
             catalog_namespace=cls.catalog_namespace,
@@ -62,6 +73,30 @@ class TestRelationalCatalog(unittest.TestCase):
         )
         cls.TABLE_DTO_JSON_STRING = 
TABLE_DTO_JSON_STRING_WITH_STARTING_DATE_SORT
         cls.table_dto = TableDTO.from_json(cls.TABLE_DTO_JSON_STRING)
+        cls.view_dto = ViewDTO(
+            _name=cls.view_name,
+            _columns=[
+                ColumnDTO(
+                    _name="id",
+                    _data_type=Types.IntegerType.get(),
+                    _comment="id column",
+                    _nullable=False,
+                )
+            ],
+            _representations=[
+                SQLRepresentationDTO(
+                    _dialect=Dialects.TRINO,
+                    _sql="SELECT id FROM test_table",
+                )
+            ],
+            _comment="test view comment",
+            _default_catalog="test_catalog",
+            _default_schema="test_schema",
+            _properties={"k1": "v1"},
+            _audit=AuditDTO(
+                "creator", "2022-01-01T00:00:00Z", "modifier", 
"2022-01-01T00:00:00Z"
+            ),
+        )
 
     def _get_mock_http_resp(self, json_str: str, return_code: int = 200):
         mock_http_resp = Mock(HTTPResponse)
@@ -76,6 +111,10 @@ class TestRelationalCatalog(unittest.TestCase):
         table_catalog = self.catalog.as_table_catalog()
         self.assertIs(table_catalog, self.catalog)
 
+    def test_as_view_catalog(self):
+        view_catalog = self.catalog.as_view_catalog()
+        self.assertIs(view_catalog, self.catalog)
+
     def test_create_table(self):
         resp_body = TableResponse(0, self.table_dto)
         mock_resp = self._get_mock_http_resp(resp_body.to_json())
@@ -329,3 +368,57 @@ class TestRelationalCatalog(unittest.TestCase):
                 TableChange.update_column_nullability(["id"], nullable=True),
             )
             self.assertEqual(table.name(), self.table_dto.name())
+
+    def test_create_view(self):
+        resp_body = ViewResponse(0, self.view_dto)
+        mock_resp = self._get_mock_http_resp(resp_body.to_json())
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.post",
+            return_value=mock_resp,
+        ):
+            view = self.catalog.as_view_catalog().create_view(
+                self.view_identifier,
+                [Column.of("id", Types.IntegerType.get(), nullable=False)],
+                [SQLRepresentation(Dialects.TRINO, "SELECT id FROM 
test_table")],
+                comment="test view comment",
+                default_catalog="test_catalog",
+                default_schema="test_schema",
+                properties={"k1": "v1"},
+            )
+            self.assertEqual(self.view_name, view.name())
+            self.assertEqual("test view comment", view.comment())
+
+    def test_create_view_already_exists(self):
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.post",
+            side_effect=ViewAlreadyExistsException("View already exists"),
+        ):
+            with self.assertRaises(ViewAlreadyExistsException):
+                self.catalog.as_view_catalog().create_view(
+                    self.view_identifier,
+                    [Column.of("id", Types.IntegerType.get())],
+                    [SQLRepresentation(Dialects.TRINO, "SELECT id FROM 
test_table")],
+                )
+
+    def test_drop_view(self):
+        resp_body = DropResponse(0, True)
+        mock_resp = self._get_mock_http_resp(resp_body.to_json())
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.delete",
+            return_value=mock_resp,
+        ):
+            is_dropped = 
self.catalog.as_view_catalog().drop_view(self.view_identifier)
+            self.assertTrue(is_dropped)
+
+    def test_drop_view_not_exists(self):
+        resp_body = DropResponse(0, False)
+        mock_resp = self._get_mock_http_resp(resp_body.to_json())
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.delete",
+            return_value=mock_resp,
+        ):
+            is_dropped = 
self.catalog.as_view_catalog().drop_view(self.view_identifier)
+            self.assertFalse(is_dropped)


Reply via email to