dbt-hotdata 0.2.1__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.
- dbt/adapters/hotdata/__init__.py +19 -0
- dbt/adapters/hotdata/__version__.py +1 -0
- dbt/adapters/hotdata/client.py +384 -0
- dbt/adapters/hotdata/column.py +74 -0
- dbt/adapters/hotdata/connections.py +185 -0
- dbt/adapters/hotdata/credentials.py +154 -0
- dbt/adapters/hotdata/impl.py +291 -0
- dbt/adapters/hotdata/relation.py +19 -0
- dbt/adapters/hotdata/seeds.py +154 -0
- dbt/include/hotdata/__init__.py +3 -0
- dbt/include/hotdata/dbt_project.yml +5 -0
- dbt/include/hotdata/macros/adapters.sql +97 -0
- dbt/include/hotdata/macros/materializations/incremental.sql +65 -0
- dbt/include/hotdata/macros/materializations/seed.sql +33 -0
- dbt/include/hotdata/macros/materializations/snapshot.sql +12 -0
- dbt/include/hotdata/macros/materializations/table.sql +28 -0
- dbt/include/hotdata/macros/materializations/view.sql +13 -0
- dbt/include/hotdata/macros/utils.sql +53 -0
- dbt/include/hotdata/profile_template.yml +14 -0
- dbt_hotdata-0.2.1.dist-info/METADATA +218 -0
- dbt_hotdata-0.2.1.dist-info/RECORD +23 -0
- dbt_hotdata-0.2.1.dist-info/WHEEL +4 -0
- dbt_hotdata-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from dbt.adapters.base import AdapterPlugin
|
|
2
|
+
|
|
3
|
+
from dbt.adapters.hotdata.connections import HotdataConnectionManager
|
|
4
|
+
from dbt.adapters.hotdata.credentials import HotdataCredentials
|
|
5
|
+
from dbt.adapters.hotdata.impl import HotdataAdapter
|
|
6
|
+
from dbt.include import hotdata
|
|
7
|
+
|
|
8
|
+
Plugin = AdapterPlugin(
|
|
9
|
+
adapter=HotdataAdapter, # type: ignore[arg-type]
|
|
10
|
+
credentials=HotdataCredentials,
|
|
11
|
+
include_path=hotdata.PACKAGE_PATH,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"HotdataAdapter",
|
|
16
|
+
"HotdataConnectionManager",
|
|
17
|
+
"HotdataCredentials",
|
|
18
|
+
"Plugin",
|
|
19
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
version = "0.2.1"
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Retry-wrapped Hotdata client used by the dbt adapter.
|
|
2
|
+
|
|
3
|
+
Builds on :class:`hotdata_framework.managed_client.ManagedDatabaseClient` (the
|
|
4
|
+
shared client behind hotdata-dlt-destination and hotdata-airflow) and adds the
|
|
5
|
+
adapter's addressing rules:
|
|
6
|
+
|
|
7
|
+
* **Id-first.** An instant database is identified by its id, never by name —
|
|
8
|
+
Hotdata names are not unique. A pinned ``database_id`` is fetched once via
|
|
9
|
+
``GET /databases/{id}``; there is deliberately no by-name lookup.
|
|
10
|
+
* **Create on first run.** With no ``database_id`` and
|
|
11
|
+
``create_database_if_missing``, the database is created (labelled
|
|
12
|
+
``database_name``) and its new id is logged so it can be pinned.
|
|
13
|
+
* **One resolution per invocation.** The resolved record is cached on the
|
|
14
|
+
credentials object all of a run's connections share (dbt hands
|
|
15
|
+
``profile.credentials`` — one instance — to every thread), so threads share
|
|
16
|
+
a single bind/create without any state outliving the invocation. A second
|
|
17
|
+
``dbtRunner.invoke()`` in the same process gets fresh credentials and
|
|
18
|
+
resolves fresh — nothing can serve a stale or differently-configured record.
|
|
19
|
+
|
|
20
|
+
Every SQL statement is executed server-side (HotSQL, a Postgres-familiar
|
|
21
|
+
dialect) scoped to the resolved database, and results come back as Arrow.
|
|
22
|
+
There is no DDL surface: tables are created by declaring them and loading
|
|
23
|
+
parquet (``replace`` / ``append`` / ``upsert`` / ``delete`` modes).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import tempfile
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
33
|
+
|
|
34
|
+
import pyarrow as pa
|
|
35
|
+
import pyarrow.parquet as pq
|
|
36
|
+
from dbt.adapters.events.logging import AdapterLogger
|
|
37
|
+
from hotdata_framework.databases import (
|
|
38
|
+
ManagedDatabase,
|
|
39
|
+
ManagedTable,
|
|
40
|
+
managed_database_from_detail,
|
|
41
|
+
)
|
|
42
|
+
from hotdata_framework.errors import HotdataError, HotdataTerminalError
|
|
43
|
+
from hotdata_framework.managed_client import ManagedDatabaseClient
|
|
44
|
+
|
|
45
|
+
from hotdata.api.databases_api import DatabasesApi
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from hotdata_framework.client import ManagedLoadMode
|
|
49
|
+
|
|
50
|
+
from dbt.adapters.hotdata.credentials import HotdataCredentials
|
|
51
|
+
|
|
52
|
+
logger = AdapterLogger("Hotdata")
|
|
53
|
+
|
|
54
|
+
# Attribute the resolved ManagedDatabase is cached under on the shared
|
|
55
|
+
# credentials object (the run cache; see the module docstring).
|
|
56
|
+
_RESOLVED_ATTR = "_hotdata_resolved_db"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _chain_has_status(exc: Exception, status: int) -> bool:
|
|
60
|
+
"""True when ``exc`` or anything in its ``__cause__`` chain carries ``status``.
|
|
61
|
+
|
|
62
|
+
The framework wraps ``ApiException`` in typed errors raised ``from`` it, so
|
|
63
|
+
the HTTP status usually sits one level down the cause chain.
|
|
64
|
+
"""
|
|
65
|
+
current: BaseException | None = exc
|
|
66
|
+
for _ in range(6):
|
|
67
|
+
if current is None:
|
|
68
|
+
break
|
|
69
|
+
if getattr(current, "status", None) == status:
|
|
70
|
+
return True
|
|
71
|
+
current = current.__cause__
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_not_found(exc: Exception) -> bool:
|
|
76
|
+
return _chain_has_status(exc, 404)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _chain_text(exc: Exception, limit: int = 6) -> str:
|
|
80
|
+
"""Lowercased messages of ``exc`` and its ``__cause__`` chain, joined."""
|
|
81
|
+
parts: list[str] = []
|
|
82
|
+
current: BaseException | None = exc
|
|
83
|
+
for _ in range(limit):
|
|
84
|
+
if current is None:
|
|
85
|
+
break
|
|
86
|
+
parts.append(str(current))
|
|
87
|
+
current = current.__cause__
|
|
88
|
+
return " ".join(parts).lower()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_already_exists_conflict(exc: Exception) -> bool:
|
|
92
|
+
"""A 409 whose message says the resource already exists.
|
|
93
|
+
|
|
94
|
+
409 is also how the engine reports catalog-lock contention (transient);
|
|
95
|
+
only the already-exists form is a success signal for declarations.
|
|
96
|
+
"""
|
|
97
|
+
return _chain_has_status(exc, 409) and "already exists" in _chain_text(exc)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _quote_ident(name: str) -> str:
|
|
101
|
+
"""Postgres-style identifier quoting, escaping embedded quotes."""
|
|
102
|
+
return '"' + name.replace('"', '""') + '"'
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class HotdataDbtClient(ManagedDatabaseClient):
|
|
106
|
+
"""One instance per dbt connection (thread); resolution is shared."""
|
|
107
|
+
|
|
108
|
+
# Serializes resolution across threads so exactly one of them binds or
|
|
109
|
+
# creates; the resolved record itself lives on the credentials object.
|
|
110
|
+
_resolve_lock: ClassVar[threading.Lock] = threading.Lock()
|
|
111
|
+
|
|
112
|
+
def __init__(self, credentials: HotdataCredentials) -> None:
|
|
113
|
+
super().__init__(
|
|
114
|
+
api_key=credentials.resolve_api_key(),
|
|
115
|
+
workspace_id=credentials.workspace_id or "",
|
|
116
|
+
api_base_url=credentials.api_base_url,
|
|
117
|
+
max_retries=credentials.max_retries,
|
|
118
|
+
retry_backoff_seconds=credentials.retry_backoff_seconds,
|
|
119
|
+
)
|
|
120
|
+
self._credentials = credentials
|
|
121
|
+
self._database_id = credentials.database_id
|
|
122
|
+
self._database_label = credentials.database_name
|
|
123
|
+
self._create_if_missing = credentials.create_database_if_missing
|
|
124
|
+
|
|
125
|
+
# --- resolution (id-first, never by name) -----------------------------
|
|
126
|
+
|
|
127
|
+
def _get_database_by_id(self, database_id: str) -> ManagedDatabase:
|
|
128
|
+
"""``GET /databases/{id}``; raises ``KeyError`` when the id is gone."""
|
|
129
|
+
try:
|
|
130
|
+
detail = self._request_with_retry(
|
|
131
|
+
lambda: DatabasesApi(self._runtime.api).get_database(database_id)
|
|
132
|
+
)
|
|
133
|
+
except HotdataTerminalError as exc:
|
|
134
|
+
if _is_not_found(exc):
|
|
135
|
+
raise KeyError(database_id) from exc
|
|
136
|
+
raise
|
|
137
|
+
return managed_database_from_detail(detail)
|
|
138
|
+
|
|
139
|
+
def _create_database(self) -> ManagedDatabase:
|
|
140
|
+
db = self._request_with_retry(
|
|
141
|
+
lambda: self._runtime.create_managed_database(description=self._database_label)
|
|
142
|
+
)
|
|
143
|
+
# Loud on purpose: without pinning this id, the next run creates
|
|
144
|
+
# another database (names are labels, not identifiers).
|
|
145
|
+
logger.warning(
|
|
146
|
+
f"hotdata: created instant database {db.id} (name={self._database_label!r}). "
|
|
147
|
+
f"Pin it for future runs by setting database_id: {db.id} in profiles.yml."
|
|
148
|
+
)
|
|
149
|
+
return db
|
|
150
|
+
|
|
151
|
+
def database(self, *, create: bool = True) -> ManagedDatabase | None:
|
|
152
|
+
"""Resolve the run's instant database (id-first), creating if allowed.
|
|
153
|
+
|
|
154
|
+
``create=False`` is the probe form used by metadata calls before any
|
|
155
|
+
model has run: it never creates and returns ``None`` when nothing is
|
|
156
|
+
pinned, so an empty workspace lists as empty instead of allocating a
|
|
157
|
+
database as a side effect of `dbt docs generate` or a dry parse.
|
|
158
|
+
"""
|
|
159
|
+
with self._resolve_lock:
|
|
160
|
+
cached = getattr(self._credentials, _RESOLVED_ATTR, None)
|
|
161
|
+
if cached is not None:
|
|
162
|
+
return cached
|
|
163
|
+
if self._database_id:
|
|
164
|
+
try:
|
|
165
|
+
db = self._get_database_by_id(self._database_id)
|
|
166
|
+
except KeyError:
|
|
167
|
+
# Ids are server-assigned: a pinned id can never be
|
|
168
|
+
# recreated, so this is terminal, not a silent recreate.
|
|
169
|
+
raise HotdataTerminalError(
|
|
170
|
+
f"configured database_id {self._database_id!r} was not found "
|
|
171
|
+
"(it may have been dropped). An instant database cannot be "
|
|
172
|
+
"recreated with the same id — unset database_id to create a "
|
|
173
|
+
"new one, or pin an existing id."
|
|
174
|
+
) from None
|
|
175
|
+
elif not create:
|
|
176
|
+
return None
|
|
177
|
+
elif self._create_if_missing:
|
|
178
|
+
db = self._create_database()
|
|
179
|
+
else:
|
|
180
|
+
raise HotdataTerminalError(
|
|
181
|
+
"no instant database is configured: set database_id: in "
|
|
182
|
+
"profiles.yml, or set create_database_if_missing: true to "
|
|
183
|
+
"create one on first run."
|
|
184
|
+
)
|
|
185
|
+
setattr(self._credentials, _RESOLVED_ATTR, db)
|
|
186
|
+
return db
|
|
187
|
+
|
|
188
|
+
def _invalidate_resolution(self) -> None:
|
|
189
|
+
"""Drop the run cache after the database itself went missing remotely."""
|
|
190
|
+
with self._resolve_lock:
|
|
191
|
+
if getattr(self._credentials, _RESOLVED_ATTR, None) is not None:
|
|
192
|
+
setattr(self._credentials, _RESOLVED_ATTR, None)
|
|
193
|
+
|
|
194
|
+
def _database_gone(self, db: ManagedDatabase) -> HotdataTerminalError:
|
|
195
|
+
self._invalidate_resolution()
|
|
196
|
+
return HotdataTerminalError(
|
|
197
|
+
f"instant database {db.id} was not found — it appears to have been "
|
|
198
|
+
"dropped while this run was using it."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _require_database(self) -> ManagedDatabase:
|
|
202
|
+
db = self.database(create=True)
|
|
203
|
+
assert db is not None # create=True never returns None
|
|
204
|
+
return db
|
|
205
|
+
|
|
206
|
+
def resolved_database_id(self) -> str | None:
|
|
207
|
+
db = self.database(create=False)
|
|
208
|
+
return db.id if db else None
|
|
209
|
+
|
|
210
|
+
# --- SQL (server-side, Arrow back) -------------------------------------
|
|
211
|
+
|
|
212
|
+
def execute_sql(self, sql: str) -> pa.Table:
|
|
213
|
+
"""Run SQL scoped to the run's database; return the full result as Arrow.
|
|
214
|
+
|
|
215
|
+
Submits the query, polls the run/result until ready, and fetches the
|
|
216
|
+
stored result as Arrow — the inline response rows are only a preview.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def operation() -> pa.Table:
|
|
220
|
+
db = self._require_database()
|
|
221
|
+
result_id = self._query_database_scoped(sql, database_id=db.id)
|
|
222
|
+
if result_id is None:
|
|
223
|
+
return pa.table({})
|
|
224
|
+
return self._fetch_result_arrow(result_id, database_id=db.id)
|
|
225
|
+
|
|
226
|
+
return self._request_with_retry(operation)
|
|
227
|
+
|
|
228
|
+
# --- managed tables -----------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def list_tables(self, schema: str) -> list[ManagedTable]:
|
|
231
|
+
db = self.database(create=False)
|
|
232
|
+
if db is None:
|
|
233
|
+
return []
|
|
234
|
+
try:
|
|
235
|
+
return self._request_with_retry(
|
|
236
|
+
lambda: self._runtime.list_managed_tables(db.id, schema=schema)
|
|
237
|
+
)
|
|
238
|
+
except HotdataError as exc:
|
|
239
|
+
# A 404 here can only mean the database itself: nothing narrower
|
|
240
|
+
# is addressed. Drop the run cache so the failure is explained
|
|
241
|
+
# instead of cascading into cryptic downstream errors.
|
|
242
|
+
if _is_not_found(exc):
|
|
243
|
+
raise self._database_gone(db) from exc
|
|
244
|
+
raise
|
|
245
|
+
|
|
246
|
+
def list_schemas(self) -> list[str]:
|
|
247
|
+
db = self.database(create=False)
|
|
248
|
+
if db is None:
|
|
249
|
+
return []
|
|
250
|
+
try:
|
|
251
|
+
tables = self._request_with_retry(lambda: self._runtime.list_managed_tables(db.id))
|
|
252
|
+
except HotdataError as exc:
|
|
253
|
+
if _is_not_found(exc):
|
|
254
|
+
raise self._database_gone(db) from exc
|
|
255
|
+
raise
|
|
256
|
+
return sorted({t.schema for t in tables})
|
|
257
|
+
|
|
258
|
+
def has_table(self, table: str, *, schema: str) -> bool:
|
|
259
|
+
return any(t.table == table for t in self.list_tables(schema))
|
|
260
|
+
|
|
261
|
+
def ensure_schema(self, schema: str) -> None:
|
|
262
|
+
"""Declare ``schema`` on the database; already-declared is success.
|
|
263
|
+
|
|
264
|
+
Tables can only be declared inside a declared schema — schemas do NOT
|
|
265
|
+
come into being with their first table. dbt calls ``create_schema``
|
|
266
|
+
for every schema its nodes need before running; this backs it.
|
|
267
|
+
"""
|
|
268
|
+
from hotdata.models.add_managed_schema_request import AddManagedSchemaRequest
|
|
269
|
+
|
|
270
|
+
db = self._require_database()
|
|
271
|
+
|
|
272
|
+
def declare() -> None:
|
|
273
|
+
try:
|
|
274
|
+
DatabasesApi(self._runtime.api).add_database_schema(
|
|
275
|
+
db.id, AddManagedSchemaRequest(name=schema)
|
|
276
|
+
)
|
|
277
|
+
except Exception as error:
|
|
278
|
+
if _is_already_exists_conflict(error):
|
|
279
|
+
return # already declared — that's the goal state
|
|
280
|
+
raise
|
|
281
|
+
|
|
282
|
+
self._request_with_retry(declare)
|
|
283
|
+
|
|
284
|
+
def ensure_table(self, table: str, *, schema: str, key: list[str] | None = None) -> None:
|
|
285
|
+
"""Declare ``table`` on the database; already-declared is success.
|
|
286
|
+
|
|
287
|
+
Declares unconditionally and treats the server's 409 CONFLICT
|
|
288
|
+
("already exists") as success instead of pre-checking existence: the
|
|
289
|
+
check-then-create pattern races concurrent invocations, and — worse —
|
|
290
|
+
the framework classifies 409 as transient (for load catalog-lock
|
|
291
|
+
contention), so an unswallowed conflict would be retried for the whole
|
|
292
|
+
~42s budget before failing. A table declared concurrently without our
|
|
293
|
+
``key`` still upserts fine: the merge key is also passed per-load.
|
|
294
|
+
"""
|
|
295
|
+
db = self._require_database()
|
|
296
|
+
|
|
297
|
+
def declare() -> None:
|
|
298
|
+
try:
|
|
299
|
+
self._runtime.add_managed_table(db.id, table, schema=schema, key=key)
|
|
300
|
+
except Exception as error:
|
|
301
|
+
if _is_already_exists_conflict(error):
|
|
302
|
+
return # already declared — that's the goal state
|
|
303
|
+
if _is_not_found(error) and "schema" in _chain_text(error):
|
|
304
|
+
# "Schema '<x>' is not declared": dbt normally creates
|
|
305
|
+
# schemas up front, but custom materializations may not —
|
|
306
|
+
# declare it and retry the table once.
|
|
307
|
+
self.ensure_schema(schema)
|
|
308
|
+
self._runtime.add_managed_table(db.id, table, schema=schema, key=key)
|
|
309
|
+
return
|
|
310
|
+
raise # anything else (incl. lock-contention 409s) retries normally
|
|
311
|
+
|
|
312
|
+
self._request_with_retry(declare)
|
|
313
|
+
|
|
314
|
+
def load_arrow(
|
|
315
|
+
self,
|
|
316
|
+
table: str,
|
|
317
|
+
*,
|
|
318
|
+
schema: str,
|
|
319
|
+
data: pa.Table,
|
|
320
|
+
mode: ManagedLoadMode = "replace",
|
|
321
|
+
key: list[str] | None = None,
|
|
322
|
+
) -> int:
|
|
323
|
+
"""Write ``data`` as parquet, upload it, and apply it to ``table``.
|
|
324
|
+
|
|
325
|
+
Returns the loaded row count. ``key`` is the per-load merge key for
|
|
326
|
+
``upsert``/``delete`` modes; ignored for ``replace``/``append``.
|
|
327
|
+
"""
|
|
328
|
+
db = self._require_database()
|
|
329
|
+
with tempfile.TemporaryDirectory(prefix="dbt_hotdata_") as tmp_dir:
|
|
330
|
+
path = os.path.join(tmp_dir, "data.parquet")
|
|
331
|
+
pq.write_table(data, path)
|
|
332
|
+
upload_id = self.upload_parquet(path)
|
|
333
|
+
attempts = max(self._max_retries, 1)
|
|
334
|
+
result = None
|
|
335
|
+
for attempt in range(1, attempts + 1):
|
|
336
|
+
try:
|
|
337
|
+
result = self.load_managed_table(
|
|
338
|
+
db.id, table, schema=schema, upload_id=upload_id, mode=mode, key=key
|
|
339
|
+
)
|
|
340
|
+
break
|
|
341
|
+
except HotdataError as exc:
|
|
342
|
+
# Deletes propagate lazily: dropping a table and redeclaring
|
|
343
|
+
# its name can land the declare on the stale entry (409
|
|
344
|
+
# already-exists, swallowed as success) while the load runs
|
|
345
|
+
# after the delete applied — 404. Re-declaring inside the
|
|
346
|
+
# retry converges from either side of that window. The miss
|
|
347
|
+
# happens before anything is applied, so retrying is safe for
|
|
348
|
+
# every mode — including append, which the framework itself
|
|
349
|
+
# never retries.
|
|
350
|
+
if _is_not_found(exc) and attempt < attempts:
|
|
351
|
+
time.sleep(min(self._retry_backoff_seconds * attempt, 5.0))
|
|
352
|
+
self.ensure_table(table, schema=schema, key=key)
|
|
353
|
+
continue
|
|
354
|
+
raise
|
|
355
|
+
assert result is not None # loop either breaks with a result or raises
|
|
356
|
+
# An authoritative 0 from the server (e.g. a no-op upsert) must not be
|
|
357
|
+
# overwritten by the input count — `or` would treat it as missing.
|
|
358
|
+
return data.num_rows if result.row_count is None else result.row_count
|
|
359
|
+
|
|
360
|
+
def truncate_table(self, table: str, *, schema: str) -> None:
|
|
361
|
+
"""Empty a table while keeping its schema: replace-load zero rows.
|
|
362
|
+
|
|
363
|
+
A ``LIMIT 0`` probe captures the table's current Arrow schema, and a
|
|
364
|
+
``replace`` load of that empty result clears the contents in place.
|
|
365
|
+
"""
|
|
366
|
+
qualified = f'"default".{_quote_ident(schema)}.{_quote_ident(table)}'
|
|
367
|
+
empty = self.execute_sql(f"select * from {qualified} limit 0")
|
|
368
|
+
self.load_arrow(table, schema=schema, data=empty, mode="replace")
|
|
369
|
+
|
|
370
|
+
def drop_table(self, table: str, *, schema: str) -> None:
|
|
371
|
+
"""Delete ``table``; already-absent is success (no check-then-drop race)."""
|
|
372
|
+
db = self.database(create=False)
|
|
373
|
+
if db is None:
|
|
374
|
+
return
|
|
375
|
+
try:
|
|
376
|
+
self._request_with_retry(
|
|
377
|
+
lambda: self._runtime.delete_managed_table(db.id, table, schema=schema)
|
|
378
|
+
)
|
|
379
|
+
except HotdataError as exc:
|
|
380
|
+
if not _is_not_found(exc):
|
|
381
|
+
raise
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
__all__ = ["HotdataDbtClient"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import pyarrow as pa
|
|
6
|
+
from dbt.adapters.base.column import Column
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def dtype_from_arrow(arrow_type: pa.DataType) -> str:
|
|
10
|
+
"""Render an Arrow type as the SQL type name HotSQL presents.
|
|
11
|
+
|
|
12
|
+
Used when describing relations: the engine returns Arrow schemas, and dbt
|
|
13
|
+
(docs, `{{ col.data_type }}`, schema tests) expects SQL type names.
|
|
14
|
+
"""
|
|
15
|
+
if pa.types.is_boolean(arrow_type):
|
|
16
|
+
return "boolean"
|
|
17
|
+
if pa.types.is_int8(arrow_type) or pa.types.is_int16(arrow_type):
|
|
18
|
+
return "smallint"
|
|
19
|
+
if pa.types.is_int32(arrow_type):
|
|
20
|
+
return "integer"
|
|
21
|
+
if pa.types.is_int64(arrow_type) or pa.types.is_unsigned_integer(arrow_type):
|
|
22
|
+
return "bigint"
|
|
23
|
+
if pa.types.is_float32(arrow_type):
|
|
24
|
+
return "real"
|
|
25
|
+
if pa.types.is_float64(arrow_type):
|
|
26
|
+
return "double precision"
|
|
27
|
+
if pa.types.is_decimal(arrow_type):
|
|
28
|
+
return f"numeric({arrow_type.precision},{arrow_type.scale})"
|
|
29
|
+
if pa.types.is_date(arrow_type):
|
|
30
|
+
return "date"
|
|
31
|
+
if pa.types.is_timestamp(arrow_type):
|
|
32
|
+
return "timestamptz" if arrow_type.tz else "timestamp"
|
|
33
|
+
if pa.types.is_time(arrow_type):
|
|
34
|
+
return "time"
|
|
35
|
+
if pa.types.is_duration(arrow_type):
|
|
36
|
+
return "interval"
|
|
37
|
+
if (
|
|
38
|
+
pa.types.is_binary(arrow_type)
|
|
39
|
+
or pa.types.is_large_binary(arrow_type)
|
|
40
|
+
or pa.types.is_binary_view(arrow_type)
|
|
41
|
+
):
|
|
42
|
+
return "bytea"
|
|
43
|
+
if (
|
|
44
|
+
pa.types.is_string(arrow_type)
|
|
45
|
+
or pa.types.is_large_string(arrow_type)
|
|
46
|
+
# The engine reads loaded string columns back as Utf8View; the SQL
|
|
47
|
+
# name must still be a castable one, never "string_view".
|
|
48
|
+
or pa.types.is_string_view(arrow_type)
|
|
49
|
+
):
|
|
50
|
+
return "varchar"
|
|
51
|
+
# Nested/list/struct and anything else: fall back to the Arrow name so the
|
|
52
|
+
# information is preserved rather than mislabeled.
|
|
53
|
+
return str(arrow_type)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class HotdataColumn(Column):
|
|
58
|
+
@classmethod
|
|
59
|
+
def string_type(cls, size: int) -> str:
|
|
60
|
+
# The base class renders "character varying(256)", which HotSQL
|
|
61
|
+
# rejects in casts; strings are unbounded here.
|
|
62
|
+
return "varchar"
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_arrow_field(cls, field: pa.Field) -> HotdataColumn:
|
|
66
|
+
arrow_type = field.type
|
|
67
|
+
numeric_precision = arrow_type.precision if pa.types.is_decimal(arrow_type) else None
|
|
68
|
+
numeric_scale = arrow_type.scale if pa.types.is_decimal(arrow_type) else None
|
|
69
|
+
return cls(
|
|
70
|
+
column=field.name,
|
|
71
|
+
dtype=dtype_from_arrow(arrow_type),
|
|
72
|
+
numeric_precision=numeric_precision,
|
|
73
|
+
numeric_scale=numeric_scale,
|
|
74
|
+
)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""dbt connection manager for Hotdata.
|
|
2
|
+
|
|
3
|
+
There is no database driver here: a "connection" is an HTTPS client
|
|
4
|
+
(:class:`HotdataDbtClient`), SQL executes server-side as HotSQL
|
|
5
|
+
(Postgres-familiar) scoped to the run's instant database, and results come
|
|
6
|
+
back as Arrow. Consequences for the dbt contract:
|
|
7
|
+
|
|
8
|
+
* ``begin``/``commit`` are no-ops — the engine has no transactions.
|
|
9
|
+
* There is no bind protocol — SQL goes over as a literal string.
|
|
10
|
+
* ``cancel`` is a no-op — an in-flight HTTP query cannot be interrupted.
|
|
11
|
+
|
|
12
|
+
Transient failures (409 from a concurrent writer's catalog lock, 429, 5xx)
|
|
13
|
+
are retried inside the client for up to ``max_retries`` x backoff before
|
|
14
|
+
they surface here; whatever does surface is terminal for the node.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import time
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
from dbt.adapters.contracts.connection import (
|
|
24
|
+
AdapterResponse,
|
|
25
|
+
Connection,
|
|
26
|
+
ConnectionState,
|
|
27
|
+
)
|
|
28
|
+
from dbt.adapters.events.logging import AdapterLogger
|
|
29
|
+
from dbt.adapters.exceptions import FailedToConnectError
|
|
30
|
+
from dbt.adapters.sql import SQLConnectionManager
|
|
31
|
+
from dbt_common.exceptions import DbtRuntimeError
|
|
32
|
+
from hotdata_framework.errors import HotdataError
|
|
33
|
+
|
|
34
|
+
from dbt.adapters.hotdata.client import HotdataDbtClient
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from collections.abc import Iterator
|
|
38
|
+
|
|
39
|
+
import agate
|
|
40
|
+
import pyarrow as pa
|
|
41
|
+
|
|
42
|
+
logger = AdapterLogger("Hotdata")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _ArrowCursorShim:
|
|
46
|
+
"""Just enough DB-API cursor for base-class helpers that expect one.
|
|
47
|
+
|
|
48
|
+
Row materialization is lazy: most statements run with ``fetch=False`` and
|
|
49
|
+
never touch rows, so the Arrow table must not be copied into Python
|
|
50
|
+
objects at construction time.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, table: pa.Table) -> None:
|
|
54
|
+
self.table = table
|
|
55
|
+
self._materialized: list[dict[str, Any]] | None = None
|
|
56
|
+
self._pos = 0
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def _rows(self) -> list[dict[str, Any]]:
|
|
60
|
+
if self._materialized is None:
|
|
61
|
+
self._materialized = self.table.to_pylist()
|
|
62
|
+
return self._materialized
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def description(self) -> list[tuple[Any, ...]]:
|
|
66
|
+
return [(name, None, None, None, None, None, None) for name in self.table.column_names]
|
|
67
|
+
|
|
68
|
+
def fetchall(self) -> list[tuple[Any, ...]]:
|
|
69
|
+
rows = [tuple(row.values()) for row in self._rows[self._pos :]]
|
|
70
|
+
self._pos = len(self._rows)
|
|
71
|
+
return rows
|
|
72
|
+
|
|
73
|
+
def fetchmany(self, size: int) -> list[tuple[Any, ...]]:
|
|
74
|
+
end = self._pos + size
|
|
75
|
+
rows = [tuple(row.values()) for row in self._rows[self._pos : end]]
|
|
76
|
+
self._pos = min(end, len(self._rows))
|
|
77
|
+
return rows
|
|
78
|
+
|
|
79
|
+
def fetchone(self) -> tuple[Any, ...] | None:
|
|
80
|
+
if self._pos >= len(self._rows):
|
|
81
|
+
return None
|
|
82
|
+
row = tuple(self._rows[self._pos].values())
|
|
83
|
+
self._pos += 1
|
|
84
|
+
return row
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class HotdataConnectionManager(SQLConnectionManager):
|
|
88
|
+
TYPE = "hotdata"
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def open(cls, connection: Connection) -> Connection:
|
|
92
|
+
if connection.state == ConnectionState.OPEN:
|
|
93
|
+
return connection
|
|
94
|
+
credentials = connection.credentials
|
|
95
|
+
try:
|
|
96
|
+
credentials.validate_connection_setup()
|
|
97
|
+
connection.handle = HotdataDbtClient(credentials)
|
|
98
|
+
connection.state = ConnectionState.OPEN
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
connection.handle = None
|
|
101
|
+
connection.state = ConnectionState.FAIL
|
|
102
|
+
raise FailedToConnectError(str(exc)) from exc
|
|
103
|
+
return connection
|
|
104
|
+
|
|
105
|
+
def cancel(self, connection: Connection) -> None:
|
|
106
|
+
# An HTTPS query in flight has nothing to interrupt client-side.
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def get_response(cls, cursor: Any) -> AdapterResponse:
|
|
111
|
+
rows = getattr(getattr(cursor, "table", None), "num_rows", None)
|
|
112
|
+
return AdapterResponse(_message="OK", rows_affected=rows)
|
|
113
|
+
|
|
114
|
+
@contextmanager
|
|
115
|
+
def exception_handler(self, sql: str) -> Iterator[None]:
|
|
116
|
+
try:
|
|
117
|
+
yield
|
|
118
|
+
except DbtRuntimeError:
|
|
119
|
+
raise
|
|
120
|
+
except HotdataError as exc:
|
|
121
|
+
logger.debug(f"hotdata error while running:\n{sql}")
|
|
122
|
+
raise DbtRuntimeError(str(exc)) from exc
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
logger.debug(f"error while running:\n{sql}")
|
|
125
|
+
raise DbtRuntimeError(str(exc)) from exc
|
|
126
|
+
|
|
127
|
+
# --- execution ----------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def add_query(
|
|
130
|
+
self,
|
|
131
|
+
sql: str,
|
|
132
|
+
auto_begin: bool = True,
|
|
133
|
+
bindings: Any | None = None,
|
|
134
|
+
abridge_sql_log: bool = False,
|
|
135
|
+
retryable_exceptions: tuple[type[Exception], ...] = (),
|
|
136
|
+
retry_limit: int = 1,
|
|
137
|
+
) -> tuple[Connection, Any]:
|
|
138
|
+
if bindings:
|
|
139
|
+
raise DbtRuntimeError(
|
|
140
|
+
"the hotdata adapter does not support parameterized queries "
|
|
141
|
+
"(the query API takes a plain SQL string with no bind protocol)"
|
|
142
|
+
)
|
|
143
|
+
connection = self.get_thread_connection()
|
|
144
|
+
client: HotdataDbtClient = connection.handle
|
|
145
|
+
fire_sql = sql if not abridge_sql_log else f"{sql[:512]}..."
|
|
146
|
+
logger.debug(f'Using hotdata connection "{connection.name}"')
|
|
147
|
+
logger.debug(f"On {connection.name}: {fire_sql}")
|
|
148
|
+
started = time.perf_counter()
|
|
149
|
+
with self.exception_handler(sql):
|
|
150
|
+
table = client.execute_sql(sql)
|
|
151
|
+
elapsed = time.perf_counter() - started
|
|
152
|
+
logger.debug(f"SQL status: OK ({table.num_rows} rows) in {elapsed:.2f} seconds")
|
|
153
|
+
return connection, _ArrowCursorShim(table)
|
|
154
|
+
|
|
155
|
+
def execute(
|
|
156
|
+
self,
|
|
157
|
+
sql: str,
|
|
158
|
+
auto_begin: bool = False,
|
|
159
|
+
fetch: bool = False,
|
|
160
|
+
limit: int | None = None,
|
|
161
|
+
) -> tuple[AdapterResponse, agate.Table]:
|
|
162
|
+
from dbt_common.clients.agate_helper import empty_table, table_from_data_flat
|
|
163
|
+
|
|
164
|
+
sql = self._add_query_comment(sql)
|
|
165
|
+
_, cursor = self.add_query(sql, auto_begin)
|
|
166
|
+
response = self.get_response(cursor)
|
|
167
|
+
if fetch:
|
|
168
|
+
arrow: pa.Table = cursor.table
|
|
169
|
+
if limit is not None:
|
|
170
|
+
arrow = arrow.slice(0, limit)
|
|
171
|
+
table = table_from_data_flat(arrow.to_pylist(), arrow.column_names)
|
|
172
|
+
else:
|
|
173
|
+
table = empty_table()
|
|
174
|
+
return response, table
|
|
175
|
+
|
|
176
|
+
# --- transactions (none) --------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def begin(self) -> None:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
def commit(self) -> None:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
def clear_transaction(self) -> None:
|
|
185
|
+
pass
|