thunderduck-sqlalchemy 0.1.0__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.
- thunderduck_sqlalchemy/__init__.py +13 -0
- thunderduck_sqlalchemy/catalog.py +76 -0
- thunderduck_sqlalchemy/client.py +216 -0
- thunderduck_sqlalchemy/dbapi.py +306 -0
- thunderduck_sqlalchemy/dialect.py +242 -0
- thunderduck_sqlalchemy/exceptions.py +48 -0
- thunderduck_sqlalchemy/py.typed +0 -0
- thunderduck_sqlalchemy/superset_spec.py +124 -0
- thunderduck_sqlalchemy/types.py +232 -0
- thunderduck_sqlalchemy-0.1.0.dist-info/METADATA +153 -0
- thunderduck_sqlalchemy-0.1.0.dist-info/RECORD +13 -0
- thunderduck_sqlalchemy-0.1.0.dist-info/WHEEL +4 -0
- thunderduck_sqlalchemy-0.1.0.dist-info/entry_points.txt +7 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""SQLAlchemy dialect and DBAPI for thunderduck.io.
|
|
2
|
+
|
|
3
|
+
Read-only by design: every statement is submitted to console-api, which runs
|
|
4
|
+
it as a Kubernetes Job, and results are paged back over HTTP. See README.md
|
|
5
|
+
for the connection URL format and the caveats that follow from that model.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .dbapi import connect
|
|
9
|
+
from .dialect import ThunderduckDialect
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__", "connect", "ThunderduckDialect"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Flatten console-api's `/catalog/schema` tree into `(schema, table)` pairs.
|
|
2
|
+
|
|
3
|
+
## Two catalog shapes, one rule
|
|
4
|
+
|
|
5
|
+
thunderduck names are not uniformly three-part. Nessie catalogs are 2-level
|
|
6
|
+
(`catalog.table`) and put their tables directly under the catalog; flat-file
|
|
7
|
+
catalogs are 3-level (`catalog.schema.table`) and put them under named
|
|
8
|
+
namespaces. SQLAlchemy and Superset both model exactly two levels.
|
|
9
|
+
|
|
10
|
+
The rule that reconciles them: **the composite `schema` is the whole dotted
|
|
11
|
+
prefix and the `table` is the leaf.** So `iris` in the nessie catalog
|
|
12
|
+
`thunder_duck_demo` has schema `"thunder_duck_demo"`, while `lego_sets` has
|
|
13
|
+
schema `"lego.public"`. The dialect's identifier preparer later splits that
|
|
14
|
+
prefix back apart and quotes each segment, so both compile correctly.
|
|
15
|
+
|
|
16
|
+
A catalog carrying an `error` (console-api could not introspect it) is skipped
|
|
17
|
+
rather than raised on: one unreachable catalog must not break reflection of
|
|
18
|
+
every other one.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class TableRef:
|
|
29
|
+
schema: str
|
|
30
|
+
table: str
|
|
31
|
+
kind: str = "table"
|
|
32
|
+
columns: list[dict[str, str]] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def flatten(tree: dict[str, Any] | None) -> list[TableRef]:
|
|
36
|
+
refs: list[TableRef] = []
|
|
37
|
+
for catalog in (tree or {}).get("catalogs") or []:
|
|
38
|
+
if catalog.get("error"):
|
|
39
|
+
continue
|
|
40
|
+
catalog_name = catalog.get("name")
|
|
41
|
+
if not catalog_name:
|
|
42
|
+
continue
|
|
43
|
+
for table in catalog.get("tables") or []:
|
|
44
|
+
ref = _make_ref(catalog_name, table)
|
|
45
|
+
if ref:
|
|
46
|
+
refs.append(ref)
|
|
47
|
+
for namespace in catalog.get("namespaces") or []:
|
|
48
|
+
namespace_name = namespace.get("name")
|
|
49
|
+
if not namespace_name:
|
|
50
|
+
continue
|
|
51
|
+
schema = f"{catalog_name}.{namespace_name}"
|
|
52
|
+
for table in namespace.get("tables") or []:
|
|
53
|
+
ref = _make_ref(schema, table)
|
|
54
|
+
if ref:
|
|
55
|
+
refs.append(ref)
|
|
56
|
+
return refs
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _make_ref(schema: str, table: dict[str, Any]) -> TableRef | None:
|
|
60
|
+
name = table.get("name")
|
|
61
|
+
if not name:
|
|
62
|
+
return None
|
|
63
|
+
kind = str(table.get("kind") or "table").lower()
|
|
64
|
+
columns = [
|
|
65
|
+
{"name": str(c.get("name")), "type": str(c.get("type") or "")}
|
|
66
|
+
for c in (table.get("columns") or [])
|
|
67
|
+
if c.get("name")
|
|
68
|
+
]
|
|
69
|
+
return TableRef(schema=schema, table=str(name), kind=kind, columns=columns)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def split_schema(schema: str) -> list[str]:
|
|
73
|
+
"""Split a composite schema into its segments (`"lego.public"` -> 2)."""
|
|
74
|
+
if not schema:
|
|
75
|
+
return []
|
|
76
|
+
return schema.split(".")
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""HTTP transport for console-api.
|
|
2
|
+
|
|
3
|
+
This is the only module that speaks HTTP. It deliberately knows nothing about
|
|
4
|
+
cursors or SQLAlchemy: it submits SQL, reports execution state, pages results
|
|
5
|
+
and cancels runs. The Java JDBC driver's `ApiClient.java` is the reference
|
|
6
|
+
implementation of the same protocol.
|
|
7
|
+
|
|
8
|
+
Two design notes:
|
|
9
|
+
|
|
10
|
+
* **Polling, not streaming.** Submitting returns immediately with an execution
|
|
11
|
+
id; the run happens in a Kubernetes Job. `await_execution` polls
|
|
12
|
+
`GET /executions/{id}` until a terminal status. console-api also offers an
|
|
13
|
+
SSE endpoint (`POST /queries/stream`) which would cut first-row latency;
|
|
14
|
+
that is a deliberate follow-up, not used here.
|
|
15
|
+
* **The token never appears in a URL or an error message.** It travels only in
|
|
16
|
+
the `Authorization` header, and error construction never interpolates it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from . import exceptions as exc
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Execution:
|
|
32
|
+
"""State of one query run (a row of `query_executions`)."""
|
|
33
|
+
|
|
34
|
+
uuid: str
|
|
35
|
+
status: str
|
|
36
|
+
error_message: str | None = None
|
|
37
|
+
result_location: str | None = None
|
|
38
|
+
result_row_count: int | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ResultPage:
|
|
43
|
+
"""One page of a stored result set."""
|
|
44
|
+
|
|
45
|
+
columns: list[str]
|
|
46
|
+
column_types: list[str]
|
|
47
|
+
rows: list[list[Any]]
|
|
48
|
+
total: int
|
|
49
|
+
offset: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class NoStoredResult(Exception):
|
|
53
|
+
"""The execution stored no result set (a non-SELECT statement).
|
|
54
|
+
|
|
55
|
+
Deliberately *not* a PEP 249 error: `dbapi.Cursor` catches it and presents
|
|
56
|
+
an empty result set, which is what a DBAPI caller expects after running
|
|
57
|
+
something that returns no rows.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
#: Statuses that mean the run is over, one way or another.
|
|
62
|
+
TERMINAL_STATUSES = frozenset({"success", "error", "cancelled"})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ThunderduckClient:
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
base_url: str,
|
|
69
|
+
token: str,
|
|
70
|
+
*,
|
|
71
|
+
poll_interval: float = 1.0,
|
|
72
|
+
timeout: float = 300.0,
|
|
73
|
+
transport: httpx.BaseTransport | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._token = token
|
|
76
|
+
self._poll_interval = poll_interval
|
|
77
|
+
self._timeout = timeout
|
|
78
|
+
self._http = httpx.Client(
|
|
79
|
+
base_url=base_url,
|
|
80
|
+
transport=transport,
|
|
81
|
+
timeout=httpx.Timeout(30.0),
|
|
82
|
+
headers={
|
|
83
|
+
"Authorization": f"Bearer {token}",
|
|
84
|
+
"Accept": "application/json",
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# ---- public API ----
|
|
89
|
+
|
|
90
|
+
def submit(self, sql: str) -> str:
|
|
91
|
+
payload = self._request("POST", "/queries", json={"sql": sql})
|
|
92
|
+
exec_id = payload.get("id")
|
|
93
|
+
if not exec_id:
|
|
94
|
+
raise exc.InternalError("console-api did not return an execution id")
|
|
95
|
+
return str(exec_id)
|
|
96
|
+
|
|
97
|
+
def get_execution(self, exec_id: str) -> Execution:
|
|
98
|
+
payload = self._request("GET", f"/executions/{exec_id}")
|
|
99
|
+
return Execution(
|
|
100
|
+
uuid=str(payload.get("uuid", exec_id)),
|
|
101
|
+
status=str(payload.get("status", "")),
|
|
102
|
+
error_message=payload.get("error_message"),
|
|
103
|
+
result_location=payload.get("result_location"),
|
|
104
|
+
result_row_count=payload.get("result_row_count"),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def await_execution(self, exec_id: str) -> Execution:
|
|
108
|
+
"""Poll until the run reaches a terminal status, then return it.
|
|
109
|
+
|
|
110
|
+
Raises on `error` and `cancelled` so callers only ever see a
|
|
111
|
+
successful `Execution` come back.
|
|
112
|
+
"""
|
|
113
|
+
deadline = time.monotonic() + self._timeout
|
|
114
|
+
while True:
|
|
115
|
+
execution = self.get_execution(exec_id)
|
|
116
|
+
if execution.status in TERMINAL_STATUSES:
|
|
117
|
+
if execution.status == "error":
|
|
118
|
+
raise exc.ProgrammingError(
|
|
119
|
+
execution.error_message or "query failed without a message"
|
|
120
|
+
)
|
|
121
|
+
if execution.status == "cancelled":
|
|
122
|
+
raise exc.OperationalError("query was cancelled")
|
|
123
|
+
return execution
|
|
124
|
+
if time.monotonic() >= deadline:
|
|
125
|
+
raise exc.OperationalError(
|
|
126
|
+
f"query timed out after {self._timeout}s (still {execution.status})"
|
|
127
|
+
)
|
|
128
|
+
if self._poll_interval:
|
|
129
|
+
time.sleep(self._poll_interval)
|
|
130
|
+
|
|
131
|
+
def fetch_page(self, exec_id: str, limit: int, offset: int) -> ResultPage:
|
|
132
|
+
payload = self._request(
|
|
133
|
+
"GET",
|
|
134
|
+
f"/executions/{exec_id}/result",
|
|
135
|
+
params={"limit": limit, "offset": offset},
|
|
136
|
+
no_result_is_signal=True,
|
|
137
|
+
)
|
|
138
|
+
return ResultPage(
|
|
139
|
+
columns=list(payload.get("columns") or []),
|
|
140
|
+
column_types=list(payload.get("column_types") or []),
|
|
141
|
+
rows=[list(r) for r in (payload.get("rows") or [])],
|
|
142
|
+
total=int(payload.get("total") or 0),
|
|
143
|
+
offset=int(payload.get("offset") or offset),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def cancel(self, exec_id: str) -> None:
|
|
147
|
+
"""Best-effort cancel; a finished or unknown run is not an error.
|
|
148
|
+
|
|
149
|
+
"Best effort" covers the run's *state* (already finished, never
|
|
150
|
+
existed) — not the credentials. A 401/403 means the token itself was
|
|
151
|
+
rejected, which must not be swallowed: doing so would make a stop
|
|
152
|
+
button appear to work while quietly hiding a rejected token.
|
|
153
|
+
"""
|
|
154
|
+
try:
|
|
155
|
+
self._request("POST", f"/executions/{exec_id}/cancel")
|
|
156
|
+
except exc.InterfaceError:
|
|
157
|
+
raise
|
|
158
|
+
except exc.Error:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
def catalog_schema(self) -> dict[str, Any]:
|
|
162
|
+
return self._request("GET", "/catalog/schema")
|
|
163
|
+
|
|
164
|
+
def close(self) -> None:
|
|
165
|
+
self._http.close()
|
|
166
|
+
|
|
167
|
+
# ---- internals ----
|
|
168
|
+
|
|
169
|
+
def _request(
|
|
170
|
+
self,
|
|
171
|
+
method: str,
|
|
172
|
+
path: str,
|
|
173
|
+
*,
|
|
174
|
+
json: Any = None,
|
|
175
|
+
params: Any = None,
|
|
176
|
+
no_result_is_signal: bool = False,
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
try:
|
|
179
|
+
response = self._http.request(method, path, json=json, params=params)
|
|
180
|
+
except httpx.HTTPError as err:
|
|
181
|
+
# Never chain the original: httpx puts the full URL in its message,
|
|
182
|
+
# and a caller may have embedded a token in a query string.
|
|
183
|
+
raise exc.OperationalError(
|
|
184
|
+
f"could not reach thunderduck: {type(err).__name__}"
|
|
185
|
+
) from None
|
|
186
|
+
|
|
187
|
+
if response.is_success:
|
|
188
|
+
if not response.content:
|
|
189
|
+
return {}
|
|
190
|
+
try:
|
|
191
|
+
return response.json()
|
|
192
|
+
except ValueError:
|
|
193
|
+
raise exc.InternalError("console-api returned a non-JSON success body") from None
|
|
194
|
+
|
|
195
|
+
detail = self._detail(response)
|
|
196
|
+
status = response.status_code
|
|
197
|
+
if status == 401 or status == 403:
|
|
198
|
+
raise exc.InterfaceError(f"authentication failed (check the API token): {detail}")
|
|
199
|
+
if status == 404 and no_result_is_signal:
|
|
200
|
+
raise NoStoredResult(detail)
|
|
201
|
+
if status == 409:
|
|
202
|
+
raise exc.OperationalError(detail)
|
|
203
|
+
if status == 400 or status == 422:
|
|
204
|
+
raise exc.ProgrammingError(detail)
|
|
205
|
+
raise exc.OperationalError(f"console-api returned {status}: {detail}")
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _detail(response: httpx.Response) -> str:
|
|
209
|
+
"""FastAPI puts the human-readable message in a JSON `detail` field."""
|
|
210
|
+
try:
|
|
211
|
+
body = response.json()
|
|
212
|
+
except ValueError:
|
|
213
|
+
return response.text.strip()[:200] or f"HTTP {response.status_code}"
|
|
214
|
+
if isinstance(body, dict) and "detail" in body:
|
|
215
|
+
return str(body["detail"])
|
|
216
|
+
return str(body)[:200]
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""PEP 249 interface over the thunderduck REST API.
|
|
2
|
+
|
|
3
|
+
## What a "connection" is here
|
|
4
|
+
|
|
5
|
+
Nothing on the server. console-api is stateless per request, so a
|
|
6
|
+
`Connection` is just a configured HTTP client. That has three consequences
|
|
7
|
+
worth knowing:
|
|
8
|
+
|
|
9
|
+
* There is no transaction. `commit()` and `rollback()` are no-ops and the
|
|
10
|
+
dialect reports read-only.
|
|
11
|
+
* Opening a connection costs nothing, so `NullPool` is the recommended
|
|
12
|
+
SQLAlchemy pool and pre-ping is pointless (a `SELECT 1` would start a
|
|
13
|
+
Kubernetes Job -- see `dialect.do_ping`).
|
|
14
|
+
* A `Cursor` owns one execution. `cancel()` reaches the run through its
|
|
15
|
+
execution id, which is what the Superset engine spec uses to make the stop
|
|
16
|
+
button work.
|
|
17
|
+
|
|
18
|
+
## Row paging
|
|
19
|
+
|
|
20
|
+
`execute()` waits for the run to finish, then fetches the first page. The
|
|
21
|
+
server clamps `limit` to its own maximum (`config.result_max_rows()`), so the
|
|
22
|
+
cursor must keep requesting pages by `offset` until it has `total` rows --
|
|
23
|
+
never assume the requested `limit` was honoured.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
from . import exceptions as exc
|
|
34
|
+
from .client import NoStoredResult, ThunderduckClient
|
|
35
|
+
from .exceptions import (
|
|
36
|
+
DatabaseError,
|
|
37
|
+
DataError,
|
|
38
|
+
Error,
|
|
39
|
+
IntegrityError,
|
|
40
|
+
InterfaceError,
|
|
41
|
+
InternalError,
|
|
42
|
+
NotSupportedError,
|
|
43
|
+
OperationalError,
|
|
44
|
+
ProgrammingError,
|
|
45
|
+
)
|
|
46
|
+
from .types import render_sql, sqlalchemy_type
|
|
47
|
+
|
|
48
|
+
_log = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
apilevel = "2.0"
|
|
51
|
+
#: 1 == "threads may share the module, but not connections".
|
|
52
|
+
threadsafety = 1
|
|
53
|
+
paramstyle = "pyformat"
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"apilevel",
|
|
57
|
+
"threadsafety",
|
|
58
|
+
"paramstyle",
|
|
59
|
+
"connect",
|
|
60
|
+
"Connection",
|
|
61
|
+
"Cursor",
|
|
62
|
+
"Error",
|
|
63
|
+
"InterfaceError",
|
|
64
|
+
"DatabaseError",
|
|
65
|
+
"DataError",
|
|
66
|
+
"OperationalError",
|
|
67
|
+
"IntegrityError",
|
|
68
|
+
"InternalError",
|
|
69
|
+
"ProgrammingError",
|
|
70
|
+
"NotSupportedError",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
DEFAULT_ARRAYSIZE = 1000
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def connect(
|
|
77
|
+
*,
|
|
78
|
+
host: str,
|
|
79
|
+
token: str,
|
|
80
|
+
port: int | None = None,
|
|
81
|
+
use_ssl: bool = True,
|
|
82
|
+
poll_interval: float = 1.0,
|
|
83
|
+
timeout: float = 300.0,
|
|
84
|
+
arraysize: int = DEFAULT_ARRAYSIZE,
|
|
85
|
+
transport: httpx.BaseTransport | None = None,
|
|
86
|
+
) -> Connection:
|
|
87
|
+
if not host:
|
|
88
|
+
raise exc.InterfaceError("a thunderduck host is required")
|
|
89
|
+
if not token:
|
|
90
|
+
raise exc.InterfaceError(
|
|
91
|
+
"an API token is required -- pass it as the URL password or ?token=…"
|
|
92
|
+
)
|
|
93
|
+
scheme = "https" if use_ssl else "http"
|
|
94
|
+
netloc = f"{host}:{port}" if port else host
|
|
95
|
+
client = ThunderduckClient(
|
|
96
|
+
base_url=f"{scheme}://{netloc}",
|
|
97
|
+
token=token,
|
|
98
|
+
poll_interval=poll_interval,
|
|
99
|
+
timeout=timeout,
|
|
100
|
+
transport=transport,
|
|
101
|
+
)
|
|
102
|
+
return Connection(client, arraysize=arraysize)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Connection:
|
|
106
|
+
def __init__(self, client: ThunderduckClient, *, arraysize: int = DEFAULT_ARRAYSIZE) -> None:
|
|
107
|
+
self.client = client
|
|
108
|
+
self._arraysize = arraysize
|
|
109
|
+
self._closed = False
|
|
110
|
+
|
|
111
|
+
def cursor(self) -> Cursor:
|
|
112
|
+
if self._closed:
|
|
113
|
+
raise exc.ProgrammingError("connection is closed")
|
|
114
|
+
return Cursor(self.client, arraysize=self._arraysize)
|
|
115
|
+
|
|
116
|
+
def close(self) -> None:
|
|
117
|
+
if not self._closed:
|
|
118
|
+
self._closed = True
|
|
119
|
+
self.client.close()
|
|
120
|
+
|
|
121
|
+
def commit(self) -> None:
|
|
122
|
+
"""No-op: thunderduck has no transactions (read-only driver)."""
|
|
123
|
+
|
|
124
|
+
def rollback(self) -> None:
|
|
125
|
+
"""No-op: see `commit`."""
|
|
126
|
+
|
|
127
|
+
def __enter__(self) -> Connection:
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def __exit__(self, *_exc: object) -> None:
|
|
131
|
+
self.close()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Cursor:
|
|
135
|
+
def __init__(self, client: ThunderduckClient, *, arraysize: int = DEFAULT_ARRAYSIZE) -> None:
|
|
136
|
+
self._client = client
|
|
137
|
+
self.arraysize = arraysize
|
|
138
|
+
self._closed = False
|
|
139
|
+
self._exec_id: str | None = None
|
|
140
|
+
self._description: list[tuple] | None = None
|
|
141
|
+
self._rowcount = -1
|
|
142
|
+
self._buffer: list[tuple] = []
|
|
143
|
+
self._buffer_pos = 0
|
|
144
|
+
self._next_offset = 0
|
|
145
|
+
self._total = 0
|
|
146
|
+
self._exhausted = True
|
|
147
|
+
|
|
148
|
+
# ---- introspection ----
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def description(self) -> list[tuple] | None:
|
|
152
|
+
return self._description
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def rowcount(self) -> int:
|
|
156
|
+
return self._rowcount
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def execution_id(self) -> str | None:
|
|
160
|
+
"""The thunderduck execution uuid of the last `execute`, if any."""
|
|
161
|
+
return self._exec_id
|
|
162
|
+
|
|
163
|
+
# ---- execution ----
|
|
164
|
+
|
|
165
|
+
def execute(self, operation: str, parameters: Any = None) -> Cursor:
|
|
166
|
+
self._check_open()
|
|
167
|
+
self._reset()
|
|
168
|
+
sql = render_sql(operation, parameters)
|
|
169
|
+
self._exec_id = self._client.submit(sql)
|
|
170
|
+
self._client.await_execution(self._exec_id)
|
|
171
|
+
self._load_first_page()
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
def executemany(self, operation: str, seq_of_parameters: Any) -> None:
|
|
175
|
+
raise exc.NotSupportedError(
|
|
176
|
+
"executemany is not supported: thunderduck is read-only and every "
|
|
177
|
+
"statement is a separate Kubernetes Job"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def cancel(self) -> None:
|
|
181
|
+
"""Best-effort cancel of the in-flight (or last) execution."""
|
|
182
|
+
if self._exec_id:
|
|
183
|
+
self._client.cancel(self._exec_id)
|
|
184
|
+
|
|
185
|
+
# ---- fetching ----
|
|
186
|
+
|
|
187
|
+
def fetchone(self) -> tuple | None:
|
|
188
|
+
self._check_executed()
|
|
189
|
+
if not self._ensure_buffered():
|
|
190
|
+
return None
|
|
191
|
+
row = self._buffer[self._buffer_pos]
|
|
192
|
+
self._buffer_pos += 1
|
|
193
|
+
return row
|
|
194
|
+
|
|
195
|
+
def fetchmany(self, size: int | None = None) -> list[tuple]:
|
|
196
|
+
self._check_executed()
|
|
197
|
+
want = self.arraysize if size is None else size
|
|
198
|
+
out: list[tuple] = []
|
|
199
|
+
while len(out) < want:
|
|
200
|
+
row = self.fetchone()
|
|
201
|
+
if row is None:
|
|
202
|
+
break
|
|
203
|
+
out.append(row)
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
def fetchall(self) -> list[tuple]:
|
|
207
|
+
self._check_executed()
|
|
208
|
+
out: list[tuple] = []
|
|
209
|
+
while True:
|
|
210
|
+
row = self.fetchone()
|
|
211
|
+
if row is None:
|
|
212
|
+
return out
|
|
213
|
+
out.append(row)
|
|
214
|
+
|
|
215
|
+
def close(self) -> None:
|
|
216
|
+
self._closed = True
|
|
217
|
+
|
|
218
|
+
def __iter__(self):
|
|
219
|
+
while True:
|
|
220
|
+
row = self.fetchone()
|
|
221
|
+
if row is None:
|
|
222
|
+
return
|
|
223
|
+
yield row
|
|
224
|
+
|
|
225
|
+
# ---- internals ----
|
|
226
|
+
|
|
227
|
+
def _reset(self) -> None:
|
|
228
|
+
self._description = None
|
|
229
|
+
self._rowcount = -1
|
|
230
|
+
self._buffer = []
|
|
231
|
+
self._buffer_pos = 0
|
|
232
|
+
self._next_offset = 0
|
|
233
|
+
self._total = 0
|
|
234
|
+
self._exhausted = True
|
|
235
|
+
|
|
236
|
+
def _load_first_page(self) -> None:
|
|
237
|
+
assert self._exec_id is not None
|
|
238
|
+
try:
|
|
239
|
+
page = self._client.fetch_page(self._exec_id, limit=self.arraysize, offset=0)
|
|
240
|
+
except NoStoredResult:
|
|
241
|
+
# A non-SELECT statement: no result set, and that is fine.
|
|
242
|
+
self._description = None
|
|
243
|
+
self._rowcount = -1
|
|
244
|
+
self._buffer = []
|
|
245
|
+
self._exhausted = True
|
|
246
|
+
return
|
|
247
|
+
self._description = [
|
|
248
|
+
(
|
|
249
|
+
name,
|
|
250
|
+
sqlalchemy_type(type_name),
|
|
251
|
+
None, # display_size
|
|
252
|
+
None, # internal_size
|
|
253
|
+
None, # precision
|
|
254
|
+
None, # scale
|
|
255
|
+
True, # null_ok -- /catalog/schema carries no nullability
|
|
256
|
+
)
|
|
257
|
+
for name, type_name in zip(
|
|
258
|
+
page.columns,
|
|
259
|
+
list(page.column_types) + [""] * (len(page.columns) - len(page.column_types)),
|
|
260
|
+
strict=False,
|
|
261
|
+
)
|
|
262
|
+
]
|
|
263
|
+
self._total = page.total
|
|
264
|
+
self._rowcount = page.total
|
|
265
|
+
self._buffer = [tuple(r) for r in page.rows]
|
|
266
|
+
self._buffer_pos = 0
|
|
267
|
+
self._next_offset = len(page.rows)
|
|
268
|
+
self._exhausted = self._next_offset >= self._total or not page.rows
|
|
269
|
+
|
|
270
|
+
def _ensure_buffered(self) -> bool:
|
|
271
|
+
"""True if a row is available at `_buffer_pos`, paging if needed."""
|
|
272
|
+
if self._buffer_pos < len(self._buffer):
|
|
273
|
+
return True
|
|
274
|
+
if self._exhausted:
|
|
275
|
+
return False
|
|
276
|
+
assert self._exec_id is not None
|
|
277
|
+
page = self._client.fetch_page(
|
|
278
|
+
self._exec_id, limit=self.arraysize, offset=self._next_offset
|
|
279
|
+
)
|
|
280
|
+
if not page.rows:
|
|
281
|
+
# The result set claims more rows than it can serve. Never silently
|
|
282
|
+
# return a short result -- a truncated dashboard looks correct.
|
|
283
|
+
_log.warning(
|
|
284
|
+
"thunderduck result set is short: served %d of %d claimed rows "
|
|
285
|
+
"for execution %s; the stored result may have been truncated",
|
|
286
|
+
self._next_offset,
|
|
287
|
+
self._total,
|
|
288
|
+
self._exec_id,
|
|
289
|
+
)
|
|
290
|
+
self._exhausted = True
|
|
291
|
+
return False
|
|
292
|
+
self._buffer = [tuple(r) for r in page.rows]
|
|
293
|
+
self._buffer_pos = 0
|
|
294
|
+
self._next_offset += len(page.rows)
|
|
295
|
+
self._total = page.total or self._total
|
|
296
|
+
self._exhausted = self._next_offset >= self._total
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
def _check_open(self) -> None:
|
|
300
|
+
if self._closed:
|
|
301
|
+
raise exc.ProgrammingError("cursor is closed")
|
|
302
|
+
|
|
303
|
+
def _check_executed(self) -> None:
|
|
304
|
+
self._check_open()
|
|
305
|
+
if self._exec_id is None:
|
|
306
|
+
raise exc.ProgrammingError("no query has been executed on this cursor")
|