Skip to content

Commit 704b8cb

Browse files
[python] Support getting a table by id in the REST catalog (#10697)
1 parent d98c63b commit 704b8cb

12 files changed

Lines changed: 160 additions & 10 deletions

File tree

‎docs/docs/pypaimon/catalogs.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,9 @@ catalog.create_table(
264264

265265
# Get Table
266266
table = catalog.get_table('database_name.table_name')
267+
268+
# Get Table by id (REST catalog only)
269+
table = catalog.get_table_by_id('table_id')
267270
```
268271

269272
## Alter Table

‎paimon-python/pypaimon/api/api_response.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,13 +247,16 @@ class GetTableResponse(AuditRESTResponse):
247247

248248
# Field constants for JSON serialization
249249
FIELD_ID = "id"
250+
FIELD_DATABASE = "database"
250251
FIELD_NAME = "name"
251252
FIELD_PATH = "path"
252253
FIELD_IS_EXTERNAL = "isExternal"
253254
FIELD_SCHEMA_ID = "schemaId"
254255
FIELD_SCHEMA = "schema"
255256

256257
id: Optional[str] = json_field(FIELD_ID, default=None)
258+
# Omitted when None: native readers compare it with the table's database
259+
database: Optional[str] = optional_json_field(FIELD_DATABASE, "non_null")
257260
name: Optional[str] = json_field(FIELD_NAME, default=None)
258261
path: Optional[str] = json_field(FIELD_PATH, default=None)
259262
is_external: Optional[bool] = json_field(FIELD_IS_EXTERNAL, default=None)
@@ -273,9 +276,11 @@ def __init__(
273276
created_by: Optional[str] = None,
274277
updated_at: Optional[int] = None,
275278
updated_by: Optional[str] = None,
279+
database: Optional[str] = None,
276280
):
277281
super().__init__(owner, created_at, created_by, updated_at, updated_by)
278282
self.id = id
283+
self.database = database
279284
self.name = name
280285
self.path = path
281286
self.is_external = is_external
@@ -285,6 +290,9 @@ def __init__(
285290
def get_id(self) -> str:
286291
return self.id
287292

293+
def get_database(self) -> Optional[str]:
294+
return self.database
295+
288296
def get_name(self) -> str:
289297
return self.name
290298

‎paimon-python/pypaimon/api/resource_paths.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class ResourcePaths:
2727
V1 = "v1"
2828
DATABASES = "databases"
2929
TABLES = "tables"
30+
ID = "id"
3031
TABLE_DETAILS = "table-details"
3132
PARTITIONS = "partitions"
3233
FUNCTIONS = "functions"
@@ -70,6 +71,11 @@ def table(self, database_name: str, table_name: str) -> str:
7071
return ("{}/{}/{}/{}/{}".format(self.base_path, self.DATABASES, RESTUtil.encode_string(database_name),
7172
self.TABLES, RESTUtil.encode_string(table_name)))
7273

74+
def table_by_id(self, table_id: str) -> str:
75+
# Keep '~' raw, as in table_details.
76+
table_id = urllib.parse.quote_plus(table_id, safe="*~", errors="replace")
77+
return "{}/{}/{}/{}".format(self.base_path, self.TABLES, self.ID, table_id)
78+
7379
def table_details(self, database_name: str) -> str:
7480
database_name = urllib.parse.quote_plus(database_name, safe="*~", errors="replace")
7581
return "{}/{}/{}/{}".format(

‎paimon-python/pypaimon/api/rest_api.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,16 @@ def get_table(self, identifier: Identifier) -> GetTableResponse:
313313
self.rest_auth_function,
314314
)
315315

316+
def get_table_by_id(self, table_id: str) -> GetTableResponse:
317+
if not table_id or not table_id.strip():
318+
raise ValueError("Table id cannot be empty")
319+
320+
return self.client.get(
321+
self.resource_paths.table_by_id(table_id),
322+
GetTableResponse,
323+
self.rest_auth_function,
324+
)
325+
316326
def drop_table(self, identifier: Identifier) -> GetTableResponse:
317327
database_name, table_name = self.__validate_identifier(identifier)
318328

‎paimon-python/pypaimon/catalog/catalog.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ def alter_database(self, name: str, changes: List[PropertyChange]) -> None:
9797
def get_table(self, identifier: Union[str, Identifier]) -> Table:
9898
"""Get paimon table identified by the given Identifier."""
9999

100+
def get_table_by_id(self, table_id: str) -> Table:
101+
"""Get paimon table identified by the given table id."""
102+
raise NotImplementedError(
103+
"get_table_by_id is not supported by this catalog."
104+
)
105+
100106
@abstractmethod
101107
def create_table(self, identifier: Union[str, Identifier], schema: Schema, ignore_if_exists: bool) -> None:
102108
"""Create table with schema."""

‎paimon-python/pypaimon/catalog/catalog_exception.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,22 @@ def __init__(self, identifier: Identifier):
7171
super().__init__(f"No permission to access table {identifier.get_full_name()}")
7272

7373

74+
class TableIdNotExistException(CatalogException):
75+
"""Table id not exist exception"""
76+
77+
def __init__(self, table_id: str):
78+
self.table_id = table_id
79+
super().__init__(f"Table id {table_id} does not exist")
80+
81+
82+
class TableIdNoPermissionException(CatalogException):
83+
"""Table id no permission exception"""
84+
85+
def __init__(self, table_id: str):
86+
self.table_id = table_id
87+
super().__init__(f"No permission to access table id {table_id}")
88+
89+
7490
class ViewNotExistException(CatalogException):
7591
"""View not exist exception"""
7692

‎paimon-python/pypaimon/catalog/rest/rest_catalog.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
TableNotExistException, DatabaseAlreadyExistException,
3333
TableAlreadyExistException, DatabaseNotExistException,
3434
TableNoPermissionException, DatabaseNoPermissionException,
35+
TableIdNotExistException, TableIdNoPermissionException,
3536
FunctionNotExistException, FunctionAlreadyExistException,
3637
DefinitionAlreadyExistException, DefinitionNotExistException,
3738
TagNotExistException, TagAlreadyExistException,
@@ -250,6 +251,22 @@ def get_table(self, identifier: Union[str, Identifier]) -> Table:
250251
return self._load_system_table(identifier)
251252
return self._load_data_table(identifier)
252253

254+
def get_table_by_id(self, table_id: str) -> Table:
255+
try:
256+
response = self.rest_api.get_table_by_id(table_id)
257+
except NoSuchResourceException as e:
258+
raise TableIdNotExistException(table_id) from e
259+
except ForbiddenException as e:
260+
raise TableIdNoPermissionException(table_id) from e
261+
database = response.get_database()
262+
identifier = Identifier.create(database, response.get_name())
263+
return self.load_table(
264+
identifier,
265+
lambda path: self.file_io_for_data(path, identifier),
266+
self.file_io_from_options,
267+
lambda _: self.to_table_metadata(database, response),
268+
)
269+
253270
def _load_data_table(self, identifier: Identifier) -> Union[FormatTable, IcebergTable, ObjectTable, FileStoreTable]:
254271
return self.load_table(
255272
identifier,

‎paimon-python/pypaimon/tests/native_plan_rest_test.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,8 @@ def token_file_io(table):
208208
assert load.call_count == 1
209209

210210

211-
@pytest.mark.parametrize('branch', [None, 'dev'])
212-
def test_rest_dotted_database_and_table_keep_identity(rest_catalog, branch):
211+
@pytest.mark.parametrize('branch,by_id', [(None, False), ('dev', False), (None, True)])
212+
def test_rest_dotted_database_and_table_keep_identity(rest_catalog, branch, by_id):
213213
from pypaimon.common.identifier import Identifier
214214
from pypaimon.tests.native_plan_resolved_schema_test import _assert_parity, _write
215215
catalog, server = rest_catalog
@@ -219,6 +219,8 @@ def test_rest_dotted_database_and_table_keep_identity(rest_catalog, branch):
219219
pa.schema([('id', pa.int64()), ('value', pa.string())])), False)
220220
table = catalog.get_table(identifier)
221221
_write(table, [{'id': 1, 'value': 'old'}])
222+
if by_id:
223+
table = catalog.get_table_by_id(table.catalog_environment.uuid)
222224
if branch:
223225
catalog.create_tag(identifier, 'first', 1)
224226
catalog.create_branch(identifier, branch, tag_name='first')

‎paimon-python/pypaimon/tests/rest/api_test.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# specific language governing permissions and limitations
1616
# under the License.
1717

18+
import json
1819
import logging
1920
import unittest
2021
import uuid
@@ -156,6 +157,22 @@ def test_parse_data(self):
156157
self.assertFalse(vector_type.element.nullable)
157158
self.assertEqual(vector_type.length, 7)
158159

160+
def test_get_table_response_database(self):
161+
from pypaimon.api.api_response import GetTableResponse
162+
from pypaimon.schema.schema import Schema
163+
164+
# JSON as the Java server sends it
165+
parsed = JSON.from_json(
166+
'{"id": "id", "database": "db", "name": "t", "path": "path", "isExternal": false, '
167+
'"schemaId": 0, "schema": {"fields": [], "partitionKeys": [], "primaryKeys": [], '
168+
'"options": {}, "comment": null}}', GetTableResponse)
169+
self.assertEqual(parsed.get_database(), 'db')
170+
self.assertEqual(parsed.get_name(), 't')
171+
self.assertEqual(json.loads(JSON.to_json(parsed))['database'], 'db')
172+
173+
response = GetTableResponse('id', 't', 'path', False, 0, Schema())
174+
self.assertNotIn('database', json.loads(JSON.to_json(response)))
175+
159176
def test_api(self):
160177
"""Example usage of RESTCatalogServer"""
161178
# Setup logging
@@ -207,6 +224,10 @@ def test_api(self):
207224
self.assertEqual(rest_api.get_database('default'), test_databases.get('default'))
208225
table = rest_api.get_table(Identifier.from_string('default.user'))
209226
self.assertEqual(table.id, str(test_tables['default.user'].uuid))
227+
self.assertEqual(table.get_database(), 'default')
228+
table_by_id = rest_api.get_table_by_id(table.id)
229+
self.assertEqual(table_by_id.get_database(), 'default')
230+
self.assertEqual(table_by_id.get_name(), 'user')
210231

211232
finally:
212233
# Shutdown server
@@ -293,6 +314,12 @@ def test_rest_api_parameter_validation(self):
293314
rest_api.get_database(" ")
294315
self.assertIn("Database name cannot be empty", str(context.exception))
295316

317+
# Test get_table_by_id with empty id
318+
for table_id in ("", " "):
319+
with self.assertRaises(ValueError) as context:
320+
rest_api.get_table_by_id(table_id)
321+
self.assertIn("Table id cannot be empty", str(context.exception))
322+
296323
# Test drop_database with empty name
297324
with self.assertRaises(ValueError) as context:
298325
rest_api.drop_database("")

‎paimon-python/pypaimon/tests/rest/resource_paths_test.py‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# under the License.
1717

1818

19+
import itertools
1920
import threading
2021
import unittest
2122
from http.server import BaseHTTPRequestHandler, HTTPServer
@@ -48,8 +49,9 @@ def test_database_and_table_names_are_url_encoded(self):
4849
paths.table("sales db", "orders/all"))
4950
self.assertEqual(
5051
"/v1/catalog%2Fid/databases/sales+db/table-details", paths.table_details("sales db"))
52+
self.assertEqual("/v1/catalog%2Fid/tables/id/a%2Fb+c", paths.table_by_id("a/b c"))
5153

52-
def test_table_details_signature_matches_the_path_sent(self):
54+
def test_signature_matches_the_path_sent(self):
5355
received = []
5456

5557
class Handler(BaseHTTPRequestHandler):
@@ -74,22 +76,24 @@ def do_GET(self):
7476
token = DLFToken("test-ak", "test-sk", "test-token", None)
7577
paths = ResourcePaths("catalog~id")
7678

79+
builders = ((paths.table_details, "/v1/catalog~id/databases/{}/table-details"),
80+
(paths.table_by_id, "/v1/catalog~id/tables/id/{}"))
7781
for algorithm in ("default", "openapi-v4"):
7882
provider = DLFAuthProvider(uri, "cn-hangzhou", algorithm, token=token)
79-
for database, encoded in (("sales~db", "sales~db"),
80-
("sales~ db/a+b*", "sales~+db%2Fa%2Bb*"),
81-
("sales%7Edb", "sales%257Edb")):
82-
with self.subTest(algorithm=algorithm, database=database):
83+
for (build, template), (name, encoded) in itertools.product(
84+
builders, (("sales~db", "sales~db"),
85+
("sales~ db/a+b*", "sales~+db%2Fa%2Bb*"),
86+
("sales%7Edb", "sales%257Edb"))):
87+
with self.subTest(algorithm=algorithm, path=template, name=name):
8388
signed = []
8489

8590
def sign(parameter):
8691
signed.append(parameter.path)
8792
return provider.merge_auth_header({}, parameter)
8893

89-
client.get(paths.table_details(database), None, sign)
94+
client.get(build(name), None, sign)
9095
path, headers = received[-1]
91-
self.assertEqual(
92-
"/v1/catalog~id/databases/{}/table-details".format(encoded), path)
96+
self.assertEqual(template.format(encoded), path)
9397
expected = provider.signer.authorization(
9498
RESTAuthParameter("GET", path, "", {}), token,
9599
provider.extract_host(uri), headers)

0 commit comments

Comments
 (0)