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.
@@ -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