interlaced 2.0.0a2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- interlace/__init__.py +26 -0
- interlace/checks/__init__.py +10 -0
- interlace/checks/builtin.py +152 -0
- interlace/checks/runner.py +103 -0
- interlace/checks/spec.py +113 -0
- interlace/cli/__init__.py +7 -0
- interlace/cli/main.py +1072 -0
- interlace/config/__init__.py +7 -0
- interlace/config/config.py +172 -0
- interlace/contracts.py +35 -0
- interlace/dsl/__init__.py +16 -0
- interlace/dsl/decorators.py +222 -0
- interlace/dsl/discovery.py +75 -0
- interlace/dsl/sql_config.py +47 -0
- interlace/engines/__init__.py +16 -0
- interlace/engines/base.py +86 -0
- interlace/engines/duckdb.py +249 -0
- interlace/engines/postgres.py +157 -0
- interlace/engines/quack.py +113 -0
- interlace/engines/registry.py +125 -0
- interlace/exceptions.py +66 -0
- interlace/exports.py +145 -0
- interlace/graph/__init__.py +17 -0
- interlace/graph/column_lineage.py +69 -0
- interlace/graph/dag.py +81 -0
- interlace/graph/project.py +242 -0
- interlace/graph/selectors.py +45 -0
- interlace/ir/__init__.py +24 -0
- interlace/ir/canonicalize.py +71 -0
- interlace/ir/fingerprint.py +55 -0
- interlace/ir/relation.py +71 -0
- interlace/ir/schema.py +23 -0
- interlace/plan/__init__.py +23 -0
- interlace/plan/apply.py +527 -0
- interlace/plan/differ.py +211 -0
- interlace/plan/plan.py +177 -0
- interlace/plan/resolve.py +74 -0
- interlace/plan/run.py +79 -0
- interlace/project.py +278 -0
- interlace/py.typed +0 -0
- interlace/runtime/__init__.py +6 -0
- interlace/runtime/handles.py +48 -0
- interlace/runtime/python_model.py +175 -0
- interlace/scaffold.py +83 -0
- interlace/scheduler/__init__.py +17 -0
- interlace/scheduler/engine.py +66 -0
- interlace/scheduler/triggers.py +84 -0
- interlace/scheduler/worker.py +154 -0
- interlace/service/__init__.py +7 -0
- interlace/service/app.py +836 -0
- interlace/service/auth.py +41 -0
- interlace/state/__init__.py +18 -0
- interlace/state/interval.py +134 -0
- interlace/state/janitor.py +142 -0
- interlace/state/snapshot.py +47 -0
- interlace/state/store.py +906 -0
- interlace/strategies/__init__.py +64 -0
- interlace/strategies/base.py +71 -0
- interlace/strategies/full.py +38 -0
- interlace/strategies/full_merge.py +98 -0
- interlace/strategies/incremental_by_time.py +65 -0
- interlace/strategies/merge_by_key.py +69 -0
- interlace/strategies/scd_type_2.py +122 -0
- interlace/strategies/view.py +33 -0
- interlace/streaming/__init__.py +18 -0
- interlace/streaming/log.py +298 -0
- interlace/streaming/materializer.py +166 -0
- interlace/streaming/schema.py +218 -0
- interlaced-2.0.0a2.dist-info/METADATA +216 -0
- interlaced-2.0.0a2.dist-info/RECORD +74 -0
- interlaced-2.0.0a2.dist-info/WHEEL +5 -0
- interlaced-2.0.0a2.dist-info/entry_points.txt +2 -0
- interlaced-2.0.0a2.dist-info/licenses/LICENSE +21 -0
- interlaced-2.0.0a2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""DuckDB engine adapter — the default local engine and federation hub.
|
|
2
|
+
|
|
3
|
+
Everything crosses the boundary as Arrow: :meth:`fetch` streams results as a
|
|
4
|
+
``pyarrow.RecordBatchReader`` (zero-copy, single pass) and :meth:`load` registers
|
|
5
|
+
an Arrow reader and writes it with one ``CREATE TABLE AS`` / ``INSERT``. Blocking
|
|
6
|
+
DuckDB calls run in a worker thread; each call uses its own ``cursor()`` so reads
|
|
7
|
+
proceed concurrently (DuckDB MVCC), while the DAG guarantees no two tasks write
|
|
8
|
+
the same table at once.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import contextlib
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
|
|
18
|
+
import duckdb
|
|
19
|
+
import pyarrow as pa
|
|
20
|
+
import tenacity
|
|
21
|
+
from sqlglot import exp
|
|
22
|
+
|
|
23
|
+
from interlace.engines.base import EngineAdapter, EngineCaps, LoadMode
|
|
24
|
+
from interlace.ir.relation import TableRef
|
|
25
|
+
|
|
26
|
+
_DUCKDB_CAPS = EngineCaps(
|
|
27
|
+
supports_create_or_replace=True,
|
|
28
|
+
supports_star_exclude=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# DuckLake uses optimistic concurrency: a concurrent writer's commit surfaces as
|
|
33
|
+
# a TransactionException. Our write batches are whole-transaction idempotent
|
|
34
|
+
# (CREATE OR REPLACE / DELETE+INSERT run as one unit), so a short retry is safe.
|
|
35
|
+
_commit_retry = tenacity.retry(
|
|
36
|
+
retry=tenacity.retry_if_exception_type(duckdb.TransactionException),
|
|
37
|
+
stop=tenacity.stop_after_attempt(3),
|
|
38
|
+
wait=tenacity.wait_exponential_jitter(initial=0.1, max=1.0),
|
|
39
|
+
reraise=True,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _affected(cur: duckdb.DuckDBPyConnection) -> int:
|
|
44
|
+
"""DML/CTAS/COPY return their affected-row count as a one-cell result; DDL returns
|
|
45
|
+
nothing. Never raises — row stats are best-effort decoration, not correctness."""
|
|
46
|
+
try:
|
|
47
|
+
rows = cur.fetchall()
|
|
48
|
+
return int(rows[0][0]) if rows and rows[0] and isinstance(rows[0][0], int) else 0
|
|
49
|
+
except Exception:
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DuckDBAdapter(EngineAdapter):
|
|
54
|
+
"""Executes canonical ASTs and moves Arrow data in and out of a DuckDB database."""
|
|
55
|
+
|
|
56
|
+
dialect = "duckdb"
|
|
57
|
+
caps = _DUCKDB_CAPS
|
|
58
|
+
|
|
59
|
+
def __init__(self, connection: duckdb.DuckDBPyConnection, session_init: Sequence[str] = ()) -> None:
|
|
60
|
+
self._conn = connection
|
|
61
|
+
# Statements re-applied on every cursor — SESSION-LOCAL state only (USE).
|
|
62
|
+
# Anything instance-wide (LOAD, secrets, ATTACH) belongs at connect time:
|
|
63
|
+
# re-running catalog writes here races across concurrent cursors.
|
|
64
|
+
self._session_init = list(session_init)
|
|
65
|
+
self._attached: list[str] = [] # aliases to DETACH on close (see close())
|
|
66
|
+
|
|
67
|
+
def _cursor(self) -> duckdb.DuckDBPyConnection:
|
|
68
|
+
cur = self._conn.cursor()
|
|
69
|
+
for statement in self._session_init:
|
|
70
|
+
cur.execute(statement)
|
|
71
|
+
return cur
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def in_memory(cls) -> DuckDBAdapter:
|
|
75
|
+
return cls(duckdb.connect(":memory:"))
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def connect(cls, path: str) -> DuckDBAdapter:
|
|
79
|
+
return cls(duckdb.connect(path))
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def connect_ducklake(
|
|
83
|
+
cls,
|
|
84
|
+
catalog: str,
|
|
85
|
+
*,
|
|
86
|
+
alias: str = "warehouse",
|
|
87
|
+
data_path: str | None = None,
|
|
88
|
+
metadata_schema: str | None = None,
|
|
89
|
+
secrets: Sequence[str] = (),
|
|
90
|
+
extensions: Sequence[str] = (),
|
|
91
|
+
) -> DuckDBAdapter:
|
|
92
|
+
"""Open a DuckLake warehouse that needs attach options and/or credentials —
|
|
93
|
+
remote catalogs (``postgres:…``) and object-store ``data_path``s can't ride the
|
|
94
|
+
plain ``duckdb.connect("ducklake:…")`` shortcut. Opens ``:memory:``, installs
|
|
95
|
+
the extensions, issues the ``CREATE SECRET`` statements, ATTACHes the DuckLake
|
|
96
|
+
with the options, and makes it the default catalog."""
|
|
97
|
+
conn = duckdb.connect(":memory:")
|
|
98
|
+
for extension in extensions:
|
|
99
|
+
conn.execute(f"INSTALL {extension}; LOAD {extension};")
|
|
100
|
+
for statement in secrets:
|
|
101
|
+
conn.execute(statement)
|
|
102
|
+
options: list[str] = []
|
|
103
|
+
if data_path:
|
|
104
|
+
options.append(f"DATA_PATH '{data_path.replace(chr(39), chr(39) * 2)}'")
|
|
105
|
+
if metadata_schema:
|
|
106
|
+
options.append(f"METADATA_SCHEMA '{metadata_schema.replace(chr(39), chr(39) * 2)}'")
|
|
107
|
+
options_sql = f" ({', '.join(options)})" if options else ""
|
|
108
|
+
escaped = catalog.replace("'", "''")
|
|
109
|
+
alias_sql = exp.to_identifier(alias).sql("duckdb")
|
|
110
|
+
conn.execute(f"ATTACH IF NOT EXISTS '{escaped}' AS {alias_sql}{options_sql}")
|
|
111
|
+
conn.execute(f"USE {alias_sql}")
|
|
112
|
+
# LOAD, secrets, and ATTACH are all instance-wide — they carry into every
|
|
113
|
+
# cursor and must run ONCE (re-running CREATE OR REPLACE SECRET per cursor
|
|
114
|
+
# races: concurrent cursors hit "catalog write-write conflict on alter").
|
|
115
|
+
# Only the default catalog is session state, so that is all a cursor re-applies.
|
|
116
|
+
return cls(conn, session_init=[f"USE {alias_sql}"])
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
# DETACH long-lived attaches first: DuckLake leaks its DatabaseInstance when
|
|
120
|
+
# concurrent cursors were used (duckdb 1.5.4), which would otherwise keep the
|
|
121
|
+
# attached databases' file handles locked for the rest of the process.
|
|
122
|
+
for alias in self._attached:
|
|
123
|
+
with contextlib.suppress(Exception):
|
|
124
|
+
self._conn.execute(f"DETACH {exp.to_identifier(alias).sql('duckdb')}")
|
|
125
|
+
self._attached.clear()
|
|
126
|
+
self._conn.close()
|
|
127
|
+
|
|
128
|
+
def attach(self, alias: str, uri: str) -> None:
|
|
129
|
+
"""ATTACH another database (duckdb/sqlite/postgres/... URI) under ``alias``."""
|
|
130
|
+
escaped = uri.replace("'", "''")
|
|
131
|
+
self._conn.execute(f"ATTACH IF NOT EXISTS '{escaped}' AS {exp.to_identifier(alias).sql('duckdb')}")
|
|
132
|
+
self._attached.append(alias)
|
|
133
|
+
|
|
134
|
+
# --- identifier helpers -------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def _table_sql(self, table: TableRef) -> str:
|
|
137
|
+
return table.to_expr().sql(dialect=self.dialect)
|
|
138
|
+
|
|
139
|
+
# --- EngineAdapter ------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
async def execute(self, ast: exp.Expression) -> None:
|
|
142
|
+
await self.execute_sql(self.transpile(ast))
|
|
143
|
+
|
|
144
|
+
async def execute_all(self, statements: Sequence[exp.Expression]) -> list[int]:
|
|
145
|
+
return await asyncio.to_thread(self._execute_all_sync, [self.transpile(s) for s in statements])
|
|
146
|
+
|
|
147
|
+
async def fetch(self, ast: exp.Expression) -> pa.RecordBatchReader:
|
|
148
|
+
return await self.fetch_sql(self.transpile(ast))
|
|
149
|
+
|
|
150
|
+
async def load(self, table: TableRef, reader: pa.RecordBatchReader, mode: LoadMode) -> int:
|
|
151
|
+
return await asyncio.to_thread(self._load_sync, table, reader, mode)
|
|
152
|
+
|
|
153
|
+
async def create_view(self, name: TableRef, target: TableRef) -> None:
|
|
154
|
+
await self.execute_sql(
|
|
155
|
+
f"CREATE OR REPLACE VIEW {self._table_sql(name)} AS SELECT * FROM {self._table_sql(target)}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# --- raw / convenience (used by the state store and tests) --------------
|
|
159
|
+
|
|
160
|
+
async def execute_sql(self, sql: str) -> None:
|
|
161
|
+
await asyncio.to_thread(self._execute_sync, sql)
|
|
162
|
+
|
|
163
|
+
async def fetch_sql(self, sql: str) -> pa.RecordBatchReader:
|
|
164
|
+
return await asyncio.to_thread(self._fetch_sync, sql)
|
|
165
|
+
|
|
166
|
+
async def create_schema(self, name: str) -> None:
|
|
167
|
+
await self.execute_sql(f"CREATE SCHEMA IF NOT EXISTS {exp.to_identifier(name).sql(dialect=self.dialect)}")
|
|
168
|
+
|
|
169
|
+
async def table_exists(self, table: TableRef) -> bool:
|
|
170
|
+
return await asyncio.to_thread(self._table_exists_sync, table)
|
|
171
|
+
|
|
172
|
+
async def describe(self, table: TableRef) -> dict[str, str]:
|
|
173
|
+
return await asyncio.to_thread(self._describe_sync, table)
|
|
174
|
+
|
|
175
|
+
# --- sync workers (run in a thread) -------------------------------------
|
|
176
|
+
|
|
177
|
+
@_commit_retry
|
|
178
|
+
def _execute_sync(self, sql: str) -> None:
|
|
179
|
+
cur = self._cursor()
|
|
180
|
+
try:
|
|
181
|
+
cur.execute(sql)
|
|
182
|
+
finally:
|
|
183
|
+
cur.close()
|
|
184
|
+
|
|
185
|
+
@_commit_retry
|
|
186
|
+
def _execute_all_sync(self, sqls: list[str]) -> list[int]:
|
|
187
|
+
cur = self._cursor()
|
|
188
|
+
counts: list[int] = []
|
|
189
|
+
try:
|
|
190
|
+
cur.execute("BEGIN")
|
|
191
|
+
for sql in sqls:
|
|
192
|
+
cur.execute(sql)
|
|
193
|
+
counts.append(_affected(cur))
|
|
194
|
+
cur.execute("COMMIT")
|
|
195
|
+
except Exception:
|
|
196
|
+
cur.execute("ROLLBACK")
|
|
197
|
+
raise
|
|
198
|
+
finally:
|
|
199
|
+
cur.close()
|
|
200
|
+
return counts
|
|
201
|
+
|
|
202
|
+
def _fetch_sync(self, sql: str) -> pa.RecordBatchReader:
|
|
203
|
+
# The reader keeps the underlying result alive after the cursor is dropped.
|
|
204
|
+
cur = self._cursor()
|
|
205
|
+
cur.execute(sql)
|
|
206
|
+
return cur.to_arrow_reader()
|
|
207
|
+
|
|
208
|
+
@_commit_retry
|
|
209
|
+
def _load_sync(self, table: TableRef, reader: pa.RecordBatchReader, mode: LoadMode) -> int:
|
|
210
|
+
cur = self._cursor()
|
|
211
|
+
src = f"__interlace_src_{uuid4().hex}"
|
|
212
|
+
cur.register(src, reader)
|
|
213
|
+
try:
|
|
214
|
+
target = self._table_sql(table)
|
|
215
|
+
if mode == "create":
|
|
216
|
+
cur.execute(f"CREATE OR REPLACE TABLE {target} AS SELECT * FROM {src}")
|
|
217
|
+
else:
|
|
218
|
+
cur.execute(f"INSERT INTO {target} SELECT * FROM {src}")
|
|
219
|
+
return _affected(cur)
|
|
220
|
+
finally:
|
|
221
|
+
cur.unregister(src)
|
|
222
|
+
cur.close()
|
|
223
|
+
|
|
224
|
+
def _table_exists_sync(self, table: TableRef) -> bool:
|
|
225
|
+
# information_schema spans every attached catalog: pin to the ref's catalog
|
|
226
|
+
# (or the session default) so same-named tables elsewhere don't collide.
|
|
227
|
+
cur = self._cursor()
|
|
228
|
+
try:
|
|
229
|
+
row = cur.execute(
|
|
230
|
+
"SELECT count(*) FROM information_schema.tables WHERE table_schema = ? AND table_name = ? "
|
|
231
|
+
"AND table_catalog = coalesce(?, current_database())",
|
|
232
|
+
[table.schema, table.name, table.catalog],
|
|
233
|
+
).fetchone()
|
|
234
|
+
finally:
|
|
235
|
+
cur.close()
|
|
236
|
+
return bool(row and row[0])
|
|
237
|
+
|
|
238
|
+
def _describe_sync(self, table: TableRef) -> dict[str, str]:
|
|
239
|
+
cur = self._cursor()
|
|
240
|
+
try:
|
|
241
|
+
rows = cur.execute(
|
|
242
|
+
"SELECT column_name, data_type FROM information_schema.columns "
|
|
243
|
+
"WHERE table_schema = ? AND table_name = ? "
|
|
244
|
+
"AND table_catalog = coalesce(?, current_database()) ORDER BY ordinal_position",
|
|
245
|
+
[table.schema, table.name, table.catalog],
|
|
246
|
+
).fetchall()
|
|
247
|
+
finally:
|
|
248
|
+
cur.close()
|
|
249
|
+
return dict(rows)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Postgres engine adapter — the first native remote engine (ADBC transport).
|
|
2
|
+
|
|
3
|
+
Strategies execute *inside* Postgres: canonical ASTs transpile to the postgres
|
|
4
|
+
dialect and run over one ADBC connection; results come back as Arrow and bulk
|
|
5
|
+
loads go in via ``adbc_ingest`` — columnar end to end, no row-format hop.
|
|
6
|
+
|
|
7
|
+
Capability honesty drives the strategy fallbacks: Postgres has no
|
|
8
|
+
``CREATE OR REPLACE TABLE`` (FullRefresh falls back to DROP+CREATE) and no
|
|
9
|
+
star-EXCLUDE projection (scd_type_2 refuses with a clear error). ``merge_by_key``
|
|
10
|
+
and ``full_merge`` are portable by construction (DELETE+INSERT / set difference).
|
|
11
|
+
|
|
12
|
+
The ADBC connection is synchronous: calls run in a worker thread behind a lock
|
|
13
|
+
(one statement at a time per engine — remote engines parallelise internally).
|
|
14
|
+
Requires the ``adbc`` extra (``pip install 'interlaced[adbc]'``).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import threading
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import pyarrow as pa
|
|
25
|
+
from sqlglot import exp
|
|
26
|
+
|
|
27
|
+
from interlace.engines.base import EngineAdapter, EngineCaps, LoadMode
|
|
28
|
+
from interlace.exceptions import ConfigurationError
|
|
29
|
+
from interlace.ir.relation import TableRef
|
|
30
|
+
|
|
31
|
+
_POSTGRES_CAPS = EngineCaps(
|
|
32
|
+
supports_create_or_replace=False, # no CREATE OR REPLACE TABLE -> DROP+CREATE fallback
|
|
33
|
+
supports_star_exclude=False, # no SELECT * EXCLUDE -> scd_type_2 unsupported for now
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PostgresAdapter(EngineAdapter):
|
|
38
|
+
"""Executes canonical ASTs inside Postgres; Arrow in and out via ADBC."""
|
|
39
|
+
|
|
40
|
+
dialect = "postgres"
|
|
41
|
+
caps = _POSTGRES_CAPS
|
|
42
|
+
|
|
43
|
+
def __init__(self, connection: Any) -> None: # adbc_driver_postgresql.dbapi.Connection
|
|
44
|
+
self._conn = connection
|
|
45
|
+
self._lock = threading.Lock()
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def connect(cls, dsn: str) -> PostgresAdapter:
|
|
49
|
+
try:
|
|
50
|
+
import adbc_driver_postgresql.dbapi as dbapi # type: ignore[import-untyped]
|
|
51
|
+
except ImportError as exc: # pragma: no cover - import guard
|
|
52
|
+
raise ConfigurationError(
|
|
53
|
+
"the postgres engine needs the 'adbc' extra: pip install 'interlaced[adbc]'"
|
|
54
|
+
) from exc
|
|
55
|
+
return cls(dbapi.connect(dsn))
|
|
56
|
+
|
|
57
|
+
def close(self) -> None:
|
|
58
|
+
self._conn.close()
|
|
59
|
+
|
|
60
|
+
# --- EngineAdapter ------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
async def execute(self, ast: exp.Expression) -> None:
|
|
63
|
+
await self.execute_sql(self.transpile(ast))
|
|
64
|
+
|
|
65
|
+
async def execute_all(self, statements: Sequence[exp.Expression]) -> list[int]:
|
|
66
|
+
return await asyncio.to_thread(self._execute_all_sync, [self.transpile(s) for s in statements])
|
|
67
|
+
|
|
68
|
+
async def fetch(self, ast: exp.Expression) -> pa.RecordBatchReader:
|
|
69
|
+
return await self.fetch_sql(self.transpile(ast))
|
|
70
|
+
|
|
71
|
+
async def load(self, table: TableRef, reader: pa.RecordBatchReader, mode: LoadMode) -> int:
|
|
72
|
+
return await asyncio.to_thread(self._load_sync, table, reader, mode)
|
|
73
|
+
|
|
74
|
+
async def create_view(self, name: TableRef, target: TableRef) -> None:
|
|
75
|
+
await self.execute_sql(
|
|
76
|
+
f"CREATE OR REPLACE VIEW {self._table_sql(name)} AS SELECT * FROM {self._table_sql(target)}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async def execute_sql(self, sql: str) -> None:
|
|
80
|
+
await asyncio.to_thread(self._execute_sync, sql)
|
|
81
|
+
|
|
82
|
+
async def fetch_sql(self, sql: str) -> pa.RecordBatchReader:
|
|
83
|
+
return await asyncio.to_thread(self._fetch_sync, sql)
|
|
84
|
+
|
|
85
|
+
async def create_schema(self, name: str) -> None:
|
|
86
|
+
await self.execute_sql(f"CREATE SCHEMA IF NOT EXISTS {exp.to_identifier(name).sql(dialect=self.dialect)}")
|
|
87
|
+
|
|
88
|
+
async def table_exists(self, table: TableRef) -> bool:
|
|
89
|
+
return await asyncio.to_thread(self._table_exists_sync, table)
|
|
90
|
+
|
|
91
|
+
async def describe(self, table: TableRef) -> dict[str, str]:
|
|
92
|
+
return await asyncio.to_thread(self._describe_sync, table)
|
|
93
|
+
|
|
94
|
+
# --- sync workers (one connection; ADBC is synchronous) ------------------
|
|
95
|
+
|
|
96
|
+
def _table_sql(self, table: TableRef) -> str:
|
|
97
|
+
return table.to_expr().sql(dialect=self.dialect)
|
|
98
|
+
|
|
99
|
+
def _execute_sync(self, sql: str) -> None:
|
|
100
|
+
with self._lock, self._conn.cursor() as cur:
|
|
101
|
+
cur.execute(sql)
|
|
102
|
+
self._conn.commit()
|
|
103
|
+
|
|
104
|
+
def _execute_all_sync(self, sqls: list[str]) -> list[int]:
|
|
105
|
+
counts: list[int] = []
|
|
106
|
+
with self._lock:
|
|
107
|
+
try:
|
|
108
|
+
with self._conn.cursor() as cur:
|
|
109
|
+
for sql in sqls:
|
|
110
|
+
cur.execute(sql)
|
|
111
|
+
rowcount = getattr(cur, "rowcount", -1)
|
|
112
|
+
counts.append(rowcount if isinstance(rowcount, int) and rowcount > 0 else 0)
|
|
113
|
+
self._conn.commit() # ADBC autocommit is off: this is one transaction
|
|
114
|
+
except Exception:
|
|
115
|
+
self._conn.rollback()
|
|
116
|
+
raise
|
|
117
|
+
return counts
|
|
118
|
+
|
|
119
|
+
def _fetch_sync(self, sql: str) -> pa.RecordBatchReader:
|
|
120
|
+
with self._lock, self._conn.cursor() as cur:
|
|
121
|
+
cur.execute(sql)
|
|
122
|
+
table = cur.fetch_arrow_table() # materialised: keeps the reader cursor-independent
|
|
123
|
+
self._conn.commit()
|
|
124
|
+
return table.to_reader()
|
|
125
|
+
|
|
126
|
+
def _load_sync(self, table: TableRef, reader: pa.RecordBatchReader, mode: LoadMode) -> int:
|
|
127
|
+
ingest_mode = "replace" if mode == "create" else "append" # "create" == CREATE OR REPLACE semantics
|
|
128
|
+
with self._lock:
|
|
129
|
+
try:
|
|
130
|
+
with self._conn.cursor() as cur:
|
|
131
|
+
loaded = cur.adbc_ingest(table.name, reader, mode=ingest_mode, db_schema_name=table.schema)
|
|
132
|
+
self._conn.commit()
|
|
133
|
+
except Exception:
|
|
134
|
+
self._conn.rollback()
|
|
135
|
+
raise
|
|
136
|
+
return int(loaded) if isinstance(loaded, int) and loaded > 0 else 0
|
|
137
|
+
|
|
138
|
+
def _table_exists_sync(self, table: TableRef) -> bool:
|
|
139
|
+
with self._lock, self._conn.cursor() as cur:
|
|
140
|
+
cur.execute(
|
|
141
|
+
"SELECT count(*) FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2",
|
|
142
|
+
(table.schema, table.name),
|
|
143
|
+
)
|
|
144
|
+
row = cur.fetchone()
|
|
145
|
+
self._conn.commit()
|
|
146
|
+
return bool(row and row[0])
|
|
147
|
+
|
|
148
|
+
def _describe_sync(self, table: TableRef) -> dict[str, str]:
|
|
149
|
+
with self._lock, self._conn.cursor() as cur:
|
|
150
|
+
cur.execute(
|
|
151
|
+
"SELECT column_name, data_type FROM information_schema.columns "
|
|
152
|
+
"WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position",
|
|
153
|
+
(table.schema, table.name),
|
|
154
|
+
)
|
|
155
|
+
rows = cur.fetchall()
|
|
156
|
+
self._conn.commit()
|
|
157
|
+
return dict(rows)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Quack engine adapter — a remote DuckDB warehouse over the quack protocol.
|
|
2
|
+
|
|
3
|
+
Connects to a warehouse served by ``interlace serve --quack`` (or any
|
|
4
|
+
``CALL quack_serve(...)``). Every statement is shipped to the server via the
|
|
5
|
+
``quack_query`` table function — full SQL pass-through with results streamed
|
|
6
|
+
back as Arrow — because quack's catalog ATTACH only resolves the server's main
|
|
7
|
+
schema while the protocol is in beta. Arrow loads go through the attached
|
|
8
|
+
remote catalog (DDL resolves schema-qualified names correctly over the wire).
|
|
9
|
+
Multi-statement plans are sent as one BEGIN/COMMIT payload so they stay atomic
|
|
10
|
+
server-side. Requires DuckDB >= 1.5.3 (quack is a core extension there).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
import duckdb
|
|
18
|
+
import pyarrow as pa
|
|
19
|
+
from sqlglot import exp
|
|
20
|
+
|
|
21
|
+
from interlace.engines.base import LoadMode
|
|
22
|
+
from interlace.engines.duckdb import DuckDBAdapter
|
|
23
|
+
from interlace.ir.relation import TableRef
|
|
24
|
+
|
|
25
|
+
_REMOTE = "__interlace_remote"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sql_literal(value: str) -> str:
|
|
29
|
+
return exp.Literal.string(value).sql(dialect="duckdb")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class QuackAdapter(DuckDBAdapter):
|
|
33
|
+
"""Executes canonical ASTs against a quack-served warehouse."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, connection: duckdb.DuckDBPyConnection, uri: str) -> None:
|
|
36
|
+
super().__init__(connection)
|
|
37
|
+
self._uri = uri
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def connect(cls, path: str, token: str | None = None) -> QuackAdapter:
|
|
41
|
+
"""Connect to a ``quack:<host>:<port>`` warehouse; ``token`` is the serve token."""
|
|
42
|
+
conn = duckdb.connect(":memory:")
|
|
43
|
+
if token:
|
|
44
|
+
conn.execute(f"CREATE SECRET {_REMOTE}_secret (TYPE quack, TOKEN {sql_literal(token)})")
|
|
45
|
+
conn.execute(f"ATTACH {sql_literal(path)} AS {_REMOTE}") # used for Arrow loads; also validates reachability
|
|
46
|
+
return cls(conn, path)
|
|
47
|
+
|
|
48
|
+
# --- sync workers: route SQL through quack_query -------------------------
|
|
49
|
+
|
|
50
|
+
def _remote_sync(self, cur: duckdb.DuckDBPyConnection, sql: str) -> duckdb.DuckDBPyConnection:
|
|
51
|
+
return cur.execute("FROM quack_query(?, ?)", [self._uri, sql])
|
|
52
|
+
|
|
53
|
+
def _execute_sync(self, sql: str) -> None:
|
|
54
|
+
cur = self._conn.cursor()
|
|
55
|
+
try:
|
|
56
|
+
self._remote_sync(cur, sql)
|
|
57
|
+
finally:
|
|
58
|
+
cur.close()
|
|
59
|
+
|
|
60
|
+
def _execute_all_sync(self, sqls: list[str]) -> list[int]:
|
|
61
|
+
# One payload, transactional server-side; quack executes it as a unit —
|
|
62
|
+
# per-statement affected counts are not observable over the wire.
|
|
63
|
+
self._execute_sync(";\n".join(["BEGIN", *sqls, "COMMIT"]))
|
|
64
|
+
return [0] * len(sqls)
|
|
65
|
+
|
|
66
|
+
def _fetch_sync(self, sql: str) -> pa.RecordBatchReader:
|
|
67
|
+
cur = self._conn.cursor()
|
|
68
|
+
return self._remote_sync(cur, sql).to_arrow_reader()
|
|
69
|
+
|
|
70
|
+
def _load_sync(self, table: TableRef, reader: pa.RecordBatchReader, mode: LoadMode) -> int:
|
|
71
|
+
# Register the reader locally; schema-qualified DDL against the attached
|
|
72
|
+
# remote catalog streams the batches over the wire. The attach caches the
|
|
73
|
+
# remote catalog, so refresh it to see schemas created since we connected.
|
|
74
|
+
cur = self._conn.cursor()
|
|
75
|
+
cur.execute("CALL quack_clear_cache()")
|
|
76
|
+
src = f"__interlace_src_{uuid4().hex}"
|
|
77
|
+
cur.register(src, reader)
|
|
78
|
+
try:
|
|
79
|
+
target = exp.table_(table.name, db=table.schema, catalog=_REMOTE).sql(dialect=self.dialect)
|
|
80
|
+
if mode == "create":
|
|
81
|
+
cur.execute(f"CREATE OR REPLACE TABLE {target} AS SELECT * FROM {src}")
|
|
82
|
+
else:
|
|
83
|
+
cur.execute(f"INSERT INTO {target} SELECT * FROM {src}")
|
|
84
|
+
rows = cur.fetchall()
|
|
85
|
+
return int(rows[0][0]) if rows and rows[0] and isinstance(rows[0][0], int) else 0
|
|
86
|
+
finally:
|
|
87
|
+
cur.unregister(src)
|
|
88
|
+
cur.close()
|
|
89
|
+
|
|
90
|
+
def _table_exists_sync(self, table: TableRef) -> bool:
|
|
91
|
+
sql = (
|
|
92
|
+
"SELECT count(*) FROM information_schema.tables "
|
|
93
|
+
f"WHERE table_schema = {sql_literal(table.schema)} AND table_name = {sql_literal(table.name)}"
|
|
94
|
+
)
|
|
95
|
+
cur = self._conn.cursor()
|
|
96
|
+
try:
|
|
97
|
+
row = self._remote_sync(cur, sql).fetchone()
|
|
98
|
+
finally:
|
|
99
|
+
cur.close()
|
|
100
|
+
return bool(row and row[0])
|
|
101
|
+
|
|
102
|
+
def _describe_sync(self, table: TableRef) -> dict[str, str]:
|
|
103
|
+
sql = (
|
|
104
|
+
"SELECT column_name, data_type FROM information_schema.columns "
|
|
105
|
+
f"WHERE table_schema = {sql_literal(table.schema)} AND table_name = {sql_literal(table.name)} "
|
|
106
|
+
"ORDER BY ordinal_position"
|
|
107
|
+
)
|
|
108
|
+
cur = self._conn.cursor()
|
|
109
|
+
try:
|
|
110
|
+
rows = self._remote_sync(cur, sql).fetchall()
|
|
111
|
+
finally:
|
|
112
|
+
cur.close()
|
|
113
|
+
return dict(rows)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Named engine registry — multi-engine routing without a global singleton.
|
|
2
|
+
|
|
3
|
+
A project may declare several engines in ``interlace.yaml``. The registry opens
|
|
4
|
+
them lazily (so unused remote engines stay cold), resolves models by name, and
|
|
5
|
+
closes everything on teardown. Single-engine projects use one entry named
|
|
6
|
+
``default`` synthesised from the top-level warehouse fields.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
12
|
+
from typing import TypeVar
|
|
13
|
+
|
|
14
|
+
from interlace.engines.base import EngineAdapter
|
|
15
|
+
from interlace.exceptions import ConfigurationError, PlanError
|
|
16
|
+
|
|
17
|
+
_T = TypeVar("_T", bound=EngineAdapter)
|
|
18
|
+
|
|
19
|
+
Opener = Callable[[str], EngineAdapter]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EngineRegistry(Mapping[str, EngineAdapter]):
|
|
23
|
+
"""Lazy map of engine name → open :class:`EngineAdapter`.
|
|
24
|
+
|
|
25
|
+
``get(name)`` opens on first use. Unknown names raise
|
|
26
|
+
:class:`ConfigurationError`. ``default`` is the project's default engine name.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
names: Iterable[str],
|
|
32
|
+
opener: Opener,
|
|
33
|
+
*,
|
|
34
|
+
default: str = "default",
|
|
35
|
+
attach_uris: Mapping[str, str] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._names = frozenset(names)
|
|
38
|
+
self._opener = opener
|
|
39
|
+
self.default = default
|
|
40
|
+
self._cache: dict[str, EngineAdapter] = {}
|
|
41
|
+
# engine name -> URI another DuckDB-family engine could ATTACH (transfer
|
|
42
|
+
# fast lane); absent/None = only reachable through its own adapter.
|
|
43
|
+
self.attach_uris: dict[str, str] = dict(attach_uris or {})
|
|
44
|
+
if default not in self._names:
|
|
45
|
+
raise ConfigurationError(
|
|
46
|
+
f"default_engine {default!r} is not a configured engine",
|
|
47
|
+
details={"engines": sorted(self._names)},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def __getitem__(self, name: str) -> EngineAdapter:
|
|
51
|
+
return self.get(name)
|
|
52
|
+
|
|
53
|
+
def __iter__(self) -> Iterator[str]:
|
|
54
|
+
return iter(sorted(self._names))
|
|
55
|
+
|
|
56
|
+
def __len__(self) -> int:
|
|
57
|
+
return len(self._names)
|
|
58
|
+
|
|
59
|
+
def __contains__(self, name: object) -> bool:
|
|
60
|
+
return isinstance(name, str) and name in self._names
|
|
61
|
+
|
|
62
|
+
def get(self, name: str | None = None) -> EngineAdapter: # type: ignore[override]
|
|
63
|
+
"""Return the adapter for ``name`` (or the default engine), opening if needed."""
|
|
64
|
+
key = self.default if name is None else name
|
|
65
|
+
if key not in self._names:
|
|
66
|
+
raise ConfigurationError(
|
|
67
|
+
f"unknown engine {key!r}",
|
|
68
|
+
details={"engines": sorted(self._names)},
|
|
69
|
+
)
|
|
70
|
+
if key not in self._cache:
|
|
71
|
+
self._cache[key] = self._opener(key)
|
|
72
|
+
return self._cache[key]
|
|
73
|
+
|
|
74
|
+
def require(self, name: str, *, model: str | None = None) -> EngineAdapter:
|
|
75
|
+
"""Like :meth:`get`, but raise :class:`PlanError` when the engine is missing."""
|
|
76
|
+
if name not in self._names:
|
|
77
|
+
where = f" for model {model!r}" if model else ""
|
|
78
|
+
raise PlanError(
|
|
79
|
+
f"engine {name!r}{where} is not configured",
|
|
80
|
+
details={"engines": sorted(self._names)},
|
|
81
|
+
)
|
|
82
|
+
return self.get(name)
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
"""Close every opened adapter (idempotent for already-closed adapters)."""
|
|
86
|
+
for adapter in self._cache.values():
|
|
87
|
+
close = getattr(adapter, "close", None)
|
|
88
|
+
if callable(close):
|
|
89
|
+
close()
|
|
90
|
+
self._cache.clear()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def as_registry(
|
|
94
|
+
engine: EngineAdapter | None = None,
|
|
95
|
+
engines: Mapping[str, EngineAdapter] | EngineRegistry | None = None,
|
|
96
|
+
*,
|
|
97
|
+
default: str = "default",
|
|
98
|
+
) -> EngineRegistry:
|
|
99
|
+
"""Normalise the ``engine=`` / ``engines=`` apply kwargs into a registry.
|
|
100
|
+
|
|
101
|
+
Existing call sites that pass a single ``engine=`` keep working: that adapter
|
|
102
|
+
is registered under ``default``.
|
|
103
|
+
"""
|
|
104
|
+
if isinstance(engines, EngineRegistry):
|
|
105
|
+
if engine is not None:
|
|
106
|
+
# Prefer the registry; a bare engine is only a fallback for default.
|
|
107
|
+
pass
|
|
108
|
+
return engines
|
|
109
|
+
|
|
110
|
+
cache: dict[str, EngineAdapter] = dict(engines or {})
|
|
111
|
+
if engine is not None:
|
|
112
|
+
cache.setdefault(default, engine)
|
|
113
|
+
if not cache:
|
|
114
|
+
raise PlanError("apply requires engine= or engines=")
|
|
115
|
+
|
|
116
|
+
def opener(name: str) -> EngineAdapter:
|
|
117
|
+
try:
|
|
118
|
+
return cache[name]
|
|
119
|
+
except KeyError as exc:
|
|
120
|
+
raise ConfigurationError(
|
|
121
|
+
f"unknown engine {name!r}",
|
|
122
|
+
details={"engines": sorted(cache)},
|
|
123
|
+
) from exc
|
|
124
|
+
|
|
125
|
+
return EngineRegistry(cache.keys(), opener, default=default if default in cache else next(iter(cache)))
|