This is an automated email from the ASF dual-hosted git repository.
mchades pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 2214a29dcc [#12158] improvement(client-python): Add view query/alter
operations (#12159)
2214a29dcc is described below
commit 2214a29dcc3d6091b52f0ee4ad8eedee92615393
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.
---
.../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 316f5f83df..86a3edc269 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,
)
@@ -369,6 +373,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())
@@ -401,6 +463,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())
@@ -422,3 +533,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()
+ )