This is an automated email from the ASF dual-hosted git repository. jerryshao pushed a commit to branch branch-1.3 in repository https://gitbox.apache.org/repos/asf/gravitino.git
commit 561bb566b6ab951de66d467932cc4786334ba920 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. (cherry picked from commit 941f030f3e0f624351fda392f39fd969c081c893) --- .../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 abb80509b9..5cfe5528b7 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 @@ -48,6 +57,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, @@ -185,6 +196,30 @@ class TestRelationalCatalog(unittest.TestCase): } """ 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) @@ -199,6 +234,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()) @@ -452,3 +491,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)
