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 17fb105c566a139661ade15982924be417066d05 Author: Zhiguo Wu <[email protected]> AuthorDate: Fri Jul 24 11:13:54 2026 +0800 [#12158] improvement(client-python): Add view query/alter operations (#12159) ### What changes were proposed in this pull request? - Add view list, load, existence check, and alter operations to `RelationalCatalog`. - Add request DTOs for view updates. - Support rename, set/remove property, and replace view changes. - Add corresponding unit and integration tests. ### Why are the changes needed? The Python client currently supports view create/drop operations but lacks view query/alter operations. These changes complete the basic view management workflow through the Python client. Fix: #12158 ### Does this PR introduce _any_ user-facing change? Yes. Python client users can now list, load, check, and alter views through `RelationalCatalog.as_view_catalog()`. ### How was this patch tested? Added unit tests for view query operations, update request conversion, serialization, validation, and error handling. Added integration tests covering view listing, loading, existence checks, property updates, and related error cases. (cherry picked from commit 2214a29dcc3d6091b52f0ee4ad8eedee92615393) --- .../gravitino/client/relational_catalog.py | 83 ++++++++- .../gravitino/dto/requests/view_update_request.py | 166 +++++++++++++++++ .../gravitino/dto/requests/view_updates_request.py | 38 ++++ .../tests/integration/test_relational_catalog.py | 85 +++++++++ .../dto/requests/test_view_update_request.py | 197 +++++++++++++++++++++ .../tests/unittests/test_relational_catalog.py | 158 +++++++++++++++++ 6 files changed, 722 insertions(+), 5 deletions(-) diff --git a/clients/client-python/gravitino/client/relational_catalog.py b/clients/client-python/gravitino/client/relational_catalog.py index 26562e1e9a..d42af35804 100644 --- a/clients/client-python/gravitino/client/relational_catalog.py +++ b/clients/client-python/gravitino/client/relational_catalog.py @@ -29,7 +29,13 @@ 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.api.rel.view_change import ( + RemoveProperty, + RenameView, + ReplaceView, + SetProperty, + ViewChange, +) from gravitino.client.base_schema_catalog import BaseSchemaCatalog from gravitino.client.generic_view import GenericView from gravitino.client.relational_table import RelationalTable @@ -38,12 +44,17 @@ 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.requests.view_update_request import ( + ViewUpdateRequest, + ViewUpdateRequestBase, +) +from gravitino.dto.requests.view_updates_request import ViewUpdatesRequest 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.base import IllegalArgumentException 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 @@ -347,10 +358,30 @@ class RelationalCatalog( return drop_resp.dropped() def list_views(self, namespace: Namespace) -> list[NameIdentifier]: - raise UnsupportedOperationException("Listing views is not supported") + self._check_view_namespace(namespace) + full_namespace = self._get_entity_full_namespace(namespace) + resp = self.rest_client.get( + self._format_view_request_path(full_namespace), + error_handler=VIEW_ERROR_HANDLER, + ) + entity_list_resp = EntityListResponse.from_json(resp.body, infer_missing=True) + entity_list_resp.validate() + return [ + NameIdentifier.of(ident.namespace().level(2), ident.name()) + for ident in entity_list_resp.identifiers() + ] def load_view(self, identifier: NameIdentifier) -> View: - raise UnsupportedOperationException("Loading views is not supported") + self._check_view_name_identifier(identifier) + full_namespace = self._get_entity_full_namespace(identifier.namespace()) + resp = self.rest_client.get( + f"{self._format_view_request_path(full_namespace)}" + f"/{encode_string(identifier.name())}", + error_handler=VIEW_ERROR_HANDLER, + ) + view_resp = ViewResponse.from_json(resp.body, infer_missing=True) + view_resp.validate() + return GenericView(view_resp.view()) def create_view( self, @@ -384,7 +415,21 @@ class RelationalCatalog( return GenericView(view_resp.view()) def alter_view(self, identifier: NameIdentifier, *changes: ViewChange) -> View: - raise UnsupportedOperationException("View alteration is not supported") + self._check_view_name_identifier(identifier) + updates_request = ViewUpdatesRequest( + _updates=[self._to_view_update_request(change) for change in changes] + ) + updates_request.validate() + full_namespace = self._get_entity_full_namespace(identifier.namespace()) + resp = self.rest_client.put( + f"{self._format_view_request_path(full_namespace)}" + f"/{encode_string(identifier.name())}", + json=updates_request, + error_handler=VIEW_ERROR_HANDLER, + ) + view_resp = ViewResponse.from_json(resp.body, infer_missing=True) + view_resp.validate() + return GenericView(view_resp.view()) def drop_view(self, identifier: NameIdentifier) -> bool: self._check_view_name_identifier(identifier) @@ -397,3 +442,31 @@ class RelationalCatalog( drop_resp = DropResponse.from_json(resp.body, infer_missing=True) drop_resp.validate() return drop_resp.dropped() + + @staticmethod + def _to_view_update_request(change: ViewChange) -> ViewUpdateRequestBase: + if isinstance(change, RenameView): + return ViewUpdateRequest.RenameViewRequest(_new_name=change.new_name()) + + if isinstance(change, SetProperty): + return ViewUpdateRequest.SetViewPropertyRequest( + _property=change.property(), _value=change.value() + ) + + if isinstance(change, RemoveProperty): + return ViewUpdateRequest.RemoveViewPropertyRequest( + _property=change.property() + ) + + if isinstance(change, ReplaceView): + return ViewUpdateRequest.ReplaceViewRequest( + _columns=DTOConverters.to_dtos(change.columns()), + _representations=DTOConverters.to_dtos(change.representations()), + _default_catalog=change.default_catalog(), + _default_schema=change.default_schema(), + _comment=change.comment(), + ) + + raise IllegalArgumentException( + f"Unknown change type: {change.__class__.__name__}" + ) diff --git a/clients/client-python/gravitino/dto/requests/view_update_request.py b/clients/client-python/gravitino/dto/requests/view_update_request.py new file mode 100644 index 0000000000..81d747384c --- /dev/null +++ b/clients/client-python/gravitino/dto/requests/view_update_request.py @@ -0,0 +1,166 @@ +# 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 dataclasses import dataclass, field +from typing import Optional + +from dataclasses_json import config, dataclass_json + +from gravitino.api.rel.view_change import ViewChange +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.requests.view_create_request import ViewCreateRequest +from gravitino.dto.util.dto_converters import DTOConverters +from gravitino.rest.rest_message import RESTRequest +from gravitino.utils.precondition import Precondition + + +@dataclass_json +@dataclass +class ViewUpdateRequestBase(RESTRequest, ABC): + """Base class for all view update requests.""" + + _type: str = field(init=False, metadata=config(field_name="@type")) + + @abstractmethod + def view_change(self) -> ViewChange: + """Convert to view change operation.""" + + +class ViewUpdateRequest: + """Namespace for all view update request types.""" + + @dataclass_json + @dataclass + class RenameViewRequest(ViewUpdateRequestBase): + """Update request to rename a view.""" + + _new_name: str = field(metadata=config(field_name="newName")) + + def __post_init__(self): + self._type = "rename" + + def validate(self): + Precondition.check_string_not_empty( + self._new_name, '"newName" field is required and cannot be empty' + ) + + def view_change(self) -> ViewChange: + return ViewChange.rename(self._new_name) + + @dataclass_json + @dataclass + class SetViewPropertyRequest(ViewUpdateRequestBase): + """Update request to set a view property.""" + + _property: str = field(metadata=config(field_name="property")) + _value: str = field(metadata=config(field_name="value")) + + def __post_init__(self): + self._type = "setProperty" + + def validate(self): + Precondition.check_string_not_empty( + self._property, '"property" field is required and cannot be empty' + ) + Precondition.check_argument( + self._value is not None, '"value" field is required and cannot be null' + ) + + def view_change(self) -> ViewChange: + return ViewChange.set_property(self._property, self._value) + + @dataclass_json + @dataclass + class RemoveViewPropertyRequest(ViewUpdateRequestBase): + """Update request to remove a view property.""" + + _property: str = field(metadata=config(field_name="property")) + + def __post_init__(self): + self._type = "removeProperty" + + def validate(self): + Precondition.check_string_not_empty( + self._property, '"property" field is required and cannot be empty' + ) + + def view_change(self) -> ViewChange: + return ViewChange.remove_property(self._property) + + @dataclass_json + @dataclass + class ReplaceViewRequest(ViewUpdateRequestBase): + """Update request to replace the view body.""" + + _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, + ), + ) + _default_catalog: Optional[str] = field( + default=None, metadata=config(field_name="defaultCatalog") + ) + _default_schema: Optional[str] = field( + default=None, metadata=config(field_name="defaultSchema") + ) + _comment: Optional[str] = field( + default=None, metadata=config(field_name="comment") + ) + + def __post_init__(self): + self._type = "replaceView" + + def validate(self): + 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) + + def view_change(self) -> ViewChange: + return ViewChange.replace_view( + self._columns or [], + DTOConverters.from_dtos(self._representations), + self._default_catalog, + self._default_schema, + self._comment, + ) diff --git a/clients/client-python/gravitino/dto/requests/view_updates_request.py b/clients/client-python/gravitino/dto/requests/view_updates_request.py new file mode 100644 index 0000000000..5c026f73b9 --- /dev/null +++ b/clients/client-python/gravitino/dto/requests/view_updates_request.py @@ -0,0 +1,38 @@ +# 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.requests.view_update_request import ViewUpdateRequestBase +from gravitino.rest.rest_message import RESTRequest +from gravitino.utils.precondition import Precondition + + +@dataclass +class ViewUpdatesRequest(RESTRequest): + """Represents a request to update a view with multiple changes.""" + + _updates: list[ViewUpdateRequestBase] = field(metadata=config(field_name="updates")) + + def validate(self): + Precondition.check_argument(self._updates is not None, "updates cannot be null") + Precondition.check_argument(len(self._updates) > 0, "updates cannot be empty") + for update in self._updates: + Precondition.check_argument(update is not None, "update cannot be null") + update.validate() diff --git a/clients/client-python/tests/integration/test_relational_catalog.py b/clients/client-python/tests/integration/test_relational_catalog.py index 1723025514..a9f02e5235 100644 --- a/clients/client-python/tests/integration/test_relational_catalog.py +++ b/clients/client-python/tests/integration/test_relational_catalog.py @@ -35,10 +35,12 @@ 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.api.rel.view_change import ViewChange from gravitino.client.relational_table import RelationalTable from gravitino.exceptions.base import ( NoSuchSchemaException, NoSuchTableException, + NoSuchViewException, TableAlreadyExistsException, ViewAlreadyExistsException, ) @@ -382,6 +384,61 @@ class TestRelationalCatalog(IntegrationTestEnv): ], ) + def test_relational_catalog_list_views(self): + """Test listing views in the relational catalog.""" + self._create_test_table() + self._create_test_view() + view_catalog = self.catalog.as_view_catalog() + + view_identifiers = view_catalog.list_views( + Namespace.of(TestRelationalCatalog.SCHEMA_NAME) + ) + self.assertEqual(len(view_identifiers), 1) + self.assertEqual(view_identifiers[0], self.view_ident) + + def test_relational_catalog_list_views_invalid_namespace(self): + """Test listing views with invalid namespace.""" + view_catalog = self.catalog.as_view_catalog() + invalid_namespace = NameIdentifier.of( + "non_existent_schema", "dummy" + ).namespace() + + with self.assertRaises(NoSuchSchemaException): + view_catalog.list_views(namespace=invalid_namespace) + + def test_relational_catalog_load_view(self): + """Test loading a view from the relational catalog.""" + self._create_test_table() + self._create_test_view() + view_catalog = self.catalog.as_view_catalog() + + view = view_catalog.load_view(identifier=self.view_ident) + self.assertEqual(view.name(), self.view_name) + self.assertEqual(view.comment(), TestRelationalCatalog.VIEW_COMMENT) + + def test_relational_catalog_load_view_not_exists(self): + """Test loading a view that doesn't exist should raise exception.""" + view_catalog = self.catalog.as_view_catalog() + non_existent_view = NameIdentifier.of( + TestRelationalCatalog.SCHEMA_NAME, "non_existent_view" + ) + + with self.assertRaises(NoSuchViewException): + view_catalog.load_view(identifier=non_existent_view) + + def test_relational_catalog_view_exists(self): + """Test checking if a view exists.""" + self._create_test_table() + view_catalog = self.catalog.as_view_catalog() + + self.assertFalse(view_catalog.view_exists(identifier=self.view_ident)) + + self._create_test_view() + + self.assertTrue(view_catalog.view_exists(identifier=self.view_ident)) + view_catalog.drop_view(self.view_ident) + self.assertFalse(view_catalog.view_exists(self.view_ident)) + def test_relational_catalog_drop_view(self): """Test dropping a view from the relational catalog.""" self._create_test_table() @@ -397,3 +454,31 @@ class TestRelationalCatalog(IntegrationTestEnv): is_dropped = view_catalog.drop_view(self.view_ident) self.assertFalse(is_dropped) + + def test_relational_catalog_alter_view(self): + """Test altering a view from the relational catalog.""" + self._create_test_table() + self._create_test_view() + view_catalog = self.catalog.as_view_catalog() + + new_property_value = "new_property_value" + changes = [ + ViewChange.set_property("view_property1", new_property_value), + ViewChange.remove_property("view_property2"), + ] + + altered_view = view_catalog.alter_view(self.view_ident, *changes) + + self.assertEqual( + altered_view.properties().get("view_property1"), + new_property_value, + ) + self.assertNotIn("view_property2", altered_view.properties()) + + def test_relational_catalog_alter_view_not_exists(self): + """Test altering a view that doesn't exist should raise NoSuchViewException.""" + view_catalog = self.catalog.as_view_catalog() + ident = NameIdentifier.of(TestRelationalCatalog.SCHEMA_NAME, "invalid_view") + + with self.assertRaises(NoSuchViewException): + view_catalog.alter_view(ident, ViewChange.set_property("property", "value")) diff --git a/clients/client-python/tests/unittests/dto/requests/test_view_update_request.py b/clients/client-python/tests/unittests/dto/requests/test_view_update_request.py new file mode 100644 index 0000000000..fa2f8eea70 --- /dev/null +++ b/clients/client-python/tests/unittests/dto/requests/test_view_update_request.py @@ -0,0 +1,197 @@ +# 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.sql_representation import SQLRepresentation +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_update_request import ViewUpdateRequest +from gravitino.dto.requests.view_updates_request import ViewUpdatesRequest + + +class TestViewUpdateRequest(unittest.TestCase): + @staticmethod + def _column() -> ColumnDTO: + return ColumnDTO( + _name="id", + _data_type=Types.IntegerType.get(), + _nullable=False, + ) + + @staticmethod + def _representation( + dialect: str = Dialects.TRINO, sql: str = "SELECT id FROM table" + ) -> SQLRepresentationDTO: + return SQLRepresentationDTO(_dialect=dialect, _sql=sql) + + def test_rename_view_request(self): + request = ViewUpdateRequest.RenameViewRequest(_new_name="new_view") + + request.validate() + json_str = request.to_json() + deserialized = ViewUpdateRequest.RenameViewRequest.from_json(json_str) + deserialized.validate() + + self.assertEqual(request, deserialized) + self.assertIn('"@type": "rename"', json_str) + self.assertEqual("new_view", deserialized.view_change().new_name()) + + with self.assertRaises(ValueError): + ViewUpdateRequest.RenameViewRequest(_new_name="").validate() + + def test_set_view_property_request(self): + request = ViewUpdateRequest.SetViewPropertyRequest( + _property="key", _value="value" + ) + + request.validate() + json_str = request.to_json() + deserialized = ViewUpdateRequest.SetViewPropertyRequest.from_json(json_str) + deserialized.validate() + + self.assertEqual(request, deserialized) + self.assertIn('"@type": "setProperty"', json_str) + self.assertEqual("key", deserialized.view_change().property()) + self.assertEqual("value", deserialized.view_change().value()) + + with self.assertRaises(ValueError): + ViewUpdateRequest.SetViewPropertyRequest( + _property="", _value="value" + ).validate() + with self.assertRaises(ValueError): + ViewUpdateRequest.SetViewPropertyRequest( + _property="key", _value=None + ).validate() + + def test_remove_view_property_request(self): + request = ViewUpdateRequest.RemoveViewPropertyRequest(_property="key") + + request.validate() + json_str = request.to_json() + deserialized = ViewUpdateRequest.RemoveViewPropertyRequest.from_json(json_str) + deserialized.validate() + + self.assertEqual(request, deserialized) + self.assertIn('"@type": "removeProperty"', json_str) + self.assertEqual("key", deserialized.view_change().property()) + + with self.assertRaises(ValueError): + ViewUpdateRequest.RemoveViewPropertyRequest(_property="").validate() + + def test_replace_view_request(self): + request = ViewUpdateRequest.ReplaceViewRequest( + _columns=[self._column()], + _representations=[self._representation()], + _default_catalog="catalog", + _default_schema="schema", + _comment="comment", + ) + + request.validate() + json_str = request.to_json() + deserialized = ViewUpdateRequest.ReplaceViewRequest.from_json(json_str) + deserialized.validate() + change = deserialized.view_change() + + self.assertEqual(request, deserialized) + self.assertIn('"@type": "replaceView"', json_str) + self.assertIn('"representations"', json_str) + self.assertEqual("catalog", change.default_catalog()) + self.assertEqual("schema", change.default_schema()) + self.assertEqual("comment", change.comment()) + self.assertEqual(1, len(change.columns())) + self.assertEqual(1, len(change.representations())) + self.assertIsInstance(change.representations()[0], SQLRepresentation) + self.assertEqual(Dialects.TRINO, change.representations()[0].dialect()) + self.assertEqual("SELECT id FROM table", change.representations()[0].sql()) + + def test_replace_view_request_defaults_empty_columns(self): + request = ViewUpdateRequest.ReplaceViewRequest( + _representations=[self._representation()] + ) + + request.validate() + self.assertEqual([], request.view_change().columns()) + + def test_replace_view_request_excludes_missing_representations(self): + request = ViewUpdateRequest.ReplaceViewRequest() + + self.assertNotIn('"representations"', request.to_json()) + + def test_replace_view_request_validate(self): + with self.assertRaises(ValueError): + ViewUpdateRequest.ReplaceViewRequest(_representations=[]).validate() + with self.assertRaises(ValueError): + ViewUpdateRequest.ReplaceViewRequest(_representations=[None]).validate() + with self.assertRaises(ValueError): + ViewUpdateRequest.ReplaceViewRequest( + _representations=[self._representation("", "SELECT 1")] + ).validate() + with self.assertRaises(ValueError): + ViewUpdateRequest.ReplaceViewRequest( + _columns=[None], _representations=[self._representation()] + ).validate() + with self.assertRaises(ValueError): + ViewUpdateRequest.ReplaceViewRequest( + _columns=[ColumnDTO(_name="", _data_type=Types.IntegerType.get())], + _representations=[self._representation()], + ).validate() + with self.assertRaises(ValueError): + ViewUpdateRequest.ReplaceViewRequest( + _representations=[ + self._representation(Dialects.TRINO, "SELECT 1"), + self._representation(Dialects.TRINO, "SELECT 2"), + ] + ).validate() + + def test_view_updates_request(self): + updates = ViewUpdatesRequest( + _updates=[ + ViewUpdateRequest.RenameViewRequest(_new_name="new_view"), + ViewUpdateRequest.SetViewPropertyRequest( + _property="key", _value="value" + ), + ViewUpdateRequest.RemoveViewPropertyRequest(_property="key"), + ViewUpdateRequest.ReplaceViewRequest( + _representations=[self._representation()] + ), + ] + ) + + updates.validate() + json_str = updates.to_json() + + self.assertIn('"updates"', json_str) + self.assertIn('"@type": "rename"', json_str) + self.assertIn('"@type": "setProperty"', json_str) + self.assertIn('"@type": "removeProperty"', json_str) + self.assertIn('"@type": "replaceView"', json_str) + + def test_view_updates_request_validate(self): + with self.assertRaises(ValueError): + ViewUpdatesRequest(_updates=None).validate() + with self.assertRaises(ValueError): + ViewUpdatesRequest(_updates=[]).validate() + with self.assertRaises(ValueError): + ViewUpdatesRequest(_updates=[None]).validate() + with self.assertRaises(ValueError): + ViewUpdatesRequest( + _updates=[ViewUpdateRequest.RenameViewRequest(_new_name="")] + ).validate() diff --git a/clients/client-python/tests/unittests/test_relational_catalog.py b/clients/client-python/tests/unittests/test_relational_catalog.py index 5cfe5528b7..6058e4226e 100644 --- a/clients/client-python/tests/unittests/test_relational_catalog.py +++ b/clients/client-python/tests/unittests/test_relational_catalog.py @@ -25,6 +25,7 @@ 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.api.rel.view_change import ViewChange from gravitino.client.relational_catalog import RelationalCatalog from gravitino.dto.audit_dto import AuditDTO from gravitino.dto.rel.column_dto import ColumnDTO @@ -32,14 +33,17 @@ 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.requests.view_update_request import ViewUpdateRequest 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 ( + IllegalArgumentException, NoSuchSchemaException, NoSuchTableException, + NoSuchViewException, TableAlreadyExistsException, ViewAlreadyExistsException, ) @@ -492,6 +496,64 @@ class TestRelationalCatalog(unittest.TestCase): ) self.assertEqual(table.name(), self.table_dto.name()) + def test_list_views(self): + view1 = NameIdentifier.of( + self.metalake_name, self.catalog_name, self.schema_name, "view1" + ) + view2 = NameIdentifier.of( + self.metalake_name, self.catalog_name, self.schema_name, "view2" + ) + + resp_body = EntityListResponse(0, [view1, view2]) + mock_resp = self._get_mock_http_resp(resp_body.to_json()) + + with patch( + "gravitino.utils.http_client.HTTPClient.get", + return_value=mock_resp, + ): + views = self.catalog.as_view_catalog().list_views( + Namespace.of(self.schema_name) + ) + self.assertEqual(2, len(views)) + self.assertEqual("view1", views[0].name()) + self.assertEqual("view2", views[1].name()) + + def test_list_views_invalid_namespace(self): + with patch( + "gravitino.utils.http_client.HTTPClient.get", + side_effect=NoSuchSchemaException("Schema not found"), + ): + with self.assertRaises(NoSuchSchemaException): + self.catalog.as_view_catalog().list_views( + Namespace.of("invalid_schema") + ) + + def test_load_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.get", + return_value=mock_resp, + ): + view = self.catalog.as_view_catalog().load_view(self.view_identifier) + self.assertEqual(self.view_name, view.name()) + self.assertEqual("test view comment", view.comment()) + self.assertEqual("test_catalog", view.default_catalog()) + self.assertEqual("test_schema", view.default_schema()) + self.assertEqual("v1", view.properties()["k1"]) + self.assertEqual( + "SELECT id FROM test_table", view.sql_for(Dialects.TRINO).sql() + ) + + def test_load_view_not_exists(self): + with patch( + "gravitino.utils.http_client.HTTPClient.get", + side_effect=NoSuchViewException("View not found"), + ): + with self.assertRaises(NoSuchViewException): + self.catalog.as_view_catalog().load_view(self.view_identifier) + def test_create_view(self): resp_body = ViewResponse(0, self.view_dto) mock_resp = self._get_mock_http_resp(resp_body.to_json()) @@ -524,6 +586,55 @@ class TestRelationalCatalog(unittest.TestCase): [SQLRepresentation(Dialects.TRINO, "SELECT id FROM test_table")], ) + def test_alter_view(self): + updated_view_dto = ViewDTO( + _name="new_view", + _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" + ), + ) + resp_body = ViewResponse(0, updated_view_dto) + mock_resp = self._get_mock_http_resp(resp_body.to_json()) + + with patch( + "gravitino.utils.http_client.HTTPClient.put", + return_value=mock_resp, + ): + view = self.catalog.as_view_catalog().alter_view( + self.view_identifier, + ViewChange.rename("new_view"), + ) + self.assertEqual("new_view", view.name()) + + def test_alter_view_not_exists(self): + with patch( + "gravitino.utils.http_client.HTTPClient.put", + side_effect=NoSuchViewException("View not found"), + ): + with self.assertRaises(NoSuchViewException): + self.catalog.as_view_catalog().alter_view( + self.view_identifier, + ViewChange.rename("new_view"), + ) + def test_drop_view(self): resp_body = DropResponse(0, True) mock_resp = self._get_mock_http_resp(resp_body.to_json()) @@ -545,3 +656,50 @@ class TestRelationalCatalog(unittest.TestCase): ): is_dropped = self.catalog.as_view_catalog().drop_view(self.view_identifier) self.assertFalse(is_dropped) + + def test_to_view_update_request(self): + to_request = ( + RelationalCatalog._to_view_update_request # pylint: disable=protected-access + ) + + rename_request = to_request(ViewChange.rename("new_view")) + set_property_request = to_request(ViewChange.set_property("key", "value")) + remove_property_request = to_request(ViewChange.remove_property("key")) + replace_view_request = to_request( + ViewChange.replace_view( + [Column.of("id", Types.IntegerType.get())], + [SQLRepresentation(Dialects.TRINO, "SELECT id FROM table")], + "catalog", + "schema", + "comment", + ) + ) + + self.assertIsInstance(rename_request, ViewUpdateRequest.RenameViewRequest) + self.assertEqual("new_view", rename_request.view_change().new_name()) + self.assertIsInstance( + set_property_request, ViewUpdateRequest.SetViewPropertyRequest + ) + self.assertEqual("key", set_property_request.view_change().property()) + self.assertEqual("value", set_property_request.view_change().value()) + self.assertIsInstance( + remove_property_request, ViewUpdateRequest.RemoveViewPropertyRequest + ) + self.assertEqual("key", remove_property_request.view_change().property()) + self.assertIsInstance( + replace_view_request, ViewUpdateRequest.ReplaceViewRequest + ) + self.assertEqual( + "catalog", replace_view_request.view_change().default_catalog() + ) + self.assertEqual("schema", replace_view_request.view_change().default_schema()) + self.assertEqual("comment", replace_view_request.view_change().comment()) + + def test_to_view_update_request_unsupported_change(self): + class UnsupportedViewChange(ViewChange): + pass + + with self.assertRaisesRegex(IllegalArgumentException, "Unknown change type"): + RelationalCatalog._to_view_update_request( # pylint: disable=protected-access + UnsupportedViewChange() + )
