Skip to content

Commit 099ce38

Browse files
Merge pull request #43 from hotdata-dev/feat/mode-and-key-on-managed-load
feat(managed): mode + key on managed-table loads
2 parents 05af97b + 11ee270 commit 099ce38

6 files changed

Lines changed: 104 additions & 9 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `load_managed_table(..., mode=...)` selects the load mode (`replace` (default), `append`, `delete`, `update`, `upsert`) instead of always replacing the table. `replace`/`append` apply the upload directly; `delete`/`update`/`upsert` match rows by the table's declared key. Backward compatible — omitting `mode` still replaces.
13+
- `create_managed_database(..., keys={table: [cols]})` and `add_managed_table(..., key=[cols])` declare a table's row-identity key, enabling the key-based load modes on it. Requires a `hotdata` client whose managed-table decl models carry `key` (see the dependency floor bump); tables declared without a key stay `replace`/`append`-only.
1014

1115
## [0.6.3] - 2026-07-08
1216

‎hotdata_framework/client.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -241,14 +241,21 @@ def create_managed_database(
241241
*,
242242
schema: str = DEFAULT_SCHEMA,
243243
tables: list[str] | None = None,
244+
keys: dict[str, list[str]] | None = None,
244245
expires_at: str | None = None,
245246
) -> ManagedDatabase:
247+
"""Create a managed database. ``keys`` maps a table to its key columns
248+
(enabling delete/update/upsert on it); omitted tables are keyless."""
249+
keys = keys or {}
246250
schemas = None
247251
if tables:
248252
schemas = [
249253
DatabaseDefaultSchemaDecl(
250254
name=schema,
251-
tables=[DatabaseDefaultTableDecl(name=t) for t in tables],
255+
tables=[
256+
DatabaseDefaultTableDecl(name=t, key=list(keys.get(t, [])))
257+
for t in tables
258+
],
252259
)
253260
]
254261
request = CreateDatabaseRequest(
@@ -314,6 +321,7 @@ def load_managed_table(
314321
schema: str = DEFAULT_SCHEMA,
315322
upload_id: str | None = None,
316323
file: str | None = None,
324+
mode: str = "replace",
317325
) -> LoadManagedTableResult:
318326
if (upload_id is None) == (file is None):
319327
raise ValueError("Exactly one of upload_id or file is required")
@@ -324,7 +332,7 @@ def load_managed_table(
324332
assert file is not None
325333
resolved_upload_id = self.upload_parquet(file)
326334
request = LoadManagedTableRequest(
327-
mode="replace",
335+
mode=mode,
328336
upload_id=resolved_upload_id,
329337
)
330338
try:
@@ -350,15 +358,17 @@ def add_managed_table(
350358
table: str,
351359
*,
352360
schema: str = DEFAULT_SCHEMA,
361+
key: list[str] | None = None,
353362
) -> ManagedTable:
354363
"""Declare a new table on an existing managed database.
355364
356365
The table is added empty (declared-but-unloaded); populate it with
357366
:meth:`load_managed_table`. Use this to evolve a managed database's
358-
schema after creation without recreating it.
367+
schema after creation without recreating it. ``key`` sets the
368+
row-identity columns for delete/update/upsert; omit for keyless.
359369
"""
360370
db = self.resolve_managed_database(database)
361-
request = AddManagedTableRequest(name=table)
371+
request = AddManagedTableRequest(name=table, key=list(key or []))
362372
try:
363373
self._databases_api().add_database_table(db.id, schema, request)
364374
except ApiException as e:

‎hotdata_framework/managed_client.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,15 @@ def load_managed_table(
203203
*,
204204
schema: str,
205205
upload_id: str,
206+
mode: str = "replace",
206207
) -> LoadManagedTableResult:
207208
return self._request_with_retry(
208209
lambda: self._runtime.load_managed_table(
209210
database,
210211
table,
211212
schema=schema,
212213
upload_id=upload_id,
214+
mode=mode,
213215
)
214216
)
215217

‎pyproject.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ classifiers = [
2626
"Typing :: Typed",
2727
]
2828
dependencies = [
29-
"hotdata>=0.6.0",
29+
# 0.7.0 adds `key` to table decls (create_managed_database(keys=) / add_managed_table(key=))
30+
"hotdata>=0.7.0",
3031
"pandas>=2.0",
3132
"pyarrow>=14.0",
3233
]

‎tests/test_databases.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import pytest
77
from hotdata.exceptions import ApiException
8+
from hotdata.models.database_default_table_decl import DatabaseDefaultTableDecl
89

910
from hotdata_framework.client import HotdataClient
1011
from hotdata_framework.databases import (
@@ -13,6 +14,20 @@
1314
)
1415

1516

17+
def _decl_key_supported() -> bool:
18+
# `key` ships with the regenerated client; the key tests activate once it does.
19+
try:
20+
return DatabaseDefaultTableDecl(name="t", key=["k"]).key == ["k"]
21+
except Exception:
22+
return False
23+
24+
25+
requires_key_field = pytest.mark.skipif(
26+
not _decl_key_supported(),
27+
reason="hotdata client without `key` on managed-table decls",
28+
)
29+
30+
1631
def _client() -> HotdataClient:
1732
return HotdataClient("k", "ws", host="https://api.hotdata.dev")
1833

@@ -230,6 +245,69 @@ def test_load_managed_table_requires_exactly_one_source():
230245
)
231246

232247

248+
def _load_and_capture_request(client, **kwargs):
249+
db = managed_database_from_detail(_detail())
250+
loaded = SimpleNamespace(
251+
connection_id="conn_1", schema_name="public", table_name="orders", row_count=1
252+
)
253+
with (
254+
patch.object(client, "resolve_managed_database", return_value=db),
255+
patch.object(client, "connections") as connections,
256+
):
257+
connections.return_value.load_managed_table.return_value = loaded
258+
client.load_managed_table("db_1", "orders", upload_id="upl_1", **kwargs)
259+
return connections.return_value.load_managed_table.call_args.args[3]
260+
261+
262+
def test_load_managed_table_defaults_to_replace():
263+
assert _load_and_capture_request(_client()).mode == "replace"
264+
265+
266+
@pytest.mark.parametrize("mode", ["append", "delete", "update", "upsert"])
267+
def test_load_managed_table_forwards_mode(mode: str):
268+
assert _load_and_capture_request(_client(), mode=mode).mode == mode
269+
270+
271+
@requires_key_field
272+
def test_create_managed_database_declares_keys():
273+
client = _client()
274+
with patch.object(client, "_databases_api") as dbs:
275+
dbs.return_value.create_database.return_value = _detail(id="db_new")
276+
client.create_managed_database(
277+
"mydb", tables=["orders", "events"], keys={"orders": ["id"]}
278+
)
279+
req = dbs.return_value.create_database.call_args.args[0]
280+
declared = {t.name: list(t.key) for t in req.schemas[0].tables}
281+
assert declared == {"orders": ["id"], "events": []}
282+
283+
284+
@requires_key_field
285+
def test_add_managed_table_declares_key():
286+
client = _client()
287+
db = managed_database_from_detail(_detail())
288+
with (
289+
patch.object(client, "resolve_managed_database", return_value=db),
290+
patch.object(client, "_databases_api") as dbs,
291+
):
292+
client.add_managed_table("db_1", "line_items", key=["order_id", "sku"])
293+
req = dbs.return_value.add_database_table.call_args.args[2]
294+
assert req.name == "line_items"
295+
assert list(req.key) == ["order_id", "sku"]
296+
297+
298+
@requires_key_field
299+
def test_add_managed_table_keyless_by_default():
300+
client = _client()
301+
db = managed_database_from_detail(_detail())
302+
with (
303+
patch.object(client, "resolve_managed_database", return_value=db),
304+
patch.object(client, "_databases_api") as dbs,
305+
):
306+
client.add_managed_table("db_1", "orders")
307+
req = dbs.return_value.add_database_table.call_args.args[2]
308+
assert list(req.key) == []
309+
310+
233311
def test_delete_managed_table_uses_default_connection_id():
234312
client = _client()
235313
db = managed_database_from_detail(_detail())

‎uv.lock‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)