arrowsql 0.1.0__cp313-cp313-win_amd64.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.
- arrowsql/__init__.py +653 -0
- arrowsql/_rust_auth.py +170 -0
- arrowsql/_server_sqlstate.py +11 -0
- arrowsql/_utf8_strings.py +78 -0
- arrowsql/agent.py +620 -0
- arrowsql/aio.py +533 -0
- arrowsql/arrow_parallel.py +464 -0
- arrowsql/async_query/__init__.py +40 -0
- arrowsql/async_query/_connection_context.py +39 -0
- arrowsql/async_query/_native.py +24 -0
- arrowsql/async_query/async_connection.py +160 -0
- arrowsql/async_query/async_cursor.py +225 -0
- arrowsql/async_query/async_execute.py +113 -0
- arrowsql/async_query/async_fetch.py +153 -0
- arrowsql/async_query/exception_translator.py +132 -0
- arrowsql/auth.py +913 -0
- arrowsql/bulk_parallel.py +385 -0
- arrowsql/connection.py +2377 -0
- arrowsql/connection_string_builder.py +114 -0
- arrowsql/connection_string_parser.py +417 -0
- arrowsql/constants.py +701 -0
- arrowsql/cursor.py +4516 -0
- arrowsql/db_connection.py +98 -0
- arrowsql/ddbc_bindings.cp313-win_amd64.pyd +0 -0
- arrowsql/ddbc_bindings.pyi +283 -0
- arrowsql/decimal_config.py +98 -0
- arrowsql/exceptions.py +378 -0
- arrowsql/helpers.py +436 -0
- arrowsql/logging.py +700 -0
- arrowsql/mssql_python.pyi +579 -0
- arrowsql/odbc_provider.py +269 -0
- arrowsql/parameter_helper.py +442 -0
- arrowsql/perf_timer.py +286 -0
- arrowsql/pooling.py +147 -0
- arrowsql/py.typed +1 -0
- arrowsql/row.py +462 -0
- arrowsql/type.py +160 -0
- arrowsql-0.1.0.dist-info/METADATA +146 -0
- arrowsql-0.1.0.dist-info/RECORD +45 -0
- arrowsql-0.1.0.dist-info/WHEEL +4 -0
- arrowsql-0.1.0.dist-info/entry_points.txt +2 -0
- arrowsql-0.1.0.dist-info/licenses/LICENSE +21 -0
- arrowsql-0.1.0.dist-info/licenses/LICENSE.upstream-mssql-python +47 -0
- arrowsql-0.1.0.dist-info/sboms/ddbc_bindings.cyclonedx.json +5195 -0
- mssql_python/__init__.py +39 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""RUST-PORT: partitioned parallel Arrow reads.
|
|
2
|
+
|
|
3
|
+
One TDS session delivers a result at whatever rate SQL Server serializes it
|
|
4
|
+
(about 1.4M rows/s for a 20-column table on a dev box), well below what the
|
|
5
|
+
Rust decoder can take. ``read_arrow`` / ``read_arrow_stream`` split the query
|
|
6
|
+
on a column into ranges and read them over separate connections at once;
|
|
7
|
+
decoding runs without the GIL, so plain threads scale.
|
|
8
|
+
|
|
9
|
+
The range is cut into more slices than workers; each worker holds one
|
|
10
|
+
connection and pulls slices from a queue, so one slow connection does not set
|
|
11
|
+
the wall time. The MIN/MAX probe runs on the first worker's connection.
|
|
12
|
+
|
|
13
|
+
``read_arrow_stream`` merges the workers' batches (native
|
|
14
|
+
``ddbc_bindings.ArrowMergeStream``, bounded buffer, unordered) so a consumer
|
|
15
|
+
overlaps with the read; ``read_arrow`` is that stream read into one
|
|
16
|
+
``ArrowTable``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import datetime
|
|
20
|
+
import math
|
|
21
|
+
from contextlib import closing
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
import queue
|
|
24
|
+
import re
|
|
25
|
+
import threading
|
|
26
|
+
import weakref
|
|
27
|
+
from typing import Any, List, Optional, Sequence, Tuple
|
|
28
|
+
|
|
29
|
+
from arrowsql import ddbc_bindings
|
|
30
|
+
from arrowsql.db_connection import connect
|
|
31
|
+
from arrowsql._utf8_strings import utf8_query
|
|
32
|
+
from arrowsql.exceptions import raise_exception
|
|
33
|
+
|
|
34
|
+
# Slices per worker, and the fewest rows worth a slice (when the row count is known).
|
|
35
|
+
_SLICES_PER_WORKER = 2
|
|
36
|
+
_MIN_SLICE_ROWS = 8 * 1024
|
|
37
|
+
# A result of at most this many rows (catalog estimate) is read over the probe
|
|
38
|
+
# connection alone (measured: parallel wins from about 20k rows up).
|
|
39
|
+
_MIN_PARALLEL_ROWS = 20_000
|
|
40
|
+
_NAME = r"(?:\[[^\]]+\]|\w+)"
|
|
41
|
+
_PLAIN_TABLE = re.compile(
|
|
42
|
+
rf"^\s*SELECT\s+(?:\*|[\w\s,.\[\]]+?)\s+FROM\s+({_NAME}(?:\.{_NAME}){{0,2}})\s*;?\s*$",
|
|
43
|
+
re.IGNORECASE,
|
|
44
|
+
)
|
|
45
|
+
_STREAM_ERROR = re.compile(r"\[(\w{5})\] (.*)", re.DOTALL)
|
|
46
|
+
_BOUND_TYPES = (int, float, Decimal, str, datetime.date, datetime.datetime, datetime.time, bytes)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _quote_identifier(name: str) -> str:
|
|
50
|
+
return "[" + name.replace("]", "]]") + "]"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_arrow(
|
|
54
|
+
connection_str: str,
|
|
55
|
+
query: str,
|
|
56
|
+
partition_on: Optional[str] = None,
|
|
57
|
+
partitions: int = 1,
|
|
58
|
+
batch_size: int = 65536,
|
|
59
|
+
utf8_strings: bool = True,
|
|
60
|
+
partition_bounds: Optional[Sequence[Any]] = None,
|
|
61
|
+
partition_nullable: Optional[bool] = None,
|
|
62
|
+
**connect_kwargs: Any,
|
|
63
|
+
) -> "ddbc_bindings.ArrowTable":
|
|
64
|
+
"""Run ``query`` and return its whole result as a native ``ArrowTable``.
|
|
65
|
+
|
|
66
|
+
With ``partition_on`` (an integer column of the result) and
|
|
67
|
+
``partitions`` > 1, the column's [min, max] range is cut into equal-width
|
|
68
|
+
slices (about twice ``partitions``, never more than the number of distinct
|
|
69
|
+
key values in the range) that ``partitions`` connections read in parallel.
|
|
70
|
+
Rows whose partition value is NULL are read as a slice of their own. A
|
|
71
|
+
result known to be small (estimated from the catalog when ``query`` is a
|
|
72
|
+
plain table) is read over a single connection. ``query`` becomes a derived
|
|
73
|
+
table, so it may not end in ORDER BY (without TOP) or contain ``?``
|
|
74
|
+
parameters.
|
|
75
|
+
|
|
76
|
+
``partition_bounds`` (typed boundaries, any column type) replaces the
|
|
77
|
+
planning; see ``read_arrow_stream``.
|
|
78
|
+
|
|
79
|
+
Slices are equal in key width, not in rows: a skewed key (a few hot values,
|
|
80
|
+
big gaps) is not rebalanced, and a column with fewer distinct values than
|
|
81
|
+
``partitions`` cannot use all connections. The partitions are separate
|
|
82
|
+
statements on separate connections, so they read at different points in
|
|
83
|
+
time: the result is not one snapshot, and rows changed concurrently may be
|
|
84
|
+
missed or read twice. Row order across partitions is not preserved.
|
|
85
|
+
|
|
86
|
+
With ``utf8_strings`` (the default) nvarchar/nchar/ntext columns are cast
|
|
87
|
+
to UTF-8 varchar on the server, which halves their bytes on the wire for
|
|
88
|
+
mostly-ASCII text; the Arrow result is the same. It needs SQL Server 2019
|
|
89
|
+
or Azure SQL and a ``query`` without ORDER BY; otherwise, or when the
|
|
90
|
+
query cannot be described, the query runs unchanged. Pass False to
|
|
91
|
+
disable. Costs two extra round trips per call.
|
|
92
|
+
"""
|
|
93
|
+
if not _partitioned(partition_on, partitions, partition_bounds):
|
|
94
|
+
return _read(connection_str, query, (), batch_size, connect_kwargs, utf8_strings)
|
|
95
|
+
probe_conn, work = _open_plan(
|
|
96
|
+
connection_str, query, partition_on, partitions, partition_bounds,
|
|
97
|
+
partition_nullable, connect_kwargs, utf8_strings,
|
|
98
|
+
)
|
|
99
|
+
if len(work) == 1:
|
|
100
|
+
with closing(probe_conn):
|
|
101
|
+
return _fetch(probe_conn, work[0][0], work[0][1], batch_size)
|
|
102
|
+
stream = _start(probe_conn, connection_str, connect_kwargs, work, partitions, batch_size, None)
|
|
103
|
+
try:
|
|
104
|
+
return stream.read_all()
|
|
105
|
+
finally:
|
|
106
|
+
stream.close()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def read_arrow_stream(
|
|
110
|
+
connection_str: str,
|
|
111
|
+
query: str,
|
|
112
|
+
partition_on: Optional[str] = None,
|
|
113
|
+
partitions: int = 1,
|
|
114
|
+
batch_size: int = 65536,
|
|
115
|
+
partition_bounds: Optional[Sequence[Any]] = None,
|
|
116
|
+
max_buffered_batches: Optional[int] = None,
|
|
117
|
+
partition_nullable: Optional[bool] = None,
|
|
118
|
+
utf8_strings: bool = True,
|
|
119
|
+
**connect_kwargs: Any,
|
|
120
|
+
) -> "ParallelArrowStream":
|
|
121
|
+
"""Like ``read_arrow``, but stream the batches while the slices are read.
|
|
122
|
+
|
|
123
|
+
Returns a ``ParallelArrowStream`` (``__arrow_c_stream__``, so DuckDB,
|
|
124
|
+
polars, pyarrow and deltalake read it directly): up to ``partitions``
|
|
125
|
+
connections decode slices without the GIL and feed one bounded buffer of
|
|
126
|
+
``max_buffered_batches`` batches (default ``2 * partitions``, at least 4),
|
|
127
|
+
so the consumer works while the server is still sending and memory stays
|
|
128
|
+
bounded. Batches arrive unordered. The stream is one-shot. ``close()``
|
|
129
|
+
(also on leaving a ``with`` block, or when a consumer stops early) cancels
|
|
130
|
+
the running statements, stops every worker and closes their connections.
|
|
131
|
+
The first worker error reaches the consumer and stops the others.
|
|
132
|
+
|
|
133
|
+
``partition_bounds``: ascending boundaries of the ``partition_on`` column
|
|
134
|
+
(``int``, ``date``/``datetime``, ``Decimal``, ``str``; one type). ``k``
|
|
135
|
+
values give the disjoint half-open slices ``< b1``, ``[b1, b2)``, ...,
|
|
136
|
+
``>= bk`` (plus an ``IS NULL`` slice when the column is nullable), each
|
|
137
|
+
bound as a typed parameter. No MIN/MAX or size probe is run, so the first
|
|
138
|
+
batch comes sooner, and skew can be handled by passing quantiles. Bounds
|
|
139
|
+
must ascend in the column's own ordering (for strings: its collation); a
|
|
140
|
+
``str`` bound is sent as nvarchar, so a ``varchar`` column is compared
|
|
141
|
+
after an implicit conversion (no index seek). ``partition_nullable`` says
|
|
142
|
+
whether the column may hold NULL (default: asked from the server, one
|
|
143
|
+
metadata call; pass False to skip it). ``utf8_strings``: as for
|
|
144
|
+
``read_arrow``.
|
|
145
|
+
"""
|
|
146
|
+
if not _partitioned(partition_on, partitions, partition_bounds):
|
|
147
|
+
probe_conn = connect(connection_str, autocommit=True, **connect_kwargs)
|
|
148
|
+
try:
|
|
149
|
+
if utf8_strings:
|
|
150
|
+
query = utf8_query(probe_conn, query)
|
|
151
|
+
except BaseException:
|
|
152
|
+
probe_conn.close()
|
|
153
|
+
raise
|
|
154
|
+
return _start(probe_conn, connection_str, connect_kwargs, [(query, ())], 1, batch_size, max_buffered_batches)
|
|
155
|
+
probe_conn, work = _open_plan(
|
|
156
|
+
connection_str, query, partition_on, partitions, partition_bounds,
|
|
157
|
+
partition_nullable, connect_kwargs, utf8_strings,
|
|
158
|
+
)
|
|
159
|
+
return _start(
|
|
160
|
+
probe_conn, connection_str, connect_kwargs, work, partitions, batch_size, max_buffered_batches
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _partitioned(partition_on, partitions, partition_bounds) -> bool:
|
|
165
|
+
if partition_bounds is not None and not partition_on:
|
|
166
|
+
raise ValueError("partition_bounds needs partition_on")
|
|
167
|
+
return bool(partition_on) and (partitions > 1 or partition_bounds is not None)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _open_plan(connection_str, query, partition_on, partitions, partition_bounds, nullable, kwargs, utf8_strings):
|
|
171
|
+
"""Open the probe connection and plan the slices: (connection, [(sql, params)...])."""
|
|
172
|
+
column = _quote_identifier(partition_on)
|
|
173
|
+
conn = connect(connection_str, autocommit=True, **kwargs)
|
|
174
|
+
try:
|
|
175
|
+
plain = query # the catalog row estimate needs the user's own text
|
|
176
|
+
if utf8_strings:
|
|
177
|
+
query = utf8_query(conn, query)
|
|
178
|
+
if partition_bounds is not None:
|
|
179
|
+
work = _bounds_plan(conn, query, column, partition_bounds, nullable)
|
|
180
|
+
else:
|
|
181
|
+
work = _plan(conn, query, column, partition_on, partitions, plain)
|
|
182
|
+
return conn, work or [(query, ())]
|
|
183
|
+
except BaseException:
|
|
184
|
+
conn.close()
|
|
185
|
+
raise
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _bounds_plan(conn, query, column, bounds, nullable) -> List[Tuple[str, tuple]]:
|
|
189
|
+
"""Slices for caller-supplied boundaries (no data probe)."""
|
|
190
|
+
bounds = list(bounds)
|
|
191
|
+
if not bounds:
|
|
192
|
+
return []
|
|
193
|
+
kind = type(bounds[0])
|
|
194
|
+
for b in bounds:
|
|
195
|
+
if isinstance(b, bool) or not isinstance(b, _BOUND_TYPES) or type(b) is not kind:
|
|
196
|
+
raise TypeError(
|
|
197
|
+
"partition_bounds must be non-NULL values of one type "
|
|
198
|
+
f"(int, Decimal, str, date, datetime); got {b!r}"
|
|
199
|
+
)
|
|
200
|
+
for a, b in zip(bounds, bounds[1:]):
|
|
201
|
+
if not a < b:
|
|
202
|
+
raise ValueError(f"partition_bounds must be strictly ascending ({a!r} !< {b!r})")
|
|
203
|
+
if nullable is None:
|
|
204
|
+
with closing(conn.cursor()) as cursor:
|
|
205
|
+
_, nullable = _probe_meta(cursor, query, column, with_rows=False)
|
|
206
|
+
base = f"SELECT * FROM ({query}) AS _q WHERE "
|
|
207
|
+
work: List[Tuple[str, tuple]] = []
|
|
208
|
+
if nullable:
|
|
209
|
+
work.append((base + f"{column} IS NULL", ()))
|
|
210
|
+
work.append((base + f"{column} < ?", (bounds[0],)))
|
|
211
|
+
for a, b in zip(bounds, bounds[1:]):
|
|
212
|
+
work.append((base + f"{column} >= ? AND {column} < ?", (a, b)))
|
|
213
|
+
work.append((base + f"{column} >= ?", (bounds[-1],)))
|
|
214
|
+
return work
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class _Workers:
|
|
218
|
+
"""Threads and open statements of one parallel stream."""
|
|
219
|
+
|
|
220
|
+
def __init__(self) -> None:
|
|
221
|
+
self.lock = threading.Lock()
|
|
222
|
+
self.cursors: set = set()
|
|
223
|
+
self.threads: List[threading.Thread] = []
|
|
224
|
+
self.error: List[BaseException] = []
|
|
225
|
+
|
|
226
|
+
def cancel_all(self) -> None:
|
|
227
|
+
with self.lock:
|
|
228
|
+
cursors = list(self.cursors)
|
|
229
|
+
for cursor in cursors:
|
|
230
|
+
try:
|
|
231
|
+
cursor.cancel()
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
def stop(self, merge, wait: bool) -> None:
|
|
236
|
+
"""Close the merge, cancel statements (repeatedly: a cancel can precede its execute)."""
|
|
237
|
+
merge.close()
|
|
238
|
+
budget = 30.0
|
|
239
|
+
while True:
|
|
240
|
+
self.cancel_all()
|
|
241
|
+
alive = [t for t in self.threads if t.is_alive() and t is not threading.current_thread()]
|
|
242
|
+
if not alive or not wait or budget <= 0:
|
|
243
|
+
return
|
|
244
|
+
alive[0].join(0.05)
|
|
245
|
+
budget -= 0.05
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _release(merge, workers) -> None:
|
|
249
|
+
# Finalizer for a stream nobody read. One handed to a consumer stops itself.
|
|
250
|
+
if not merge.in_use:
|
|
251
|
+
workers.stop(merge, wait=False)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class ParallelArrowStream:
|
|
255
|
+
"""Batches of a partitioned read, arriving while the slices are read.
|
|
256
|
+
|
|
257
|
+
Created by ``read_arrow_stream``. One-shot, unordered, bounded buffer.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
def __init__(self, merge, workers: _Workers) -> None:
|
|
261
|
+
self._merge = merge
|
|
262
|
+
self._workers = workers
|
|
263
|
+
self._finalizer = weakref.finalize(self, _release, merge, workers)
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def column_names(self) -> List[str]:
|
|
267
|
+
return self._merge.column_names
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def num_columns(self) -> int:
|
|
271
|
+
return self._merge.num_columns
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def closed(self) -> bool:
|
|
275
|
+
return self._merge.closed
|
|
276
|
+
|
|
277
|
+
def __arrow_c_schema__(self) -> Any:
|
|
278
|
+
return self._merge.__arrow_c_schema__()
|
|
279
|
+
|
|
280
|
+
def __arrow_c_stream__(self, requested_schema: Optional[Any] = None) -> Any:
|
|
281
|
+
return self._merge.__arrow_c_stream__(requested_schema)
|
|
282
|
+
|
|
283
|
+
def read_all(self) -> "ddbc_bindings.ArrowTable":
|
|
284
|
+
"""Read every remaining batch into an ``ArrowTable`` (Ctrl-C interrupts)."""
|
|
285
|
+
try:
|
|
286
|
+
return self._merge.read_all()
|
|
287
|
+
except RuntimeError as exc:
|
|
288
|
+
self.close()
|
|
289
|
+
if self._workers.error:
|
|
290
|
+
raise self._workers.error[0] from None
|
|
291
|
+
# A read that failed mid-statement arrives as "[SQLSTATE] message".
|
|
292
|
+
found = _STREAM_ERROR.search(str(exc))
|
|
293
|
+
if found:
|
|
294
|
+
raise_exception(found.group(1), found.group(2))
|
|
295
|
+
raise
|
|
296
|
+
except BaseException:
|
|
297
|
+
self.close()
|
|
298
|
+
raise
|
|
299
|
+
|
|
300
|
+
def close(self) -> None:
|
|
301
|
+
"""Cancel running statements, stop the workers, close their connections."""
|
|
302
|
+
self._workers.stop(self._merge, wait=True)
|
|
303
|
+
|
|
304
|
+
def __enter__(self) -> "ParallelArrowStream":
|
|
305
|
+
return self
|
|
306
|
+
|
|
307
|
+
def __exit__(self, *exc: Any) -> None:
|
|
308
|
+
self.close()
|
|
309
|
+
|
|
310
|
+
def __repr__(self) -> str:
|
|
311
|
+
return f"ParallelArrowStream(columns={self.column_names!r}, closed={self.closed})"
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _start(probe_conn, connection_str, connect_kwargs, work, partitions, batch_size, depth):
|
|
315
|
+
"""Run work[0] on the probe connection (for the schema), then start the workers."""
|
|
316
|
+
try:
|
|
317
|
+
cursor = probe_conn.cursor()
|
|
318
|
+
sql, params = work[0]
|
|
319
|
+
cursor.execute(sql, params) if params else cursor.execute(sql)
|
|
320
|
+
first = cursor.arrow_stream(batch_size, 1)
|
|
321
|
+
except BaseException:
|
|
322
|
+
probe_conn.close()
|
|
323
|
+
raise
|
|
324
|
+
workers = _Workers()
|
|
325
|
+
count = max(1, min(partitions, len(work)))
|
|
326
|
+
merge = ddbc_bindings.ArrowMergeStream(first, count, depth or max(2 * count, 4))
|
|
327
|
+
pending: "queue.Queue[Tuple[str, tuple]]" = queue.Queue()
|
|
328
|
+
for item in work[1:]:
|
|
329
|
+
pending.put(item)
|
|
330
|
+
|
|
331
|
+
def drain(conn) -> None:
|
|
332
|
+
while not merge.stopped:
|
|
333
|
+
try:
|
|
334
|
+
sql, params = pending.get_nowait()
|
|
335
|
+
except queue.Empty:
|
|
336
|
+
return
|
|
337
|
+
cur = conn.cursor()
|
|
338
|
+
with workers.lock:
|
|
339
|
+
workers.cursors.add(cur)
|
|
340
|
+
try:
|
|
341
|
+
cur.execute(sql, params) if params else cur.execute(sql)
|
|
342
|
+
merge.pump(cur.arrow_stream(batch_size, 1))
|
|
343
|
+
finally:
|
|
344
|
+
with workers.lock:
|
|
345
|
+
workers.cursors.discard(cur)
|
|
346
|
+
cur.close()
|
|
347
|
+
|
|
348
|
+
def run(first_stream, first_cursor, conn) -> None:
|
|
349
|
+
try:
|
|
350
|
+
if first_stream is not None:
|
|
351
|
+
with workers.lock:
|
|
352
|
+
workers.cursors.add(first_cursor)
|
|
353
|
+
try:
|
|
354
|
+
merge.pump(first_stream)
|
|
355
|
+
finally:
|
|
356
|
+
with workers.lock:
|
|
357
|
+
workers.cursors.discard(first_cursor)
|
|
358
|
+
first_cursor.close()
|
|
359
|
+
drain(conn)
|
|
360
|
+
elif not (merge.stopped or pending.empty()):
|
|
361
|
+
with connect(connection_str, autocommit=True, **connect_kwargs) as own:
|
|
362
|
+
drain(own)
|
|
363
|
+
except BaseException as exc:
|
|
364
|
+
if not merge.stopped:
|
|
365
|
+
workers.error.append(exc)
|
|
366
|
+
merge.fail(f"{type(exc).__name__}: {exc}")
|
|
367
|
+
finally:
|
|
368
|
+
try:
|
|
369
|
+
if conn is not None:
|
|
370
|
+
conn.close()
|
|
371
|
+
finally:
|
|
372
|
+
merge.worker_done()
|
|
373
|
+
|
|
374
|
+
jobs = [(first, cursor, probe_conn)] + [(None, None, None)] * (count - 1)
|
|
375
|
+
for first_stream, first_cursor, conn in jobs:
|
|
376
|
+
thread = threading.Thread(
|
|
377
|
+
target=run, args=(first_stream, first_cursor, conn), name="mssql-read-arrow", daemon=True
|
|
378
|
+
)
|
|
379
|
+
workers.threads.append(thread)
|
|
380
|
+
thread.start()
|
|
381
|
+
return ParallelArrowStream(merge, workers)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
_NULLABLE_SQL = (
|
|
385
|
+
"(SELECT TOP 1 is_nullable FROM sys.dm_exec_describe_first_result_set(?, NULL, 0))"
|
|
386
|
+
)
|
|
387
|
+
_ROWS_SQL = (
|
|
388
|
+
"(SELECT SUM(row_count) FROM sys.dm_db_partition_stats "
|
|
389
|
+
"WHERE object_id = OBJECT_ID(?) AND index_id IN (0, 1))"
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _probe_meta(cursor, query, column, plain=None, with_rows: bool = True) -> Tuple[Optional[int], bool]:
|
|
394
|
+
"""(estimated row count or None, column may hold NULL) in one round trip.
|
|
395
|
+
|
|
396
|
+
The row count comes from the catalog and only when ``query`` is a plain
|
|
397
|
+
``SELECT cols FROM table`` (a count scan is not worth it just to size the
|
|
398
|
+
plan); filters, joins, views or missing permissions give None. A failed
|
|
399
|
+
nullability check counts as nullable.
|
|
400
|
+
"""
|
|
401
|
+
describe = (f"SELECT {column} FROM ({query}) AS _q",)
|
|
402
|
+
match = _PLAIN_TABLE.match(plain if plain is not None else query) if with_rows else None
|
|
403
|
+
if match:
|
|
404
|
+
try:
|
|
405
|
+
cursor.execute(f"SELECT {_ROWS_SQL}, {_NULLABLE_SQL}", (match.group(1),) + describe)
|
|
406
|
+
rows, nullable = cursor.fetchone()
|
|
407
|
+
return (None if rows is None else int(rows)), nullable is None or bool(nullable)
|
|
408
|
+
except Exception:
|
|
409
|
+
pass
|
|
410
|
+
try:
|
|
411
|
+
cursor.execute(f"SELECT {_NULLABLE_SQL}", describe)
|
|
412
|
+
row = cursor.fetchone()
|
|
413
|
+
return None, row is None or row[0] is None or bool(row[0])
|
|
414
|
+
except Exception:
|
|
415
|
+
return None, True
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _plan(conn, query, column, name, partitions, plain=None) -> List[Tuple[str, tuple]]:
|
|
419
|
+
"""Probe [min, max]; return the slices, or [] to read in one stream."""
|
|
420
|
+
with closing(conn.cursor()) as cursor:
|
|
421
|
+
cursor.execute(f"SELECT MIN({column}), MAX({column}) FROM ({query}) AS _q")
|
|
422
|
+
low, high = cursor.fetchone()
|
|
423
|
+
if low is None:
|
|
424
|
+
return []
|
|
425
|
+
if not isinstance(low, int) or not isinstance(high, int):
|
|
426
|
+
raise TypeError(f"partition_on column {name!r} must be an integer column")
|
|
427
|
+
span = high - low + 1
|
|
428
|
+
count = partitions * _SLICES_PER_WORKER
|
|
429
|
+
rows, has_null = _probe_meta(cursor, query, column, plain)
|
|
430
|
+
if rows is not None:
|
|
431
|
+
if rows <= _MIN_PARALLEL_ROWS:
|
|
432
|
+
return []
|
|
433
|
+
count = min(count, max(rows // _MIN_SLICE_ROWS, 2))
|
|
434
|
+
# Cannot cut finer than one key value per slice. The size of the key
|
|
435
|
+
# range says nothing about the row count (many rows can share a key),
|
|
436
|
+
# so it never makes the read single-stream except for a single value.
|
|
437
|
+
count = min(count, span)
|
|
438
|
+
if count < 2:
|
|
439
|
+
return []
|
|
440
|
+
step = math.ceil(span / count)
|
|
441
|
+
edges = [low + i * step for i in range(1, count) if low + i * step <= high]
|
|
442
|
+
base = f"SELECT * FROM ({query}) AS _q WHERE "
|
|
443
|
+
work: List[Tuple[str, tuple]] = []
|
|
444
|
+
if has_null:
|
|
445
|
+
work.append((base + f"{column} IS NULL", ()))
|
|
446
|
+
# Open-ended first and last ranges so out-of-range rows cannot be lost.
|
|
447
|
+
work.append((base + f"{column} < ?", (edges[0],)))
|
|
448
|
+
for a, b in zip(edges, edges[1:]):
|
|
449
|
+
work.append((base + f"{column} >= ? AND {column} < ?", (a, b)))
|
|
450
|
+
work.append((base + f"{column} >= ?", (edges[-1],)))
|
|
451
|
+
return work
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _fetch(conn, sql, params, batch_size):
|
|
455
|
+
with closing(conn.cursor()) as cursor:
|
|
456
|
+
cursor.execute(sql, params) if params else cursor.execute(sql)
|
|
457
|
+
return cursor.fetchall_arrow(batch_size)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _read(connection_str, sql, params, batch_size, connect_kwargs, utf8_strings=False):
|
|
461
|
+
with connect(connection_str, autocommit=True, **connect_kwargs) as conn:
|
|
462
|
+
if utf8_strings and not params:
|
|
463
|
+
sql = utf8_query(conn, sql)
|
|
464
|
+
return _fetch(conn, sql, params, batch_size)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Asynchronous query support backed directly by mssql-py-core.
|
|
2
|
+
|
|
3
|
+
The internal entry points are ``_AsyncConnection`` and ``_AsyncCursor``.
|
|
4
|
+
Their methods retain DB-API names, but the classes are not stable public API.
|
|
5
|
+
|
|
6
|
+
Warning:
|
|
7
|
+
Async query execution APIs are under active development and are not intended
|
|
8
|
+
for production use. Their signatures, behavior, error handling, and compatibility
|
|
9
|
+
may change without notice.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._native import load_py_core
|
|
13
|
+
from .async_connection import _AsyncConnection # pyright: ignore[reportPrivateUsage]
|
|
14
|
+
from .async_cursor import _AsyncCursor # pyright: ignore[reportPrivateUsage]
|
|
15
|
+
from .exception_translator import (
|
|
16
|
+
DataError,
|
|
17
|
+
DatabaseError,
|
|
18
|
+
Error,
|
|
19
|
+
IntegrityError,
|
|
20
|
+
InterfaceError,
|
|
21
|
+
InternalError,
|
|
22
|
+
NotSupportedError,
|
|
23
|
+
OperationalError,
|
|
24
|
+
ProgrammingError,
|
|
25
|
+
Warning,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"DataError",
|
|
30
|
+
"DatabaseError",
|
|
31
|
+
"Error",
|
|
32
|
+
"IntegrityError",
|
|
33
|
+
"InterfaceError",
|
|
34
|
+
"InternalError",
|
|
35
|
+
"NotSupportedError",
|
|
36
|
+
"OperationalError",
|
|
37
|
+
"ProgrammingError",
|
|
38
|
+
"Warning",
|
|
39
|
+
"load_py_core",
|
|
40
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Build the internal py-core context for asynchronous connections."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..connection_string_parser import _ConnectionStringParser
|
|
6
|
+
from ..exceptions import InterfaceError, NotSupportedError
|
|
7
|
+
from ..helpers import connstr_to_pycore_params
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_async_connection_context(connection_str: str, timeout: int) -> dict[str, Any]:
|
|
11
|
+
"""Parse and validate a public connection string for py-core."""
|
|
12
|
+
if not isinstance(connection_str, str):
|
|
13
|
+
raise TypeError("connection_str must be a string")
|
|
14
|
+
if "\x00" in connection_str:
|
|
15
|
+
raise InterfaceError(
|
|
16
|
+
driver_error="Connection string must not contain a NUL (\\x00) character.",
|
|
17
|
+
ddbc_error="Embedded NUL in connection string.",
|
|
18
|
+
)
|
|
19
|
+
if isinstance(timeout, bool) or not isinstance(timeout, int):
|
|
20
|
+
raise TypeError("Login timeout must be an integer")
|
|
21
|
+
if timeout < 0:
|
|
22
|
+
raise ValueError("Login timeout cannot be negative")
|
|
23
|
+
|
|
24
|
+
parser = _ConnectionStringParser(validate_keywords=True)
|
|
25
|
+
params = parser._parse(connection_str)
|
|
26
|
+
if not any(params.get(key) for key in ("server", "addr", "address")):
|
|
27
|
+
raise ValueError("SERVER parameter is required in connection string")
|
|
28
|
+
|
|
29
|
+
authentication = params.get("authentication", "").strip()
|
|
30
|
+
if authentication and authentication.lower() != "sqlpassword":
|
|
31
|
+
raise NotSupportedError(
|
|
32
|
+
f"Authentication mode '{authentication}' is not supported for async queries",
|
|
33
|
+
"Use Authentication=SqlPassword or Trusted_Connection for async queries",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
context = connstr_to_pycore_params(params, strict=True)
|
|
37
|
+
if timeout > 0:
|
|
38
|
+
context["connect_timeout"] = timeout
|
|
39
|
+
return context
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Native mssql-py-core dependency boundary for asynchronous queries."""
|
|
2
|
+
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
from types import ModuleType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_py_core() -> ModuleType:
|
|
8
|
+
"""Load the PyO3 extension that owns asynchronous TDS operations."""
|
|
9
|
+
try:
|
|
10
|
+
# Upgrading from a bundled core can remove the package's __init__.py.
|
|
11
|
+
py_core = import_module("mssql_py_core.mssql_py_core")
|
|
12
|
+
except ImportError as exc:
|
|
13
|
+
raise ImportError(
|
|
14
|
+
"Async query support requires the mssql-python-rs dependency "
|
|
15
|
+
"that provides mssql_py_core."
|
|
16
|
+
) from exc
|
|
17
|
+
|
|
18
|
+
required_types = ("PyAsyncConnection", "PyAsyncCursor")
|
|
19
|
+
missing_types = [name for name in required_types if not hasattr(py_core, name)]
|
|
20
|
+
if missing_types:
|
|
21
|
+
missing = ", ".join(missing_types)
|
|
22
|
+
raise ImportError(f"mssql_py_core does not provide the required async types: {missing}")
|
|
23
|
+
|
|
24
|
+
return py_core
|