diff --git a/mkdocs/docs/api.md b/mkdocs/docs/api.md index eaffb84a54..237564aa22 100644 --- a/mkdocs/docs/api.md +++ b/mkdocs/docs/api.md @@ -1129,6 +1129,19 @@ with table.manage_snapshots() as ms: ms.create_branch(snapshot_id1, "Branch_A").create_tag(snapshot_id2, "tag789") ``` +## Views + +PyIceberg supports view operations. + +### Check if a view exists + +```python +from pyiceberg.catalog import load_catalog + +catalog = load_catalog("default") +catalog.view_exists("default.bar") +``` + ## Query the data To query a table, a table scan is needed. A table scan accepts a filter, columns, optionally a limit and a snapshot ID: diff --git a/pyiceberg/catalog/__init__.py b/pyiceberg/catalog/__init__.py index 7eb8b02d40..9e3adca529 100644 --- a/pyiceberg/catalog/__init__.py +++ b/pyiceberg/catalog/__init__.py @@ -450,6 +450,17 @@ def table_exists(self, identifier: Union[str, Identifier]) -> bool: bool: True if the table exists, False otherwise. """ + @abstractmethod + def view_exists(self, identifier: Union[str, Identifier]) -> bool: + """Check if a view exists. + + Args: + identifier (str | Identifier): View identifier. + + Returns: + bool: True if the view exists, False otherwise. + """ + @abstractmethod def register_table(self, identifier: Union[str, Identifier], metadata_location: str) -> Table: """Register a new table using existing metadata. diff --git a/pyiceberg/catalog/dynamodb.py b/pyiceberg/catalog/dynamodb.py index 59cbe8ce28..9bbd15a4cf 100644 --- a/pyiceberg/catalog/dynamodb.py +++ b/pyiceberg/catalog/dynamodb.py @@ -543,6 +543,9 @@ def list_views(self, namespace: Union[str, Identifier]) -> List[Identifier]: def drop_view(self, identifier: Union[str, Identifier]) -> None: raise NotImplementedError + def view_exists(self, identifier: Union[str, Identifier]) -> bool: + 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 5c7b1ddcbd..8faf7e074a 100644 --- a/pyiceberg/catalog/glue.py +++ b/pyiceberg/catalog/glue.py @@ -782,6 +782,9 @@ def list_views(self, namespace: Union[str, Identifier]) -> List[Identifier]: def drop_view(self, identifier: Union[str, Identifier]) -> None: raise NotImplementedError + def view_exists(self, identifier: Union[str, Identifier]) -> bool: + raise NotImplementedError + @staticmethod def __is_iceberg_table(table: TableTypeDef) -> bool: return table["Parameters"] is not None and table["Parameters"][TABLE_TYPE].lower() == ICEBERG diff --git a/pyiceberg/catalog/hive.py b/pyiceberg/catalog/hive.py index 030470e164..9a769d91f5 100644 --- a/pyiceberg/catalog/hive.py +++ b/pyiceberg/catalog/hive.py @@ -409,6 +409,9 @@ def register_table(self, identifier: Union[str, Identifier], metadata_location: def list_views(self, namespace: Union[str, Identifier]) -> List[Identifier]: raise NotImplementedError + def view_exists(self, identifier: Union[str, Identifier]) -> bool: + 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 caebf1e445..39a0b382a7 100644 --- a/pyiceberg/catalog/noop.py +++ b/pyiceberg/catalog/noop.py @@ -123,5 +123,8 @@ def update_namespace_properties( def list_views(self, namespace: Union[str, Identifier]) -> List[Identifier]: raise NotImplementedError + def view_exists(self, identifier: Union[str, Identifier]) -> bool: + raise NotImplementedError + def drop_view(self, identifier: Union[str, Identifier]) -> None: raise NotImplementedError diff --git a/pyiceberg/catalog/rest.py b/pyiceberg/catalog/rest.py index 20a04d9c5b..55d5667776 100644 --- a/pyiceberg/catalog/rest.py +++ b/pyiceberg/catalog/rest.py @@ -105,6 +105,7 @@ class Endpoints: rename_table: str = "tables/rename" list_views: str = "namespaces/{namespace}/views" drop_view: str = "namespaces/{namespace}/views/{view}" + view_exists: str = "namespaces/{namespace}/views/{view}" class IdentifierKind(Enum): @@ -890,6 +891,34 @@ def table_exists(self, identifier: Union[str, Identifier]) -> bool: return False + @retry(**_RETRY_ARGS) + def view_exists(self, identifier: Union[str, Identifier]) -> bool: + """Check if a view exists. + + Args: + identifier (str | Identifier): View identifier. + + Returns: + bool: True if the view exists, False otherwise. + """ + identifier_tuple = self._identifier_to_tuple_without_catalog(identifier) + response = self._session.head( + self.url( + Endpoints.view_exists, prefixed=True, **self._split_identifier_for_path(identifier_tuple, IdentifierKind.VIEW) + ), + ) + if response.status_code == 404: + return False + elif response.status_code == 204: + return True + + try: + response.raise_for_status() + except HTTPError as exc: + self._handle_non_200_response(exc, {}) + + return False + @retry(**_RETRY_ARGS) def drop_view(self, identifier: Union[str]) -> None: identifier_tuple = self.identifier_to_tuple_without_catalog(identifier) diff --git a/pyiceberg/catalog/sql.py b/pyiceberg/catalog/sql.py index 6a4318253f..819265af43 100644 --- a/pyiceberg/catalog/sql.py +++ b/pyiceberg/catalog/sql.py @@ -708,5 +708,8 @@ def update_namespace_properties( def list_views(self, namespace: Union[str, Identifier]) -> List[Identifier]: raise NotImplementedError + def view_exists(self, identifier: Union[str, Identifier]) -> bool: + raise NotImplementedError + def drop_view(self, identifier: Union[str, Identifier]) -> None: raise NotImplementedError diff --git a/tests/catalog/test_base.py b/tests/catalog/test_base.py index e212854ee2..6298f5e2b2 100644 --- a/tests/catalog/test_base.py +++ b/tests/catalog/test_base.py @@ -264,6 +264,9 @@ def list_views(self, namespace: Optional[Union[str, Identifier]] = None) -> List def drop_view(self, identifier: Union[str, Identifier]) -> None: raise NotImplementedError + def view_exists(self, identifier: Union[str, Identifier]) -> bool: + raise NotImplementedError + @pytest.fixture def catalog(tmp_path: PosixPath) -> InMemoryCatalog: diff --git a/tests/catalog/test_rest.py b/tests/catalog/test_rest.py index f05e15df38..1eef85beda 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -450,6 +450,42 @@ def test_list_views_404(rest_mock: Mocker) -> None: assert "Namespace does not exist" in str(e.value) +def test_view_exists_204(rest_mock: Mocker) -> None: + namespace = "examples" + view = "some_view" + rest_mock.head( + f"{TEST_URI}v1/namespaces/{namespace}/views/{view}", + status_code=204, + request_headers=TEST_HEADERS, + ) + catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN) + assert catalog.view_exists((namespace, view)) + + +def test_view_exists_404(rest_mock: Mocker) -> None: + namespace = "examples" + view = "some_view" + rest_mock.head( + f"{TEST_URI}v1/namespaces/{namespace}/views/{view}", + status_code=404, + request_headers=TEST_HEADERS, + ) + catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN) + assert not catalog.view_exists((namespace, view)) + + +def test_view_exists_multilevel_namespace_404(rest_mock: Mocker) -> None: + multilevel_namespace = "core.examples.some_namespace" + view = "some_view" + rest_mock.head( + f"{TEST_URI}v1/namespaces/{multilevel_namespace}/views/{view}", + status_code=404, + request_headers=TEST_HEADERS, + ) + catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN) + assert not catalog.view_exists((multilevel_namespace, view)) + + def test_list_namespaces_200(rest_mock: Mocker) -> None: rest_mock.get( f"{TEST_URI}v1/namespaces",