Skip to content

Commit 26acad1

Browse files
committed
Align runtime contract with adapter usage and ambiguity safeguards.
Document the adapter-facing HotdataClient methods in the runtime contract, add duplicate-connection-name protection, and support explicit connection_id column resolution with regression coverage.
1 parent 618f3c3 commit 26acad1

3 files changed

Lines changed: 94 additions & 6 deletions

File tree

CONTRACT.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ Adapters should import from `hotdata_runtime` and treat this surface as the stab
3939
- `from_env()` resolves runtime context from env vars and selected workspace.
4040
- `execute_sql(sql)` returns `QueryResult` or raises `RuntimeError`/`TimeoutError`.
4141
- `get_result(result_id)` returns a ready `QueryResult` and waits for readiness when needed.
42+
- `connections()` returns the connections API wrapper for adapter UI/status features.
43+
- `query_runs()` returns the query-runs API wrapper for adapter history views.
44+
- `results()` returns the results API wrapper for adapter result pickers.
45+
- `list_qualified_table_names(...)` returns sorted fully qualified table names.
46+
- `columns_for_qualified(qualified, connection_id=...)` resolves table columns, and
47+
adapters should pass `connection_id` when known.
4248

4349
### `QueryResult`
4450

hotdata_runtime/client.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,26 @@ def list_qualified_table_names(
144144

145145
def connection_id_by_name(self) -> dict[str, str]:
146146
listing = self.connections().list_connections()
147-
return {c.name: c.id for c in listing.connections}
147+
id_map: dict[str, str] = {}
148+
duplicate_names: set[str] = set()
149+
for c in listing.connections:
150+
if c.name in id_map and id_map[c.name] != c.id:
151+
duplicate_names.add(c.name)
152+
id_map[c.name] = c.id
153+
if duplicate_names:
154+
names = ", ".join(sorted(duplicate_names))
155+
raise RuntimeError(
156+
f"Duplicate connection names found: {names}. "
157+
"Use an explicit connection_id."
158+
)
159+
return id_map
148160

149-
def columns_for_qualified(self, qualified: str) -> list[TableInfo]:
161+
def columns_for_qualified(
162+
self,
163+
qualified: str,
164+
*,
165+
connection_id: str | None = None,
166+
) -> list[TableInfo]:
150167
parts = qualified.split(".")
151168
if len(parts) < 3:
152169
raise ValueError(
@@ -157,10 +174,12 @@ def columns_for_qualified(self, qualified: str) -> list[TableInfo]:
157174
parts[1],
158175
".".join(parts[2:]),
159176
)
160-
id_map = self.connection_id_by_name()
161-
conn_id = id_map.get(conn_name)
162-
if not conn_id:
163-
raise KeyError(f"Unknown connection {conn_name!r}")
177+
conn_id = connection_id
178+
if conn_id is None:
179+
id_map = self.connection_id_by_name()
180+
conn_id = id_map.get(conn_name)
181+
if not conn_id:
182+
raise KeyError(f"Unknown connection {conn_name!r}")
164183
resp = self._information_schema().information_schema(
165184
connection_id=conn_id,
166185
var_schema=schema_name,

tests/test_client.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,24 @@ def test_pick_workspace_falls_back_to_first(monkeypatch: pytest.MonkeyPatch):
8181
assert pick_workspace("k", "https://api.hotdata.dev", None) == "ws_1"
8282

8383

84+
def test_resolve_workspace_selection_source_first(monkeypatch: pytest.MonkeyPatch):
85+
monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False)
86+
monkeypatch.delenv("HOTDATA_WORKSPACE_ID", raising=False)
87+
items = [
88+
SimpleNamespace(public_id="ws_1", active=False),
89+
SimpleNamespace(public_id="ws_2", active=False),
90+
]
91+
listing = SimpleNamespace(workspaces=items)
92+
with patch("hotdata_runtime.env.WorkspacesApi") as Api:
93+
Api.return_value.list_workspaces.return_value = listing
94+
resolved = resolve_workspace_selection(
95+
"k", "https://api.hotdata.dev", None
96+
)
97+
assert resolved.workspace_id == "ws_1"
98+
assert resolved.source == "first"
99+
assert resolved.workspaces == items
100+
101+
84102
def test_resolve_workspace_selection_returns_workspaces_and_source(
85103
monkeypatch: pytest.MonkeyPatch,
86104
):
@@ -121,3 +139,48 @@ def get_result(self, result_id: str):
121139
with patch.object(client, "_results_api", return_value=FakeResultsApi()):
122140
with pytest.raises(RuntimeError, match="cancelled"):
123141
client._wait_result_ready("res_1", timeout_s=0.1, interval_s=0)
142+
143+
144+
def test_connection_id_by_name_raises_on_duplicate_names():
145+
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
146+
listing = SimpleNamespace(
147+
connections=[
148+
SimpleNamespace(name="warehouse", id="conn_1"),
149+
SimpleNamespace(name="warehouse", id="conn_2"),
150+
]
151+
)
152+
153+
class FakeConnectionsApi:
154+
def list_connections(self):
155+
return listing
156+
157+
with patch.object(client, "connections", return_value=FakeConnectionsApi()):
158+
with pytest.raises(RuntimeError, match="Duplicate connection names"):
159+
client.connection_id_by_name()
160+
161+
162+
def test_columns_for_qualified_prefers_explicit_connection_id():
163+
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
164+
col = SimpleNamespace(name="a", data_type="INTEGER", nullable=True)
165+
table = SimpleNamespace(columns=[col])
166+
response = SimpleNamespace(tables=[table])
167+
168+
class FakeInformationSchemaApi:
169+
def __init__(self):
170+
self.kwargs = None
171+
172+
def information_schema(self, **kwargs):
173+
self.kwargs = kwargs
174+
return response
175+
176+
fake_api = FakeInformationSchemaApi()
177+
with patch.object(client, "_information_schema", return_value=fake_api), patch.object(
178+
client, "connection_id_by_name"
179+
) as id_map:
180+
cols = client.columns_for_qualified(
181+
"warehouse.public.orders",
182+
connection_id="conn_explicit",
183+
)
184+
id_map.assert_not_called()
185+
assert cols == [col]
186+
assert fake_api.kwargs["connection_id"] == "conn_explicit"

0 commit comments

Comments
 (0)