Skip to content

feat: accept a resolved ManagedDatabase in managed-table ops (skip read probe) - #52

Merged
rohan-hotdata merged 1 commit into
mainfrom
feat/managed-database-passthrough
Jul 23, 2026
Merged

feat: accept a resolved ManagedDatabase in managed-table ops (skip read probe)#52
rohan-hotdata merged 1 commit into
mainfrom
feat/managed-database-passthrough

Conversation

@rohan-hotdata

Copy link
Copy Markdown
Contributor

What

Adds HotdataClient._as_managed_database and widens six managed-table ops to accept str | ManagedDatabase:

  • list_managed_tables, load_managed_table, add_managed_table, delete_managed_table, delete_managed_database
  • execute_sql (str | Nonestr | ManagedDatabase | None)

Each op replaces its resolve_managed_database(...) call with _as_managed_database(...). When passed an already-resolved ManagedDatabase (the value create_managed_database already returns, complete with default_connection_id), the op skips the get_database/list_databases read probe and uses the object directly.

Why — unblocks hotdata-dlt-destination#55

A Hotdata API key can be scoped to create databases + upload + query + create schemas/tables but not read /databases (a legitimate, server-advertised tier — the 403 body says so).

Today every managed-table op resolves the DB by name first (resolve_managed_database → a get_database read, then a list_databases scan). For a create-scoped key both reads return 403, which resolve_managed_database maps to a hard RuntimeError. So:

  1. create_managed_database(...) succeeds (the key may create) and returns the id + default_connection_id.
  2. The next op — load_managed_table(name, …) — re-resolves the name → 403 → the run dies.

The enabling fact: create already returns everything a load needs. The raw SDK's create_database_response exposes default_connection_id specifically so callers can load via POST /v1/connections/{id}/schemas/{s}/tables/{t}/loads without a further read. The framework was discarding it and re-resolving by name.

So the fix is not "make create return the id" (it already does) — it's "let the load/add/query ops accept the already-resolved ManagedDatabase and skip the read probe." A caller that caches the ManagedDatabase from create can then drive load/add/query with zero reads, letting a create-scoped key bootstrap and load into a managed database in a single run.

Scope / deliberate non-changes

  • The name/id string path is unchanged (regression-tested) — read-capable callers keep resolving by name/id.
  • resolve_managed_database's 403 → RuntimeError behavior is preserved on purpose: 403 means forbidden, not absent. The "403 → try create" policy stays in the destination, which discriminates via RuntimeError.__cause__.status == 403 (the framework raises ... from e, preserving the ApiException).

Related: hotdata-dlt-destination#39 (name lookups are collision-prone) — the id/name string path is retained precisely for read-capable callers.

Versioning

Additive, backward-compatible → minor bump 0.8.0 → 0.9.0 (per CONTRACT.md's versioning policy). CONTRACT.md semantic guarantees and CHANGELOG.md updated; ManagedDatabase was already in the public export surface, so no surface change.

Tests

  • New _ForbiddenDatabasesApi (all reads raise ForbiddenException(403), writes succeed — mirrors a create-scoped key) proving load_managed_table / add_managed_table / execute_sql with a ManagedDatabase perform no get_database/list_databases call and succeed.
  • Name-path regression test retained.
  • uv run pytest -q → 86 passed. Contract-surface test green.

Downstream follow-up (after release)

hotdata-dlt-destination bumps its hotdata-framework pin, then in ensure_managed_database: catches the 403 → create, caches the returned ManagedDatabase, and passes that object (not the name) into load_managed_table/add_managed_table/execute_sql.

…ad probe

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.
@rohan-hotdata
rohan-hotdata requested a review from a team as a code owner July 23, 2026 13:15
@rohan-hotdata
rohan-hotdata requested review from anoop-narang and removed request for a team July 23, 2026 13:15

def delete_managed_database(self, name_or_id: str) -> None:
db = self.resolve_managed_database(name_or_id)
def delete_managed_database(self, name_or_id: str | ManagedDatabase) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nit: delete_managed_database keeps the name_or_id parameter name while now also accepting a ManagedDatabase. The other five ops in this change renamed their parameter to database. Renaming here would keep the signature naming consistent across the widened ops. (not blocking)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I left this as name_or_id on purpose: it mirrors the sibling database-level op resolve_managed_database(name_or_id), whereas database on the other five denotes which database a table/query lives in. The type widened here, but no op was actually renamed in this PR — the five table/query ops were already database, and the two identifier ops (resolve_/delete_managed_database) were already name_or_id.

There's also a small cost to renaming: name_or_id is public and keyword-callable, so delete_managed_database(name_or_id=...) would break — a backward-incompatible change in what's otherwise an additive 0.9.0. Happy to standardize the identifier name across all DB-level ops in a future major if we want the consistency. Leaving as-is for now.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, additive, backward-compatible change. The read-probe skip is correctly gated on isinstance, the name/id path is regression-tested, and CONTRACT.md/CHANGELOG/version are all updated. One super nit left inline.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant