Skip to content

Commit 0fb624d

Browse files
feat: accept resolved ManagedDatabase in managed-table ops to skip read probe (#52)
Add HotdataClient._as_managed_database and widen list_managed_tables, load_managed_table, add_managed_table, delete_managed_table, delete_managed_database, and execute_sql to accept str | ManagedDatabase. Passing an already-resolved ManagedDatabase (as returned by create_managed_database) skips the get_database/list_databases read probe, letting a create+load-scoped key bootstrap and load into a managed database in a single run without a forbidden read. The name/id string path is unchanged, and resolve_managed_database's 403->RuntimeError behavior is preserved. Additive change: minor version bump 0.8.0 -> 0.9.0. Refs hotdata-dlt-destination#55, hotdata-dlt-destination#39.
1 parent 2ec35c5 commit 0fb624d

6 files changed

Lines changed: 171 additions & 18 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010

11+
## [0.9.0] - 2026-07-23
12+
13+
### Added
14+
15+
- `list_managed_tables`, `load_managed_table`, `add_managed_table`,
16+
`delete_managed_table`, `delete_managed_database`, and `execute_sql` accept an
17+
already-resolved `ManagedDatabase` (as returned by `create_managed_database`)
18+
in place of a name/id. When passed one, they skip the `get_database` /
19+
`list_databases` read probe. This lets an API key scoped to create + load but
20+
not read `/databases` bootstrap a managed database and load into it within a
21+
single run: the caller holds the `ManagedDatabase` from `create` and drives
22+
the load/add/query ops with zero reads. The name/id string path is unchanged.
23+
1124
## [0.8.0] - 2026-07-20
1225

1326
### Changed

‎CONTRACT.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,14 @@ Adapters should import from `hotdata_framework` and treat this surface as the st
5656
adapters should pass `connection_id` when known.
5757
- `uploads()` returns the uploads API wrapper for parquet staging.
5858
- `list_managed_databases()` returns all databases via the `/databases` API.
59-
- `resolve_managed_database(name_or_id)` resolves a database by id (direct lookup) or description (list scan).
60-
- `create_managed_database(description=..., schema=..., tables=..., expires_at=...)` creates a database via the `/databases` API and optionally declares tables up front.
59+
- `resolve_managed_database(name_or_id)` resolves a database by id (direct lookup) or description (list scan). A `403` from `/databases` surfaces as `RuntimeError` (forbidden, not absent), preserving the underlying `ApiException` as `__cause__`.
60+
- `create_managed_database(description=..., schema=..., tables=..., expires_at=...)` creates a database via the `/databases` API and optionally declares tables up front. Returns a `ManagedDatabase` (id + `default_connection_id`) sufficient to load without a further read.
6161
- `delete_managed_database(name_or_id)` deletes a database via the `/databases` API.
6262
- `list_managed_tables(database, schema=...)` lists tables in a managed database.
6363
- `upload_parquet(path)` uploads a local parquet file and returns an upload id.
6464
- `load_managed_table(database, table, schema=..., upload_id=..., file=...)` publishes parquet data into a declared managed table.
6565
- `delete_managed_table(database, table, schema=...)` deletes a managed table.
66+
- The `database` argument of `list_managed_tables`, `load_managed_table`, `add_managed_table`, `delete_managed_table`, `delete_managed_database`, and `execute_sql` accepts a name/id **or** an already-resolved `ManagedDatabase`. Passing a `ManagedDatabase` skips the name/id read probe, so a create-scoped key that cannot read `/databases` can load into a database it just created.
6667

6768
### `QueryResult`
6869

‎hotdata_framework/client.py‎

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,19 @@ def resolve_managed_database(self, name_or_id: str) -> ManagedDatabase:
241241
raise RuntimeError(api_error_message(e)) from e
242242
return managed_database_from_detail(detail)
243243

244+
def _as_managed_database(self, database: str | ManagedDatabase) -> ManagedDatabase:
245+
"""Return ``database`` as-is if it is already a resolved ``ManagedDatabase``,
246+
otherwise resolve it by name or id.
247+
248+
Passing an already-resolved ``ManagedDatabase`` (e.g. the value returned by
249+
:meth:`create_managed_database`) skips the id/name read probe, so callers
250+
whose API key may create but not read ``/databases`` can drive loads without
251+
a forbidden read.
252+
"""
253+
if isinstance(database, ManagedDatabase):
254+
return database
255+
return self.resolve_managed_database(database)
256+
244257
def create_managed_database(
245258
self,
246259
description: str | None = None,
@@ -275,20 +288,20 @@ def create_managed_database(
275288
raise RuntimeError(api_error_message(e)) from e
276289
return managed_database_from_detail(created)
277290

278-
def delete_managed_database(self, name_or_id: str) -> None:
279-
db = self.resolve_managed_database(name_or_id)
291+
def delete_managed_database(self, name_or_id: str | ManagedDatabase) -> None:
292+
db = self._as_managed_database(name_or_id)
280293
try:
281294
self._databases_api().delete_database(db.id)
282295
except ApiException as e:
283296
raise RuntimeError(api_error_message(e)) from e
284297

285298
def list_managed_tables(
286299
self,
287-
database: str,
300+
database: str | ManagedDatabase,
288301
*,
289302
schema: str | None = None,
290303
) -> list[ManagedTable]:
291-
db = self.resolve_managed_database(database)
304+
db = self._as_managed_database(database)
292305
rows: list[ManagedTable] = []
293306
for t in self.iter_tables(connection_id=db.default_connection_id):
294307
if schema is not None and t.var_schema != schema:
@@ -333,7 +346,7 @@ def upload_parquet(self, path: str) -> str:
333346

334347
def load_managed_table(
335348
self,
336-
database: str,
349+
database: str | ManagedDatabase,
337350
table: str,
338351
*,
339352
schema: str = DEFAULT_SCHEMA,
@@ -344,7 +357,7 @@ def load_managed_table(
344357
) -> LoadManagedTableResult:
345358
if (upload_id is None) == (file is None):
346359
raise ValueError("Exactly one of upload_id or file is required")
347-
db = self.resolve_managed_database(database)
360+
db = self._as_managed_database(database)
348361
if upload_id is not None:
349362
resolved_upload_id = upload_id
350363
else:
@@ -374,7 +387,7 @@ def load_managed_table(
374387

375388
def add_managed_table(
376389
self,
377-
database: str,
390+
database: str | ManagedDatabase,
378391
table: str,
379392
*,
380393
schema: str = DEFAULT_SCHEMA,
@@ -387,7 +400,7 @@ def add_managed_table(
387400
schema after creation without recreating it. ``key`` sets the
388401
row-identity columns for delete/update/upsert; omit for keyless.
389402
"""
390-
db = self.resolve_managed_database(database)
403+
db = self._as_managed_database(database)
391404
request = AddManagedTableRequest(name=table, key=list(key or []))
392405
try:
393406
self._databases_api().add_database_table(db.id, schema, request)
@@ -403,12 +416,12 @@ def add_managed_table(
403416

404417
def delete_managed_table(
405418
self,
406-
database: str,
419+
database: str | ManagedDatabase,
407420
table: str,
408421
*,
409422
schema: str = DEFAULT_SCHEMA,
410423
) -> None:
411-
db = self.resolve_managed_database(database)
424+
db = self._as_managed_database(database)
412425
try:
413426
self.connections().delete_managed_table(db.default_connection_id, schema, table)
414427
except ApiException as e:
@@ -569,16 +582,19 @@ def _wait_result_ready(
569582
f"(last status: {getattr(last, 'status', None)})"
570583
)
571584

572-
def execute_sql(self, sql: str, *, database: str | None = None) -> QueryResult:
585+
def execute_sql(
586+
self, sql: str, *, database: str | ManagedDatabase | None = None
587+
) -> QueryResult:
573588
"""Execute SQL and return a :class:`QueryResult`.
574589
575-
Pass ``database`` to scope the query to a managed database. The name
576-
is resolved to a database ID once before the retry loop, and the
590+
Pass ``database`` to scope the query to a managed database. A name or
591+
id is resolved to a database ID once before the retry loop; an
592+
already-resolved ``ManagedDatabase`` is used as-is (no read probe). The
577593
``X-Database-Id`` header is sent with every attempt. Inside a managed
578594
database the built-in catalog is always ``"default"``, so table
579595
references should use ``"default"."<schema>"."<table>"``.
580596
"""
581-
database_id = self.resolve_managed_database(database).id if database else None
597+
database_id = self._as_managed_database(database).id if database else None
582598
last_err: BaseException | None = None
583599
for attempt in range(3):
584600
try:

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "hotdata-framework"
7-
version = "0.8.0"
7+
version = "0.9.0"
88
description = "Python framework for building Hotdata integrations: workspace/session runtime, query execution, and managed databases"
99
readme = "README.md"
1010
requires-python = ">=3.10"

‎tests/test_client.py‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,134 @@
44
from unittest.mock import patch
55

66
import pytest
7+
from hotdata.exceptions import ForbiddenException
78

89
from hotdata_framework.client import HotdataClient
10+
from hotdata_framework.databases import ManagedDatabase
911
from hotdata_framework.env import normalize_host, pick_workspace, resolve_workspace_selection
1012

1113

14+
class _ForbiddenDatabasesApi:
15+
"""A `/databases` API that a create-scoped key would see: every read is 403,
16+
while the declare-table write succeeds. Counts reads so tests can assert none
17+
happened."""
18+
19+
def __init__(self) -> None:
20+
self.read_calls = 0
21+
self.add_calls: list[tuple[str, str, str]] = []
22+
23+
def get_database(self, database_id: str):
24+
self.read_calls += 1
25+
raise ForbiddenException(status=403)
26+
27+
def list_databases(self):
28+
self.read_calls += 1
29+
raise ForbiddenException(status=403)
30+
31+
def add_database_table(self, database_id, var_schema, request):
32+
self.add_calls.append((database_id, var_schema, request.name))
33+
return SimpleNamespace(
34+
connection_id="conn", var_schema=var_schema, table=request.name
35+
)
36+
37+
38+
class _FakeConnectionsApi:
39+
def __init__(self) -> None:
40+
self.load_calls: list[tuple[str, str, str]] = []
41+
42+
def load_managed_table(self, connection_id, schema, table, request):
43+
self.load_calls.append((connection_id, schema, table))
44+
return SimpleNamespace(
45+
connection_id=connection_id,
46+
schema_name=schema,
47+
table_name=table,
48+
row_count=3,
49+
)
50+
51+
52+
def test_load_managed_table_with_object_skips_read_probe():
53+
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
54+
db = ManagedDatabase(id="db_1", description="mydb", default_connection_id="conn_1")
55+
databases = _ForbiddenDatabasesApi()
56+
connections = _FakeConnectionsApi()
57+
58+
with (
59+
patch.object(client, "_databases_api", return_value=databases),
60+
patch.object(client, "connections", return_value=connections),
61+
):
62+
result = client.load_managed_table(db, "orders", schema="public", upload_id="up_1")
63+
64+
assert databases.read_calls == 0
65+
assert connections.load_calls == [("conn_1", "public", "orders")]
66+
assert result.full_name == "db_1.public.orders"
67+
assert result.row_count == 3
68+
69+
70+
def test_add_managed_table_with_object_skips_read_probe():
71+
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
72+
db = ManagedDatabase(id="db_1", description="mydb", default_connection_id="conn_1")
73+
databases = _ForbiddenDatabasesApi()
74+
75+
with patch.object(client, "_databases_api", return_value=databases):
76+
result = client.add_managed_table(db, "orders", schema="public")
77+
78+
assert databases.read_calls == 0
79+
assert databases.add_calls == [("db_1", "public", "orders")]
80+
assert result.full_name == "db_1.public.orders"
81+
82+
83+
def test_execute_sql_with_object_skips_read_probe():
84+
from hotdata.models.query_response import QueryResponse as _QR
85+
86+
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
87+
db = ManagedDatabase(id="db_abc", description="mydb", default_connection_id="conn_1")
88+
databases = _ForbiddenDatabasesApi()
89+
90+
class FakeQueryApi:
91+
def __init__(self):
92+
self.calls: list[dict] = []
93+
94+
def query(self, request, **kwargs):
95+
self.calls.append(kwargs)
96+
return _QR(
97+
columns=["n"],
98+
rows=[[1]],
99+
row_count=1,
100+
preview_row_count=1,
101+
truncated=False,
102+
nullable=[False],
103+
result_id="res_1",
104+
query_run_id="qrun_1",
105+
execution_time_ms=1,
106+
)
107+
108+
fake_q = FakeQueryApi()
109+
with (
110+
patch.object(client, "_query_api", return_value=fake_q),
111+
patch.object(client, "_databases_api", return_value=databases),
112+
):
113+
client.execute_sql("SELECT 1", database=db)
114+
115+
assert databases.read_calls == 0
116+
assert fake_q.calls == [{"x_database_id": "db_abc"}]
117+
118+
119+
def test_load_managed_table_with_name_still_resolves():
120+
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
121+
connections = _FakeConnectionsApi()
122+
resolved = ManagedDatabase(id="db_1", description="mydb", default_connection_id="conn_1")
123+
124+
with (
125+
patch.object(client, "resolve_managed_database", return_value=resolved) as resolve,
126+
patch.object(client, "connections", return_value=connections),
127+
):
128+
result = client.load_managed_table("mydb", "orders", schema="public", upload_id="up_1")
129+
130+
resolve.assert_called_once_with("mydb")
131+
assert connections.load_calls == [("conn_1", "public", "orders")]
132+
assert result.full_name == "db_1.public.orders"
133+
134+
12135
def _clear_workspace_env(monkeypatch: pytest.MonkeyPatch) -> None:
13136
monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False)
14137

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)