Skip to content

Commit 1312bd4

Browse files
committed
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.
1 parent c55b4b5 commit 1312bd4

4 files changed

Lines changed: 86 additions & 17 deletions

File tree

‎CONTRACT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ Adapters should import from `hotdata_framework` and treat this surface as the st
6969
- `load_managed_table(database, table, schema=..., upload_id=..., file=...)` publishes parquet data into a declared managed table.
7070
- `delete_managed_table(database, table, schema=...)` deletes a managed table.
7171
- `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.
72-
- 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.
7373

7474
### `QueryResult`
7575

‎hotdata_framework/client.py‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,16 @@ def create_managed_database(
306306
keys = keys or {}
307307
partition_by = partition_by or {}
308308
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+
)
309319
schemas = None
310320
if tables:
311321
schemas = [
@@ -502,14 +512,23 @@ def managed_table_layout(
502512
two are very different for a caller deciding whether to load.
503513
"""
504514
db = self._as_managed_database(database)
505-
for info in self.iter_tables(connection_id=db.default_connection_id):
506-
if info.table == table and info.var_schema == schema:
507-
return TableLayout(
508-
schema_name=schema,
509-
table_name=table,
510-
partition_by=list(info.partition_by or []),
511-
sorted_by=list(info.sorted_by or []),
512-
)
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+
)
513532
raise KeyError(f"{schema}.{table} is not declared on database {db.id}")
514533

515534
def delete_managed_table(

‎hotdata_framework/databases.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ class TableLayout:
5757
partition_by: list[TablePartitionKey]
5858
sorted_by: list[TableSortKey]
5959

60+
def to_dict(self) -> dict[str, Any]:
61+
"""Plain dict, like the other dataclasses here.
62+
63+
Not `asdict()`: the key lists hold pydantic models, which `asdict` copies
64+
through untouched, so the result would not be a plain dict. Each key is
65+
mapped through its own `to_dict()` instead.
66+
"""
67+
return {
68+
"schema_name": self.schema_name,
69+
"table_name": self.table_name,
70+
"partition_by": [k.to_dict() for k in self.partition_by],
71+
"sorted_by": [k.to_dict() for k in self.sorted_by],
72+
}
73+
6074
@property
6175
def is_partitioned(self) -> bool:
6276
return bool(self.partition_by)

‎tests/test_databases.py‎

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -489,15 +489,19 @@ def create_database(self, request):
489489

490490

491491
def test_managed_table_layout_reads_it_back():
492-
493492
parts, sorts = _layout()
494493
client = _client()
495494
info = SimpleNamespace(
496495
table="files", var_schema="public", partition_by=parts, sorted_by=sorts
497496
)
498-
other = SimpleNamespace(table="elsewhere", var_schema="public", partition_by=[], sorted_by=[])
497+
captured = {}
498+
499+
class FakeInfoSchema:
500+
def information_schema(self, **kwargs):
501+
captured.update(kwargs)
502+
return SimpleNamespace(tables=[info])
499503

500-
with patch.object(client, "iter_tables", return_value=iter([other, info])), patch.object(
504+
with patch.object(client, "_information_schema", return_value=FakeInfoSchema()), patch.object(
501505
client, "_as_managed_database", return_value=_MANAGED_DB
502506
):
503507
layout = client.managed_table_layout("db_1", "files")
@@ -507,21 +511,53 @@ def test_managed_table_layout_reads_it_back():
507511
assert [s.column for s in layout.sorted_by] == ["event_time"]
508512
assert layout.is_partitioned is True
509513
assert layout.is_sorted is True
514+
# Filtered server-side, not by paging the whole listing.
515+
assert captured["var_schema"] == "public"
516+
assert captured["table"] == "files"
517+
assert captured["connection_id"] == _MANAGED_DB.default_connection_id
518+
# to_dict flattens the pydantic keys rather than copying them through.
519+
assert layout.to_dict()["partition_by"] == [
520+
{"column": "event_date", "transform": "identity"}
521+
]
510522

511523

512524
def test_managed_table_layout_distinguishes_absent_from_unpartitioned():
513-
"""KeyError for a missing table, empty lists for one declared without a
514-
layout. Collapsing the two would let a caller treat "table isn't there" as
515-
"confirmed no layout" and load into something it never verified."""
516-
525+
"""KeyError for a table the server does not report, empty lists for one
526+
declared without a layout. Collapsing the two would let a caller read "table
527+
isn't there" as "confirmed no layout" and load into something unverified.
528+
529+
Each call gets a FRESH response via side_effect. Sharing one would leave the
530+
second assertion inspecting a consumed result rather than the case named
531+
here.
532+
"""
517533
client = _client()
518534
plain = SimpleNamespace(table="files", var_schema="public", partition_by=[], sorted_by=[])
519535

520-
with patch.object(client, "iter_tables", return_value=iter([plain])), patch.object(
536+
def respond(**kwargs):
537+
found = [plain] if kwargs.get("table") == "files" else []
538+
return SimpleNamespace(tables=found)
539+
540+
api = SimpleNamespace(information_schema=respond)
541+
with patch.object(client, "_information_schema", return_value=api), patch.object(
521542
client, "_as_managed_database", return_value=_MANAGED_DB
522543
):
523544
layout = client.managed_table_layout("db_1", "files")
524545
assert layout.is_partitioned is False and layout.is_sorted is False
525546

526547
with pytest.raises(KeyError, match="missing"):
527548
client.managed_table_layout("db_1", "missing")
549+
550+
551+
def test_create_managed_database_refuses_layout_for_a_table_it_is_not_creating():
552+
"""A typo'd table name in the layout mapping would otherwise be dropped, and
553+
the table it was meant for created flat — permanently, since a layout cannot
554+
be added later."""
555+
parts, _ = _layout()
556+
client = _client()
557+
558+
with patch.object(client, "_databases_api") as dbs:
559+
with pytest.raises(ValueError, match="fils"):
560+
client.create_managed_database(
561+
"demo", tables=["files"], partition_by={"fils": parts}
562+
)
563+
dbs.return_value.create_database.assert_not_called()

0 commit comments

Comments
 (0)