Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`hl.attach_catalog` and `hl.detach_catalog`.** An instant database could only ever read its
own tables from this package: every tool set is scoped to one database and there was no way to
bring a second source into that scope without dropping out of the library to the raw SDK.
Attaching a registered connection makes its tables addressable as `<alias>.<schema>.<table>`
alongside the database's own, which the SQL tool description already knows how to describe —
it just had no way to produce that state. `attach_catalog` returns the attachment that landed
rather than the one requested, because the alias is the server's choice when it is not given
and SQL has to address the name that exists.

Both endpoints answer 204 with no body, so a refusal raises and there is nothing ambiguous
to inspect. What a status code cannot cover is a 204 that did not do the work, so both helpers
re-read the database and raise if the attachment did not land, or if a detached connection is
still listed. That shape is not hypothetical on this platform — `delete_managed_table` reports
success while leaving a registration behind — though it has not been observed for an attach.
Pass `confirm=False` to skip the extra request.

A 409 from the attach is resolved by reading rather than assumed to mean "already attached": if
the connection turns out to be attached, its existing attachment is returned, so re-running a
provisioning step is a no-op instead of an error.

Attaching one instant database into another is refused by the platform
(`Connection '<id>' is scoped to another database`); the refusal is passed through with its
message intact. A registered data source is what attaches.

- **`hl.database_attachments` and `CatalogAttachment`.** `ManagedDatabase` carries only `id`,
`description` and `default_connection_id`, so a caller holding a resolved record could not ask
what was attached to it. This reads the fields that record drops.

These are Python helpers and deliberately not tools. Whether provisioning of this kind should be
agent-callable is the open question in
[#61](https://github.com/hotdata-dev/hotdata-langchain/issues/61), and nothing here settles it.

## [0.15.0] - 2026-09-01

### Added
Expand Down
60 changes: 56 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,8 +424,11 @@ SQL does what the tool does.

An instant database's tables read as `default.<schema>.<table>`. An **attached** source's do
not — its tables answer to the attachment's alias, and `default.<schema>.<table>` is not
found there. Nothing on the database record distinguishes the two, so there is no constant
the tools can assume.
found there. A resolved `ManagedDatabase` distinguishes nothing here, since it carries neither
the attachments nor the default catalog, and `default_catalog` reports `default` whether or not
anything answers to that name. `hl.database_attachments` will name what is attached, but that
is not the same question: it says which sources are attached, not which catalogs actually hold
tables. So there is no constant the tools can assume.

`make_hotdata_tools` therefore reads the catalog from `information_schema` once, when the
tools are built, and states it in the SQL tool's description — so the model is told the real
Expand Down Expand Up @@ -783,8 +786,57 @@ the instant-database tools act on the workspace, so naming one database in them
`management_tools=False` on the extra sets is worth it for the same reason: listing, creating
and loading databases are workspace-wide, so a second copy of them is redundant surface.

Note that a query cannot reach across databases: `SELECT ... FROM other_db.public.t` from within
one database's scope fails with `table not found`. Each set queries its own.
Note that a query cannot reach from one instant database into another: `SELECT ... FROM
other_db.public.t` from within one database's scope fails with `table not found`, and the
platform refuses attaching one instant database into another to get around it. Each set queries
its own. A *registered data source* is a different matter — see below.

## Attaching another source into a database's scope

A registered connection can be attached into an instant database, which puts its tables in the
same query scope as the database's own. That is how one query reads across the boundary:

```python
attachment = hl.attach_catalog(client, db, connection_id="conn...", alias="warehouse")
print(attachment.alias) # the catalog name SQL has to address
```

Its tables are then addressable as `warehouse.public.orders` alongside the database's own
tables, and a query can join the two. `hl.detach_catalog(client, db, connection_id="conn...")`
removes the attachment; the connection stays registered and its data is untouched, so this is
reversible.

**Pass `alias=` if you need to know the name in advance.** Left unset, the server chooses it —
so `attach_catalog` returns the attachment that landed rather than echoing what was asked for,
and that is the name to address.

Both calls answer 204 with no body, so a refusal raises and there is nothing ambiguous to read.
What that does not cover is a 204 that did not take effect, so each one re-reads the database
afterwards and raises if the attachment did not land, or if a detached connection is still
listed. Pass `confirm=False` to skip the extra request — the returned `alias` is then the one
you asked for rather than the one that landed.

Attaching a connection that is already attached is a no-op rather than an error, so re-running a
provisioning step is safe.

To ask what a database already has attached:

```python
for one in hl.database_attachments(client, db):
print(one.connection_id, one.alias)
```

This is a separate call because `ManagedDatabase` carries only `id`, `description` and
`default_connection_id` — a resolved record cannot answer the question on its own.

A database with something attached exposes more than one catalog, and the SQL tool's description
changes to match: instead of naming a single catalog it tells the model to read `table_catalog`
from `information_schema.tables` to see which one holds a table. Nothing extra is needed to make
that happen — build the tools after attaching.

These are Python helpers, not tools. Whether an agent should be able to attach a source itself
is [#61](https://github.com/hotdata-dev/hotdata-langchain/issues/61)'s open question, and this
does not answer it.

## Controlling result size

Expand Down
25 changes: 21 additions & 4 deletions docs/engine-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,10 +385,27 @@ record — nothing in `information_schema` marks the generated column as generat
call; the same client read from two different instant databases in one session.
- **Cross-database references inside a single query fail** by default:
`SELECT id FROM f1_db.public.drivers` from within another database's scope gives
`table 'f1_db.public.drivers' not found`. `DatabasesApi` does expose
`attach_database_catalog`/`detach_database_catalog`, and `bm25_search`'s scope resolution
translates "an attachment alias or `default`", so attachment is presumably the supported route
— **not verified here**.
`table 'f1_db.public.drivers' not found`.

- **Attachment is the supported route across that boundary, but only for a registered source**
(verified 2026-08-31/09-01 against the live workspace, through `DatabasesApi` directly):

| Attempt | Result |
|---|---|
| attach instant database A into instant database B | **refused** — `Connection '<id>' is scoped to another database and cannot be attached here` |
| load a `result_id` from A's query into a table in A | accepted, 1 row landed |
| load that same `result_id` into B | **refused** — `Result '<id>' not found` |
| attach a **Postgres connection** into an instant database | accepted |
| `SELECT * FROM <alias>.public.drivers` through that alias | **859 rows**, from inside the instant database's scope |

So two instant databases cannot see each other by any route, and the earlier guess that
attachment was "presumably the supported route" was half right: it is the route, and it does
not work for another instant database. `ResultsApi.get_result` requires an `x_database_id`,
which is the same boundary expressed in a signature.

`hl.attach_catalog`/`hl.detach_catalog` wrap that same endpoint pair. The endpoints are
verified as above; **the helpers themselves have not been exercised against a live
workspace.**
- **Database names are not unique.** `name` is a display label; `resolve_managed_database` tries
the id first and then scans `list_databases()` matching on name. Ids are the only safe handle,
so this package never calls that resolver: `resolve_database_by_id` goes straight to
Expand Down
8 changes: 8 additions & 0 deletions hotdata_langchain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@

from hotdata_langchain._sql import DISTANCE_FUNCTIONS, DistanceMetric
from hotdata_langchain.databases import (
CatalogAttachment,
LoadMode,
attach_catalog,
create_managed_database,
database_attachments,
detach_catalog,
list_managed_databases_json,
load_managed_table,
load_result_summary,
Expand Down Expand Up @@ -111,6 +115,7 @@
"SEARCH_NOUNS",
"SEMANTIC",
"TEXT",
"CatalogAttachment",
"DistanceMetric",
"Fusion",
"HotdataClient",
Expand All @@ -126,11 +131,14 @@
"TablePartitionKey",
"TableSortKey",
"__version__",
"attach_catalog",
"bm25_search_json",
"bm25_search_sql",
"capabilities_by_column",
"create_managed_database",
"database_attachments",
"describe_tables_json",
"detach_catalog",
"engine_error_message",
"error_feedback",
"execute_sql_json",
Expand Down
Loading
Loading