diff --git a/pyiceberg/catalog/__init__.py b/pyiceberg/catalog/__init__.py index 4da116434e..7a8e88a212 100644 --- a/pyiceberg/catalog/__init__.py +++ b/pyiceberg/catalog/__init__.py @@ -657,6 +657,21 @@ def drop_view(self, identifier: Union[str, Identifier]) -> None: NoSuchViewError: If a view with the given name does not exist. """ + @abstractmethod + def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + """Rename a fully classified view name. + + Args: + from_identifier (str | Identifier): Existing view identifier. + to_identifier (str | Identifier): New view identifier. + + Returns: + Table: the updated table instance with its metadata. + + Raises: + NoSuchViewError: If a table with the name does not exist. + """ + @staticmethod def identifier_to_tuple(identifier: Union[str, Identifier]) -> Identifier: """Parse an identifier to a tuple. diff --git a/pyiceberg/catalog/dynamodb.py b/pyiceberg/catalog/dynamodb.py index 3b37762638..b9e242f236 100644 --- a/pyiceberg/catalog/dynamodb.py +++ b/pyiceberg/catalog/dynamodb.py @@ -547,6 +547,9 @@ def drop_view(self, identifier: Union[str, Identifier]) -> None: def view_exists(self, identifier: Union[str, Identifier]) -> bool: raise NotImplementedError + def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + raise NotImplementedError + def _get_iceberg_table_item(self, database_name: str, table_name: str) -> Dict[str, Any]: try: return self._get_dynamo_item(identifier=f"{database_name}.{table_name}", namespace=database_name) diff --git a/pyiceberg/catalog/glue.py b/pyiceberg/catalog/glue.py index 88ad8aa433..63908da24e 100644 --- a/pyiceberg/catalog/glue.py +++ b/pyiceberg/catalog/glue.py @@ -818,6 +818,9 @@ def drop_view(self, identifier: Union[str, Identifier]) -> None: def view_exists(self, identifier: Union[str, Identifier]) -> bool: raise NotImplementedError + def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + raise NotImplementedError + @staticmethod def __is_iceberg_table(table: "TableTypeDef") -> bool: return table.get("Parameters", {}).get(TABLE_TYPE, "").lower() == ICEBERG diff --git a/pyiceberg/catalog/hive.py b/pyiceberg/catalog/hive.py index 09437dd1b6..8b413f47b2 100644 --- a/pyiceberg/catalog/hive.py +++ b/pyiceberg/catalog/hive.py @@ -475,6 +475,9 @@ def list_views(self, namespace: Union[str, Identifier]) -> List[Identifier]: def view_exists(self, identifier: Union[str, Identifier]) -> bool: raise NotImplementedError + def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + raise NotImplementedError + def _create_lock_request(self, database_name: str, table_name: str) -> LockRequest: lock_component: LockComponent = LockComponent( level=LockLevel.TABLE, type=LockType.EXCLUSIVE, dbname=database_name, tablename=table_name, isTransactional=True diff --git a/pyiceberg/catalog/noop.py b/pyiceberg/catalog/noop.py index 39a0b382a7..b345bd302a 100644 --- a/pyiceberg/catalog/noop.py +++ b/pyiceberg/catalog/noop.py @@ -128,3 +128,6 @@ def view_exists(self, identifier: Union[str, Identifier]) -> bool: def drop_view(self, identifier: Union[str, Identifier]) -> None: raise NotImplementedError + + def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + raise NotImplementedError diff --git a/pyiceberg/catalog/rest/__init__.py b/pyiceberg/catalog/rest/__init__.py index 3f59a196ea..bd168d7495 100644 --- a/pyiceberg/catalog/rest/__init__.py +++ b/pyiceberg/catalog/rest/__init__.py @@ -53,6 +53,7 @@ NoSuchViewError, TableAlreadyExistsError, UnauthorizedError, + ViewAlreadyExistsError, ) from pyiceberg.io import AWS_ACCESS_KEY_ID, AWS_REGION, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN from pyiceberg.partitioning import UNPARTITIONED_PARTITION_SPEC, PartitionSpec, assign_fresh_partition_spec_ids @@ -102,6 +103,7 @@ class Endpoints: list_views: str = "namespaces/{namespace}/views" drop_view: str = "namespaces/{namespace}/views/{view}" view_exists: str = "namespaces/{namespace}/views/{view}" + rename_view: str = "views/rename" class IdentifierKind(Enum): @@ -857,3 +859,15 @@ def drop_view(self, identifier: Union[str]) -> None: response.raise_for_status() except HTTPError as exc: _handle_non_200_response(exc, {404: NoSuchViewError}) + + @retry(**_RETRY_ARGS) + def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + payload = { + "source": self._split_identifier_for_json(from_identifier), + "destination": self._split_identifier_for_json(to_identifier), + } + response = self._session.post(self.url(Endpoints.rename_view), json=payload) + try: + response.raise_for_status() + except HTTPError as exc: + _handle_non_200_response(exc, {404: NoSuchViewError, 409: ViewAlreadyExistsError}) diff --git a/pyiceberg/catalog/sql.py b/pyiceberg/catalog/sql.py index 880a4db481..616925c365 100644 --- a/pyiceberg/catalog/sql.py +++ b/pyiceberg/catalog/sql.py @@ -730,3 +730,6 @@ def view_exists(self, identifier: Union[str, Identifier]) -> bool: def drop_view(self, identifier: Union[str, Identifier]) -> None: raise NotImplementedError + + def rename_view(self, from_identifier: Union[str, Identifier], to_identifier: Union[str, Identifier]) -> None: + raise NotImplementedError diff --git a/pyiceberg/exceptions.py b/pyiceberg/exceptions.py index c80f104e46..c87bd108e0 100644 --- a/pyiceberg/exceptions.py +++ b/pyiceberg/exceptions.py @@ -44,6 +44,10 @@ class NoSuchViewError(Exception): """Raises when the view can't be found in the REST catalog.""" +class ViewAlreadyExistsError(Exception): + """Raises when the view being created already exists in the REST catalog.""" + + class NoSuchIdentifierError(Exception): """Raises when the identifier can't be found in the REST catalog.""" diff --git a/tests/catalog/test_rest.py b/tests/catalog/test_rest.py index ed91dd15a1..5565202c9c 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -36,6 +36,7 @@ OAuthError, ServerError, TableAlreadyExistsError, + ViewAlreadyExistsError, ) from pyiceberg.io import load_file_io from pyiceberg.partitioning import PartitionField, PartitionSpec @@ -1621,3 +1622,65 @@ def test_drop_view_204(rest_mock: Mocker) -> None: request_headers=TEST_HEADERS, ) RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN).drop_view(("some_namespace", "some_view")) + + +def test_rename_view_204(rest_mock: Mocker) -> None: + from_identifier = ("some_namespace", "old_view") + to_identifier = ("some_namespace", "new_view") + rest_mock.post( + f"{TEST_URI}v1/views/rename", + json={ + "source": {"namespace": ["some_namespace"], "name": "old_view"}, + "destination": {"namespace": ["some_namespace"], "name": "new_view"}, + }, + status_code=204, + request_headers=TEST_HEADERS, + ) + catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN) + catalog.rename_view(from_identifier, to_identifier) + assert ( + rest_mock.last_request.text + == """{"source": {"namespace": ["some_namespace"], "name": "old_view"}, "destination": {"namespace": ["some_namespace"], "name": "new_view"}}""" + ) + + +def test_rename_view_404(rest_mock: Mocker) -> None: + from_identifier = ("some_namespace", "non_existent_view") + to_identifier = ("some_namespace", "new_view") + rest_mock.post( + f"{TEST_URI}v1/views/rename", + json={ + "error": { + "message": "View does not exist: some_namespace.non_existent_view", + "type": "NoSuchViewException", + "code": 404, + } + }, + status_code=404, + request_headers=TEST_HEADERS, + ) + catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN) + with pytest.raises(NoSuchViewError) as exc_info: + catalog.rename_view(from_identifier, to_identifier) + assert "View does not exist: some_namespace.non_existent_view" in str(exc_info.value) + + +def test_rename_view_409(rest_mock: Mocker) -> None: + from_identifier = ("some_namespace", "old_view") + to_identifier = ("some_namespace", "existing_view") + rest_mock.post( + f"{TEST_URI}v1/views/rename", + json={ + "error": { + "message": "View already exists: some_namespace.existing_view", + "type": "ViewAlreadyExistsException", + "code": 409, + } + }, + status_code=409, + request_headers=TEST_HEADERS, + ) + catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN) + with pytest.raises(ViewAlreadyExistsError) as exc_info: + catalog.rename_view(from_identifier, to_identifier) + assert "View already exists: some_namespace.existing_view" in str(exc_info.value)