Skip to content

Commit 5dcfe57

Browse files
authored
feat: declare and read back a managed table's storage layout (#69)
* feat: declare and read back a managed table's storage layout Closes #55 add_managed_table() and create_managed_database() take partition_by / sorted_by; managed_table_layout() reads back what was actually declared. TablePartitionKey and TableSortKey are re-exported so callers need one import. BOTH DIRECTIONS, because either alone is not usable. A layout is fixed when the table is created and there is no alter path -- a table declared without one keeps that shape until it is recreated and its data rewritten. So declaring is half the job: a caller has to be able to confirm it took, and to refuse to load when it cannot. That is why at least one consumer hand-built the HTTP request rather than using this package: the write side dropped the fields and the read side had nowhere to report them. managed_table_layout() raises KeyError for a table that is not declared instead of returning an empty layout. "Not there" and "declared without a layout" lead a caller to opposite decisions. The generated key models are passed through rather than wrapped in a parallel type system. This is a thin wrapper over a GENERATED client, so the transform vocabulary and field names stay exactly the API's, and the strict mypy config is satisfied without Any. hotdata floor raised to >=0.9.0,<0.10. 0.9.0 is the first release carrying partition_by / sorted_by on all three models this needs -- the add-table request, the create-database table declarations, and the table-info response. On an older hotdata the fields are silently dropped by the model and the table is declared without a layout, returning success. Verified against the published 0.9.0 wheel. TESTS ASSERT THE SERIALISED REQUEST, not that the call succeeded, because the failure mode is silence: a field the model does not know about vanishes at to_dict() and the API returns 201. Sabotaged all three: drop the layout from the request -> caught send empty arrays instead of omitting -> caught collapse missing-table into empty -> caught 133 -> 138 passed. ruff 2 -> 1 (the auto-fix also cleaned a pre-existing import-order issue in client.py; the hotdata.uploads comment stayed with its import, checked). mypy unchanged at 158, all pre-existing. * fix: refuse layout for a table not being created, and read it back in one request Five review points, all taken. create_managed_database now raises ValueError when partition_by or sorted_by names a table that is not in `tables`. Previously a typo -- {"fils": ...} against tables=["files"] -- was dropped in silence and the intended table created flat, which is permanent because a layout is fixed at creation with no alter path. The asymmetry with `keys` is deliberate: a wrong key is recoverable, since load_managed_table takes `key=` per call. managed_table_layout now filters server-side (var_schema=/table=/limit=1) instead of paging iter_tables until it finds a match, which cost several round trips for a table sorting late in the listing. Same KeyError contract. The absent-vs-unpartitioned test shared one response object across both calls, so its second assertion inspected a consumed result rather than the case named in the docstring. Each call now gets a fresh response, and the fake answers by table name so the "listing has files but not missing" scenario is the one exercised. TableLayout gains to_dict(), matching every other public dataclass here. Not asdict(): the key lists hold pydantic models, which asdict copies through untouched, so each key is mapped through its own to_dict(). The reviewer assumed the omission was deliberate -- it was not. CONTRACT.md lists managed_table_layout among the methods accepting an already-resolved ManagedDatabase, which matters because a create-scoped key that cannot read /databases is exactly the caller needing this read-back. Re-sabotaged after the rewrite -- dropping the layout, collapsing missing-table into an empty layout, and removing the unknown-table guard are each caught. 138 -> 139 passed. * fix: drop TableLayout.to_dict rather than add strict-mypy debt I added to_dict() for surface consistency and then checked the gates properly: it added 8 errors under this package s strict mypy settings (dict[str, Any] plus comprehensions over the pydantic key models), taking the file from the 158 on main to 166. The alternative -- hand-building the dict from named fields -- is Any-free but silently drops any field a later spec adds to TablePartitionKey or TableSortKey, which is exactly the silent-drop failure this whole feature exists to prevent. So taking the reviewer s first option: no to_dict, with a comment recording why the inconsistency is deliberate, so the next reader does not "fix" it. A caller wanting dicts can map k.to_dict() itself. Also collapses a nested `with` flagged by ruff. Back to main s baseline exactly: mypy 158, ruff only the pre-existing long line in test_request_timeout.py. 139 passed.
1 parent 402e27d commit 5dcfe57

9 files changed

Lines changed: 366 additions & 13 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- Table storage layout, both directions. `add_managed_table()` and
13+
`create_managed_database()` take `partition_by` / `sorted_by`, and
14+
`managed_table_layout()` reads back what was actually declared as a
15+
`TableLayout`. `TablePartitionKey` and `TableSortKey` are re-exported so
16+
callers need one import.
17+
18+
Both halves matter because a layout is fixed when the table is created and
19+
there is no alter path: a table declared without one keeps that shape until it
20+
is recreated and its data rewritten. So declaring is not enough — a caller has
21+
to be able to confirm it took, and to refuse to load when it cannot.
22+
23+
`managed_table_layout()` raises `KeyError` for a table that is not declared,
24+
rather than returning an empty layout. "Not there" and "declared without a
25+
layout" lead to opposite decisions for a caller.
26+
27+
Until now this package could not express a layout at all, which is why at least
28+
one consumer hand-built the HTTP request instead. The generated key models are
29+
passed through rather than wrapped, so the transform vocabulary stays exactly
30+
the API's.
31+
32+
### Changed
33+
34+
- Require `hotdata>=0.9.0,<0.10`. 0.9.0 is the first release whose models carry
35+
`partition_by` / `sorted_by` on the add-table request, the create-database
36+
table declarations, and the table-info response. On an older `hotdata` the
37+
fields would be silently dropped by the model and the table declared without a
38+
layout, returning success — which is the failure this feature exists to end.
39+
1040
## [0.11.0] - 2026-08-11
1141

1242
### Changed

CONTRACT.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ The supported import surface is:
3131
- `WorkspaceSelection`
3232
- `ManagedDatabase`
3333
- `ManagedTable`
34+
- `TableLayout`
35+
- `TablePartitionKey`
36+
- `TableSortKey`
3437
- `LoadManagedTableResult`
3538
- `CreateIndexResult`
3639
- `DEFAULT_SCHEMA`
@@ -56,6 +59,8 @@ Adapters should import from `hotdata_framework` and treat this surface as the st
5659
adapters should pass `connection_id` when known.
5760
- `uploads()` returns the uploads API wrapper for parquet staging.
5861
- `list_managed_databases()` returns all databases via the `/databases` API.
62+
- `add_managed_table(...)` and `create_managed_database(...)` accept `partition_by` / `sorted_by` to declare a table's storage layout. The layout is fixed when the table is created and cannot be altered afterwards, so omitting it is permanent for that table.
63+
- `managed_table_layout(database, table, schema=...)` returns the declared layout as `TableLayout`. Empty lists mean no layout was declared — sound only because the table is resolved through a managed database. Raises `KeyError` when the table is not declared, keeping "absent" distinct from "declared without a layout".
5964
- `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__`.
6065
- `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.
6166
- `delete_managed_database(name_or_id)` deletes a database via the `/databases` API.
@@ -64,7 +69,7 @@ Adapters should import from `hotdata_framework` and treat this surface as the st
6469
- `load_managed_table(database, table, schema=..., upload_id=..., file=...)` publishes parquet data into a declared managed table.
6570
- `delete_managed_table(database, table, schema=...)` deletes a managed table.
6671
- `create_index(database, table, schema=..., columns=..., index_type=..., index_name=...)` builds a `"sorted"`, `"bm25"`, or `"vector"` index on a managed table and returns a `CreateIndexResult`. It is the framework-side equivalent of the CLI's `hotdata indexes create`; indexing a table on a plain (non-managed) connection is out of scope. `index_name` defaults to `{table}_{columns}_{index_type}`, matching the CLI's derivation when `--name` is omitted. `index_type` is required rather than defaulting to the API's `"sorted"`. The build runs as a background job; the call polls it to a terminal state and raises `RuntimeError` with the job's `error_message` when it fails, because the submit call reports success regardless. `wait=False` returns as soon as the job is accepted, with `status="pending"` and a `job_id` for the caller to poll. For `index_type="vector"`, omitting `embedding_provider_id` indexes an existing vector column and `metric` (`"l2"`, `"cosine"`, `"dot"`) selects the distance function the index accelerates — a query using a different function silently falls back to a full scan; setting `embedding_provider_id` indexes a source *text* column instead, and the returned `source_column` names the column to pass to `vector_distance`. Argument combinations the server would silently ignore raise `ValueError` before any request is sent.
67-
- The `database` argument of `list_managed_tables`, `load_managed_table`, `add_managed_table`, `delete_managed_table`, `delete_managed_database`, `create_index`, 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.
72+
- The `database` argument of `list_managed_tables`, `load_managed_table`, `add_managed_table`, `delete_managed_table`, `delete_managed_database`, `create_index`, `managed_table_layout`, 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.
6873

6974
### `QueryResult`
7075

hotdata_framework/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
from importlib.metadata import PackageNotFoundError, version
44

5+
from hotdata.models.table_partition_key import TablePartitionKey
6+
from hotdata.models.table_sort_key import TableSortKey
7+
58
from hotdata_framework.client import (
69
HotdataClient,
710
ResultSummary,
@@ -14,6 +17,7 @@
1417
LoadManagedTableResult,
1518
ManagedDatabase,
1619
ManagedTable,
20+
TableLayout,
1721
is_parquet_path,
1822
)
1923
from hotdata_framework.env import (
@@ -55,6 +59,9 @@
5559
"QueryResult",
5660
"ResultSummary",
5761
"RunHistoryItem",
62+
"TableLayout",
63+
"TablePartitionKey",
64+
"TableSortKey",
5865
"WorkspaceSelection",
5966
"__version__",
6067
"classify_sdk_error",

hotdata_framework/client.py

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import functools
44
import time
5-
from collections.abc import Iterator
5+
from collections.abc import Iterator, Sequence
66
from dataclasses import asdict, dataclass
77
from typing import Any, Literal, get_args
88

@@ -15,9 +15,6 @@
1515
from hotdata.api.query_api import QueryApi
1616
from hotdata.api.query_runs_api import QueryRunsApi
1717
from hotdata.api.results_api import ResultsApi
18-
# The enriched wrapper (hotdata.uploads), NOT the generated hotdata.api class:
19-
# it adds the full upload_file orchestration used by upload_parquet.
20-
from hotdata.uploads import UploadError, UploadsApi
2118
from hotdata.exceptions import ApiException
2219
from hotdata.models.add_managed_table_request import AddManagedTableRequest
2320
from hotdata.models.async_query_response import AsyncQueryResponse
@@ -32,6 +29,12 @@
3229
from hotdata.models.query_response import QueryResponse
3330
from hotdata.models.submit_job_response import SubmitJobResponse
3431
from hotdata.models.table_info import TableInfo
32+
from hotdata.models.table_partition_key import TablePartitionKey
33+
from hotdata.models.table_sort_key import TableSortKey
34+
35+
# The enriched wrapper (hotdata.uploads), NOT the generated hotdata.api class:
36+
# it adds the full upload_file orchestration used by upload_parquet.
37+
from hotdata.uploads import UploadError, UploadsApi
3538
from urllib3.exceptions import HTTPError as Urllib3HTTPError
3639
from urllib3.exceptions import ProtocolError
3740

@@ -41,6 +44,7 @@
4144
LoadManagedTableResult,
4245
ManagedDatabase,
4346
ManagedTable,
47+
TableLayout,
4448
api_error_message,
4549
enum_value,
4650
is_parquet_path,
@@ -286,18 +290,44 @@ def create_managed_database(
286290
schema: str = DEFAULT_SCHEMA,
287291
tables: list[str] | None = None,
288292
keys: dict[str, list[str]] | None = None,
293+
partition_by: dict[str, Sequence[TablePartitionKey]] | None = None,
294+
sorted_by: dict[str, Sequence[TableSortKey]] | None = None,
289295
expires_at: str | None = None,
290296
) -> ManagedDatabase:
291297
"""Create a managed database. ``keys`` maps a table to its key columns
292-
(enabling delete/update/upsert on it); omitted tables are keyless."""
298+
(enabling delete/update/upsert on it); omitted tables are keyless.
299+
300+
``partition_by`` and ``sorted_by`` are keyed the same way — table name to
301+
that table's keys, in declaration order — so a database can be created
302+
with its tables already laid out. Tables absent from the mapping get no
303+
layout, and a layout cannot be added afterwards: it is fixed at table
304+
creation, so a table created here without one stays that way.
305+
"""
293306
keys = keys or {}
307+
partition_by = partition_by or {}
308+
sorted_by = sorted_by or {}
309+
# A layout aimed at a table that is not being created would otherwise be
310+
# dropped in silence, and the table it was meant for created flat — which
311+
# is permanent, since a layout is fixed at creation with no alter path. A
312+
# typo'd `keys` entry costs nothing by comparison: load_managed_table
313+
# takes `key=` per call, so it can be corrected later.
314+
unknown = (set(partition_by) | set(sorted_by)) - set(tables or ())
315+
if unknown:
316+
raise ValueError(
317+
f"layout given for tables not being created: {', '.join(sorted(unknown))}"
318+
)
294319
schemas = None
295320
if tables:
296321
schemas = [
297322
DatabaseDefaultSchemaDecl(
298323
name=schema,
299324
tables=[
300-
DatabaseDefaultTableDecl(name=t, key=list(keys.get(t, [])))
325+
DatabaseDefaultTableDecl(
326+
name=t,
327+
key=list(keys.get(t, [])),
328+
partition_by=list(partition_by.get(t, ())) or None,
329+
sorted_by=list(sorted_by.get(t, ())) or None,
330+
)
301331
for t in tables
302332
],
303333
)
@@ -417,16 +447,33 @@ def add_managed_table(
417447
*,
418448
schema: str = DEFAULT_SCHEMA,
419449
key: list[str] | None = None,
450+
partition_by: Sequence[TablePartitionKey] | None = None,
451+
sorted_by: Sequence[TableSortKey] | None = None,
420452
) -> ManagedTable:
421453
"""Declare a new table on an existing managed database.
422454
423455
The table is added empty (declared-but-unloaded); populate it with
424456
:meth:`load_managed_table`. Use this to evolve a managed database's
425457
schema after creation without recreating it. ``key`` sets the
426458
row-identity columns for delete/update/upsert; omit for keyless.
459+
460+
``partition_by`` and ``sorted_by`` declare the table's storage layout, in
461+
the order given. THIS IS THE ONLY CHANCE TO SET IT: a layout is fixed
462+
when the table is created and there is no alter path, so a table declared
463+
without one keeps that shape until it is recreated and its data rewritten.
464+
Confirm what was applied with :meth:`managed_table_layout`.
465+
466+
The generated key models are passed through rather than wrapped, so the
467+
transform vocabulary and field names stay exactly the API's. Both are
468+
re-exported from ``hotdata_framework`` so callers need one import.
427469
"""
428470
db = self._as_managed_database(database)
429-
request = AddManagedTableRequest(name=table, key=list(key or []))
471+
request = AddManagedTableRequest(
472+
name=table,
473+
key=list(key or []),
474+
partition_by=list(partition_by) if partition_by else None,
475+
sorted_by=list(sorted_by) if sorted_by else None,
476+
)
430477
try:
431478
self._databases_api().add_database_table(db.id, schema, request)
432479
except ApiException as e:
@@ -439,6 +486,51 @@ def add_managed_table(
439486
last_sync=None,
440487
)
441488

489+
def managed_table_layout(
490+
self,
491+
database: str | ManagedDatabase,
492+
table: str,
493+
*,
494+
schema: str = DEFAULT_SCHEMA,
495+
) -> TableLayout:
496+
"""Read back a managed table's declared storage layout.
497+
498+
The counterpart to the ``partition_by`` / ``sorted_by`` arguments on
499+
:meth:`add_managed_table` and :meth:`create_managed_database`. Declaring a
500+
layout is only half of it: it is fixed at table creation with no alter
501+
path, so a caller that cares whether the layout took has to look, and a
502+
caller that cannot confirm it should refuse to load rather than fill a
503+
table it can never repair.
504+
505+
Empty lists here mean no layout was declared. That reading is sound
506+
because the table is resolved through a managed database — the same fields
507+
on a table discovered from an external connection are empty because its
508+
layout belongs to the upstream system, which is not the same claim.
509+
510+
Raises KeyError when the table is not present on the database, so that
511+
"no such table" is distinguishable from "declared without a layout"; the
512+
two are very different for a caller deciding whether to load.
513+
"""
514+
db = self._as_managed_database(database)
515+
# Filtered server-side rather than paging iter_tables: this answers a
516+
# single-table question, and a table sorting late in the listing would
517+
# otherwise cost several round trips. include_columns is left off — the
518+
# layout lives on the table row, not the columns.
519+
resp = self._information_schema().information_schema(
520+
connection_id=db.default_connection_id,
521+
var_schema=schema,
522+
table=table,
523+
limit=1,
524+
)
525+
for info in resp.tables:
526+
return TableLayout(
527+
schema_name=schema,
528+
table_name=table,
529+
partition_by=list(info.partition_by or []),
530+
sorted_by=list(info.sorted_by or []),
531+
)
532+
raise KeyError(f"{schema}.{table} is not declared on database {db.id}")
533+
442534
def delete_managed_table(
443535
self,
444536
database: str | ManagedDatabase,

hotdata_framework/databases.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from typing import Any
88

99
from hotdata.exceptions import ApiException
10+
from hotdata.models.table_partition_key import TablePartitionKey
11+
from hotdata.models.table_sort_key import TableSortKey
1012

1113
DEFAULT_SCHEMA = "public"
1214

@@ -33,6 +35,47 @@ def to_dict(self) -> dict[str, Any]:
3335
return asdict(self)
3436

3537

38+
@dataclass(frozen=True)
39+
class TableLayout:
40+
"""A managed table's declared storage layout, as the server reports it.
41+
42+
Both lists carry the generated `TablePartitionKey` / `TableSortKey` models,
43+
in the order they were declared. A layout is fixed when the table is created
44+
and cannot be altered, so reading it back is the only way to confirm what was
45+
actually applied — which is why this exists as a first-class return rather
46+
than a field on `ManagedTable`, whose other fields describe sync state.
47+
48+
Empty lists mean no layout was declared. That reading is only safe because
49+
this is resolved through a MANAGED database: the same fields on a table
50+
discovered from an external connection are empty because its layout belongs
51+
to the upstream system, which is "not known from here" rather than
52+
"confirmed none".
53+
"""
54+
55+
schema_name: str
56+
table_name: str
57+
partition_by: list[TablePartitionKey]
58+
sorted_by: list[TableSortKey]
59+
60+
# NO to_dict(), unlike every other dataclass here, and deliberately so.
61+
# `asdict()` would copy the pydantic key models through untouched rather than
62+
# flatten them, so it would not return a plain dict. Mapping each key through
63+
# its own `to_dict()` does flatten, but returns `dict[str, Any]` and adds
64+
# eight errors under this package's strict mypy settings; hand-building the
65+
# dict from named fields avoids that but silently drops any field a later
66+
# spec adds to the key models, which is the failure this whole feature exists
67+
# to prevent. A caller wanting dicts can map `k.to_dict()` itself and own
68+
# that choice.
69+
70+
@property
71+
def is_partitioned(self) -> bool:
72+
return bool(self.partition_by)
73+
74+
@property
75+
def is_sorted(self) -> bool:
76+
return bool(self.sorted_by)
77+
78+
3679
@dataclass(frozen=True)
3780
class LoadManagedTableResult:
3881
connection_id: str

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ dependencies = [
3737
# uncapped floor turns someone else's release into a break in ours,
3838
# with no commit of our own to point at. Raise the cap deliberately, after
3939
# running the suite against the new minor.
40-
"hotdata>=0.8.0,<0.9",
40+
"hotdata>=0.9.0,<0.10",
4141
"pandas>=2.0",
4242
"pyarrow>=14.0",
4343
]

tests/test_contract.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ def test_public_exports_contract():
2323
"QueryResult",
2424
"ResultSummary",
2525
"RunHistoryItem",
26+
"TableLayout",
27+
"TablePartitionKey",
28+
"TableSortKey",
2629
"WorkspaceSelection",
2730
"__version__",
2831
"classify_sdk_error",

0 commit comments

Comments
 (0)