Skip to content

Commit e598827

Browse files
authored
Add support for registering views (#3288)
# Rationale for this change - Relates to apache/iceberg#14869 ## Are these changes tested? tests/catalog/test_rest.py contains new tests. ## Are there any user-facing changes? This PR adds a new `register_view` method to `catalog`. <!-- In the case of user-facing changes, please add the changelog label. -->
1 parent b21b7e8 commit e598827

10 files changed

Lines changed: 142 additions & 0 deletions

File tree

mkdocs/docs/api.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1529,6 +1529,17 @@ catalog = load_catalog("default")
15291529
catalog.view_exists("default.bar")
15301530
```
15311531

1532+
## Register a view
1533+
1534+
To register a view using existing metadata:
1535+
1536+
```python
1537+
catalog.register_view(
1538+
identifier="docs_example.bids",
1539+
metadata_location="s3://warehouse/path/to/metadata.json"
1540+
)
1541+
```
1542+
15321543
## Table Statistics Management
15331544

15341545
Manage table statistics with operations through the `Table` API:

pyiceberg/catalog/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,22 @@ def update_namespace_properties(
690690
ValueError: If removals and updates have overlapping keys.
691691
"""
692692

693+
@abstractmethod
694+
def register_view(self, identifier: str | Identifier, metadata_location: str) -> View:
695+
"""Register a new view using existing metadata.
696+
697+
Args:
698+
identifier (Union[str, Identifier]): View identifier for the view
699+
metadata_location (str): The location to the metadata
700+
701+
Returns:
702+
View: The newly registered view
703+
704+
Raises:
705+
ViewAlreadyExistsError: If the view already exists.
706+
TableAlreadyExistsError: If a table with the same name already exists.
707+
"""
708+
693709
@abstractmethod
694710
def drop_view(self, identifier: str | Identifier) -> None:
695711
"""Drop a view.

pyiceberg/catalog/bigquery_metastore.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,9 @@ def register_table(self, identifier: str | Identifier, metadata_location: str, o
305305
def list_views(self, namespace: str | Identifier) -> list[Identifier]:
306306
raise NotImplementedError
307307

308+
def register_view(self, identifier: str | Identifier, metadata_location: str) -> View:
309+
raise NotImplementedError
310+
308311
def drop_view(self, identifier: str | Identifier) -> None:
309312
raise NotImplementedError
310313

pyiceberg/catalog/dynamodb.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,9 @@ def create_view(
553553
def list_views(self, namespace: str | Identifier) -> list[Identifier]:
554554
raise NotImplementedError
555555

556+
def register_view(self, identifier: str | Identifier, metadata_location: str) -> View:
557+
raise NotImplementedError
558+
556559
def drop_view(self, identifier: str | Identifier) -> None:
557560
raise NotImplementedError
558561

pyiceberg/catalog/glue.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,9 @@ def create_view(
970970
def list_views(self, namespace: str | Identifier) -> list[Identifier]:
971971
raise NotImplementedError
972972

973+
def register_view(self, identifier: str | Identifier, metadata_location: str) -> View:
974+
raise NotImplementedError
975+
973976
def drop_view(self, identifier: str | Identifier) -> None:
974977
raise NotImplementedError
975978

pyiceberg/catalog/hive.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,9 @@ def update_namespace_properties(
854854

855855
return PropertiesUpdateSummary(removed=list(removed or []), updated=list(updated or []), missing=list(expected_to_change))
856856

857+
def register_view(self, identifier: str | Identifier, metadata_location: str) -> View:
858+
raise NotImplementedError
859+
857860
def drop_view(self, identifier: str | Identifier) -> None:
858861
raise NotImplementedError
859862

pyiceberg/catalog/noop.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ def view_exists(self, identifier: str | Identifier) -> bool:
132132
def namespace_exists(self, namespace: str | Identifier) -> bool:
133133
raise NotImplementedError
134134

135+
def register_view(self, identifier: str | Identifier, metadata_location: str) -> View:
136+
raise NotImplementedError
137+
135138
def drop_view(self, identifier: str | Identifier) -> None:
136139
raise NotImplementedError
137140

pyiceberg/catalog/rest/__init__.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ class Endpoints:
154154
list_views: str = "namespaces/{namespace}/views"
155155
load_view: str = "namespaces/{namespace}/views/{view}"
156156
create_view: str = "namespaces/{namespace}/views"
157+
register_view: str = "namespaces/{namespace}/register-view"
157158
drop_view: str = "namespaces/{namespace}/views/{view}"
158159
view_exists: str = "namespaces/{namespace}/views/{view}"
159160
plan_table_scan: str = "namespaces/{namespace}/tables/{table}/plan"
@@ -183,6 +184,7 @@ class Capability:
183184
V1_LIST_VIEWS = Endpoint(http_method=HttpMethod.GET, path=f"{API_PREFIX}/{Endpoints.list_views}")
184185
V1_LOAD_VIEW = Endpoint(http_method=HttpMethod.GET, path=f"{API_PREFIX}/{Endpoints.load_view}")
185186
V1_VIEW_EXISTS = Endpoint(http_method=HttpMethod.HEAD, path=f"{API_PREFIX}/{Endpoints.view_exists}")
187+
V1_REGISTER_VIEW = Endpoint(http_method=HttpMethod.POST, path=f"{API_PREFIX}/{Endpoints.register_view}")
186188
V1_DELETE_VIEW = Endpoint(http_method=HttpMethod.DELETE, path=f"{API_PREFIX}/{Endpoints.drop_view}")
187189
V1_SUBMIT_TABLE_SCAN_PLAN = Endpoint(http_method=HttpMethod.POST, path=f"{API_PREFIX}/{Endpoints.plan_table_scan}")
188190
V1_TABLE_SCAN_PLAN_TASKS = Endpoint(http_method=HttpMethod.POST, path=f"{API_PREFIX}/{Endpoints.fetch_scan_tasks}")
@@ -322,6 +324,11 @@ class RegisterTableRequest(IcebergBaseModel):
322324
overwrite: bool
323325

324326

327+
class RegisterViewRequest(IcebergBaseModel):
328+
name: str
329+
metadata_location: str = Field(..., alias="metadata-location")
330+
331+
325332
class ConfigResponse(IcebergBaseModel):
326333
defaults: Properties | None = Field(default_factory=dict)
327334
overrides: Properties | None = Field(default_factory=dict)
@@ -1332,6 +1339,29 @@ def view_exists(self, identifier: str | Identifier) -> bool:
13321339

13331340
return False
13341341

1342+
@retry(**_RETRY_ARGS)
1343+
def register_view(self, identifier: str | Identifier, metadata_location: str) -> View:
1344+
self._check_endpoint(Capability.V1_REGISTER_VIEW)
1345+
namespace_and_view = self._split_identifier_for_path(identifier, IdentifierKind.VIEW)
1346+
namespace = namespace_and_view["namespace"]
1347+
view = namespace_and_view["view"]
1348+
if self.table_exists(identifier):
1349+
raise TableAlreadyExistsError(f"Table {namespace}.{view} already exists")
1350+
1351+
request = RegisterViewRequest(name=view, metadata_location=metadata_location)
1352+
serialized_json = request.model_dump_json().encode(UTF8)
1353+
response = self._session.post(
1354+
self.url(Endpoints.register_view, namespace=namespace),
1355+
data=serialized_json,
1356+
)
1357+
try:
1358+
response.raise_for_status()
1359+
except HTTPError as exc:
1360+
_handle_non_200_response(exc, {409: ViewAlreadyExistsError})
1361+
1362+
view_response = ViewResponse.model_validate_json(response.text)
1363+
return self._response_to_view(self.identifier_to_tuple(identifier), view_response)
1364+
13351365
@retry(**_RETRY_ARGS)
13361366
def drop_view(self, identifier: str) -> None:
13371367
self._check_endpoint(Capability.V1_DELETE_VIEW)

pyiceberg/catalog/sql.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,9 @@ def list_views(self, namespace: str | Identifier) -> list[Identifier]:
745745
def view_exists(self, identifier: str | Identifier) -> bool:
746746
raise NotImplementedError
747747

748+
def register_view(self, identifier: str | Identifier, metadata_location: str) -> View:
749+
raise NotImplementedError
750+
748751
def drop_view(self, identifier: str | Identifier) -> None:
749752
raise NotImplementedError
750753

tests/catalog/test_rest.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
Capability.V1_LIST_VIEWS,
106106
Capability.V1_LOAD_VIEW,
107107
Capability.V1_VIEW_EXISTS,
108+
Capability.V1_REGISTER_VIEW,
108109
Capability.V1_DELETE_VIEW,
109110
Capability.V1_SUBMIT_TABLE_SCAN_PLAN,
110111
Capability.V1_TABLE_SCAN_PLAN_TASKS,
@@ -2182,6 +2183,72 @@ def test_table_identifier_in_commit_table_request(
21822183
)
21832184

21842185

2186+
def test_register_view_200(rest_mock: Mocker, example_view_metadata_rest_json: dict[str, Any]) -> None:
2187+
rest_mock.head(
2188+
f"{TEST_URI}v1/namespaces/default/tables/registered_view",
2189+
status_code=404,
2190+
request_headers=TEST_HEADERS,
2191+
)
2192+
rest_mock.post(
2193+
f"{TEST_URI}v1/namespaces/default/register-view",
2194+
json=example_view_metadata_rest_json,
2195+
status_code=200,
2196+
request_headers=TEST_HEADERS,
2197+
)
2198+
2199+
catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN)
2200+
actual = catalog.register_view(
2201+
identifier=("default", "registered_view"), metadata_location="s3://warehouse/database/view/metadata.json"
2202+
)
2203+
expected = View(
2204+
identifier=("default", "registered_view"),
2205+
metadata=ViewMetadata(**example_view_metadata_rest_json["metadata"]),
2206+
)
2207+
assert actual == expected
2208+
2209+
2210+
def test_register_view_409_view(rest_mock: Mocker) -> None:
2211+
rest_mock.head(
2212+
f"{TEST_URI}v1/namespaces/default/tables/registered_view",
2213+
status_code=404,
2214+
request_headers=TEST_HEADERS,
2215+
)
2216+
rest_mock.post(
2217+
f"{TEST_URI}v1/namespaces/default/register-view",
2218+
json={
2219+
"error": {
2220+
"message": "View already exists: default.view in warehouse 8bcb0838-50fc-472d-9ddb-8feb89ef5f1e",
2221+
"type": "AlreadyExistsException",
2222+
"code": 409,
2223+
}
2224+
},
2225+
status_code=409,
2226+
request_headers=TEST_HEADERS,
2227+
)
2228+
2229+
catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN)
2230+
with pytest.raises(ViewAlreadyExistsError) as e:
2231+
catalog.register_view(
2232+
identifier=("default", "registered_view"), metadata_location="s3://warehouse/database/view/metadata.json"
2233+
)
2234+
assert "View already exists" in str(e.value)
2235+
2236+
2237+
def test_register_view_409_table(rest_mock: Mocker) -> None:
2238+
rest_mock.head(
2239+
f"{TEST_URI}v1/namespaces/default/tables/registered_view",
2240+
status_code=200,
2241+
request_headers=TEST_HEADERS,
2242+
)
2243+
2244+
catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN)
2245+
with pytest.raises(TableAlreadyExistsError) as e:
2246+
catalog.register_view(
2247+
identifier=("default", "registered_view"), metadata_location="s3://warehouse/database/view/metadata.json"
2248+
)
2249+
assert "Table default.registered_view already exists" in str(e.value)
2250+
2251+
21852252
def test_drop_view_invalid_namespace(rest_mock: Mocker) -> None:
21862253
view = "view"
21872254
with pytest.raises(NoSuchIdentifierError) as e:

0 commit comments

Comments
 (0)