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.
Files changed (45) hide show
  1. arrowsql/__init__.py +653 -0
  2. arrowsql/_rust_auth.py +170 -0
  3. arrowsql/_server_sqlstate.py +11 -0
  4. arrowsql/_utf8_strings.py +78 -0
  5. arrowsql/agent.py +620 -0
  6. arrowsql/aio.py +533 -0
  7. arrowsql/arrow_parallel.py +464 -0
  8. arrowsql/async_query/__init__.py +40 -0
  9. arrowsql/async_query/_connection_context.py +39 -0
  10. arrowsql/async_query/_native.py +24 -0
  11. arrowsql/async_query/async_connection.py +160 -0
  12. arrowsql/async_query/async_cursor.py +225 -0
  13. arrowsql/async_query/async_execute.py +113 -0
  14. arrowsql/async_query/async_fetch.py +153 -0
  15. arrowsql/async_query/exception_translator.py +132 -0
  16. arrowsql/auth.py +913 -0
  17. arrowsql/bulk_parallel.py +385 -0
  18. arrowsql/connection.py +2377 -0
  19. arrowsql/connection_string_builder.py +114 -0
  20. arrowsql/connection_string_parser.py +417 -0
  21. arrowsql/constants.py +701 -0
  22. arrowsql/cursor.py +4516 -0
  23. arrowsql/db_connection.py +98 -0
  24. arrowsql/ddbc_bindings.cp313-win_amd64.pyd +0 -0
  25. arrowsql/ddbc_bindings.pyi +283 -0
  26. arrowsql/decimal_config.py +98 -0
  27. arrowsql/exceptions.py +378 -0
  28. arrowsql/helpers.py +436 -0
  29. arrowsql/logging.py +700 -0
  30. arrowsql/mssql_python.pyi +579 -0
  31. arrowsql/odbc_provider.py +269 -0
  32. arrowsql/parameter_helper.py +442 -0
  33. arrowsql/perf_timer.py +286 -0
  34. arrowsql/pooling.py +147 -0
  35. arrowsql/py.typed +1 -0
  36. arrowsql/row.py +462 -0
  37. arrowsql/type.py +160 -0
  38. arrowsql-0.1.0.dist-info/METADATA +146 -0
  39. arrowsql-0.1.0.dist-info/RECORD +45 -0
  40. arrowsql-0.1.0.dist-info/WHEEL +4 -0
  41. arrowsql-0.1.0.dist-info/entry_points.txt +2 -0
  42. arrowsql-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. arrowsql-0.1.0.dist-info/licenses/LICENSE.upstream-mssql-python +47 -0
  44. arrowsql-0.1.0.dist-info/sboms/ddbc_bindings.cyclonedx.json +5195 -0
  45. mssql_python/__init__.py +39 -0
@@ -0,0 +1,385 @@
1
+ """RUST-PORT: parallel bulk load.
2
+
3
+ One bulk-copy stream is limited by the client-side encoder, not by the server.
4
+ ``write_arrow`` (Arrow sources) and ``write_rows`` (Python row tuples) cut the
5
+ source into slices, hand them to N connections, and run one bulk copy per
6
+ connection.
7
+
8
+ The target decides the policy (``_TARGET_QUERY`` detects it):
9
+
10
+ * heap: TABLOCK on every worker. Bulk-update locks are compatible with each
11
+ other, so the streams load the same heap at once.
12
+ * clustered columnstore: parallel, without TABLOCK (a table lock serializes
13
+ the loads; concurrent loads without it are the documented way to scale a
14
+ columnstore load) and with batches of at least 102,400 rows so full
15
+ rowgroups compress directly instead of landing in the delta store. Each
16
+ worker's last partial batch ends up in an open delta rowgroup that the tuple
17
+ mover compresses later.
18
+ * heap with nonclustered indexes, or a clustered rowstore index: one worker
19
+ and a ``RuntimeWarning`` (measured: TABLOCK workers into a heap with a
20
+ nonclustered index were slower than one).
21
+
22
+ Row order is not kept. Rows committed by a worker before another worker fails
23
+ stay in the table; the first error is raised.
24
+ """
25
+
26
+ import itertools
27
+ import queue
28
+ import threading
29
+ import time
30
+ import warnings
31
+ from contextlib import closing
32
+ from concurrent.futures import ThreadPoolExecutor, wait
33
+ from typing import Any, Optional
34
+
35
+ from arrowsql.db_connection import connect
36
+
37
+ # Cap on rows per slice. Smaller slices spread work across workers; larger
38
+ # ones amortize per-batch overhead.
39
+ _MAX_SLICE_ROWS = 65_536
40
+ _SLICES_PER_WORKER = 4
41
+ _POLL_SECONDS = 0.1
42
+ _END = object()
43
+
44
+ # One round trip: the target kind and the database recovery model. Index types:
45
+ # 1 clustered, 2 nonclustered, 5 clustered columnstore, 6 nonclustered columnstore.
46
+ _TARGET_QUERY = """
47
+ SELECT
48
+ CASE WHEN OBJECT_ID(?) IS NULL THEN NULL
49
+ WHEN EXISTS (SELECT 1 FROM sys.indexes
50
+ WHERE object_id = OBJECT_ID(?) AND type = 5) THEN 'columnstore'
51
+ WHEN EXISTS (SELECT 1 FROM sys.indexes
52
+ WHERE object_id = OBJECT_ID(?) AND type = 1) THEN 'clustered'
53
+ WHEN EXISTS (SELECT 1 FROM sys.indexes
54
+ WHERE object_id = OBJECT_ID(?) AND type IN (2, 6)) THEN 'heap_nci'
55
+ ELSE 'heap' END,
56
+ (SELECT recovery_model_desc FROM sys.databases WHERE database_id = DB_ID())
57
+ """
58
+
59
+ # Fewest rows per batch for a columnstore load: smaller batches land in the delta store.
60
+ _COLUMNSTORE_MIN_BATCH = 102_400
61
+
62
+ _SINGLE_WORKER_REASON = {
63
+ "clustered": "it has a clustered index",
64
+ "heap_nci": "it is a heap with nonclustered indexes, where parallel TABLOCK loads are slower",
65
+ }
66
+
67
+
68
+ def write_arrow(
69
+ connection_str: str,
70
+ table: str,
71
+ source: Any,
72
+ workers: int = 4,
73
+ batch_size: int = 0,
74
+ table_lock: bool = True,
75
+ timeout: int = 30,
76
+ column_mappings: Optional[list] = None,
77
+ keep_identity: bool = False,
78
+ check_constraints: bool = False,
79
+ keep_nulls: bool = False,
80
+ fire_triggers: bool = False,
81
+ **connect_kwargs: Any,
82
+ ) -> dict:
83
+ """Bulk-load an Arrow source into ``table`` over ``workers`` connections.
84
+
85
+ ``source`` takes the same forms as :meth:`Cursor.bulkcopy_arrow`. With
86
+ ``workers > 1`` the source is cut into slices that workers pull from a
87
+ shared queue, so faster connections take more of the load. With
88
+ ``workers > 1`` the target decides the lock: TABLOCK for a heap, none for a
89
+ clustered columnstore (batches are raised to at least 102,400 rows), and
90
+ ``workers`` falls back to 1 with a warning for other indexed targets
91
+ (``table_lock`` then applies as given).
92
+
93
+ Returns a dict with ``rows_copied``, ``batch_count``, ``elapsed_time``,
94
+ ``rows_per_second`` and ``workers`` (connections used).
95
+
96
+ Raises the first error from any worker or from reading the source. The
97
+ other workers stop taking new slices. ``workers > 1`` needs pyarrow to cut
98
+ the source.
99
+ """
100
+ _check_args(workers, batch_size, timeout)
101
+ bulk_options = {
102
+ "batch_size": batch_size,
103
+ "timeout": timeout,
104
+ "column_mappings": column_mappings,
105
+ "keep_identity": keep_identity,
106
+ "check_constraints": check_constraints,
107
+ "keep_nulls": keep_nulls,
108
+ "fire_triggers": fire_triggers,
109
+ }
110
+ started = time.perf_counter()
111
+
112
+ if workers > 1:
113
+ workers, table_lock, bulk_options = _apply_target_policy(
114
+ "write_arrow", connection_str, table, workers, table_lock, bulk_options, connect_kwargs
115
+ )
116
+ if workers == 1:
117
+ result = _load_single(connection_str, table, source, table_lock, bulk_options, connect_kwargs)
118
+ return _summarize([result], 1, started)
119
+
120
+ slice_rows = _slice_rows(source, workers)
121
+ results = _load_parallel(
122
+ connection_str,
123
+ workers,
124
+ _slices(source, slice_rows),
125
+ lambda cursor, first, pieces: _load_arrow_pieces(
126
+ cursor, table, first, pieces, table_lock, bulk_options
127
+ ),
128
+ connect_kwargs,
129
+ )
130
+ return _summarize(results, workers, started)
131
+
132
+
133
+ def write_rows(
134
+ connection_str: str,
135
+ table: str,
136
+ rows: Any,
137
+ workers: int = 4,
138
+ batch_size: int = 0,
139
+ timeout: int = 30,
140
+ column_mappings: Optional[list] = None,
141
+ keep_identity: bool = False,
142
+ check_constraints: bool = False,
143
+ keep_nulls: bool = False,
144
+ fire_triggers: bool = False,
145
+ **connect_kwargs: Any,
146
+ ) -> dict:
147
+ """Bulk-load row tuples into ``table`` over ``workers`` connections.
148
+
149
+ ``rows`` is an iterable of tuples (the :meth:`Cursor.bulkcopy` input). A
150
+ list or tuple is cut with plain slicing; any other iterable is read by a
151
+ feeder thread in chunks. Workers pull chunks from a bounded queue and each
152
+ runs one ``cursor.bulkcopy`` with TABLOCK.
153
+
154
+ Converting Python values holds the GIL, so the speed-up is bounded by the
155
+ share of a bulk copy spent on the network and the server. Row order is not
156
+ kept. If a worker fails, the others stop taking new rows and the first
157
+ error is raised; rows already committed stay in the table.
158
+
159
+ Returns a dict with ``rows_copied``, ``batch_count``, ``elapsed_time``,
160
+ ``rows_per_second`` and ``workers`` (connections used).
161
+ """
162
+ _check_args(workers, batch_size, timeout)
163
+ bulk_options = {
164
+ "batch_size": batch_size,
165
+ "timeout": timeout,
166
+ "column_mappings": column_mappings,
167
+ "keep_identity": keep_identity,
168
+ "check_constraints": check_constraints,
169
+ "keep_nulls": keep_nulls,
170
+ "fire_triggers": fire_triggers,
171
+ }
172
+ started = time.perf_counter()
173
+
174
+ table_lock = True
175
+ if workers > 1:
176
+ workers, table_lock, bulk_options = _apply_target_policy(
177
+ "write_rows", connection_str, table, workers, True, bulk_options, connect_kwargs
178
+ )
179
+ if workers == 1:
180
+ with connect(connection_str, autocommit=True, **connect_kwargs) as conn:
181
+ with closing(conn.cursor()) as cursor:
182
+ result = cursor.bulkcopy(table, rows, table_lock=table_lock, **bulk_options)
183
+ return _summarize([result], 1, started)
184
+
185
+ results = _load_parallel(
186
+ connection_str,
187
+ workers,
188
+ _row_slices(rows, workers),
189
+ lambda cursor, first, pieces: _load_row_pieces(
190
+ cursor, table, first, pieces, table_lock, bulk_options
191
+ ),
192
+ connect_kwargs,
193
+ )
194
+ return _summarize(results, workers, started)
195
+
196
+
197
+ def _check_args(workers, batch_size, timeout):
198
+ if not isinstance(workers, int) or isinstance(workers, bool) or workers < 1:
199
+ raise ValueError(f"workers must be a positive integer, got {workers!r}")
200
+ if not isinstance(batch_size, int) or isinstance(batch_size, bool) or batch_size < 0:
201
+ raise ValueError(f"batch_size must be a non-negative integer, got {batch_size!r}")
202
+ if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout < 0:
203
+ raise ValueError(f"timeout must be a non-negative integer, got {timeout!r}")
204
+
205
+
206
+ def _apply_target_policy(caller, connection_str, table, workers, table_lock, bulk_options, connect_kwargs):
207
+ """Return ``(workers, table_lock, bulk_options)`` for the target's kind."""
208
+ with connect(connection_str, autocommit=True, **connect_kwargs) as conn:
209
+ with closing(conn.cursor()) as cursor:
210
+ cursor.execute(_TARGET_QUERY, (table, table, table, table))
211
+ kind, recovery_model = cursor.fetchone()
212
+ if recovery_model == "FULL":
213
+ warnings.warn(
214
+ f"{caller}: the database uses the FULL recovery model, so bulk loads are fully logged; "
215
+ "BULK_LOGGED or SIMPLE is much faster for large loads",
216
+ RuntimeWarning,
217
+ stacklevel=3,
218
+ )
219
+ if kind in _SINGLE_WORKER_REASON:
220
+ warnings.warn(
221
+ f"{caller}: loading {table!r} with 1 worker because {_SINGLE_WORKER_REASON[kind]}",
222
+ RuntimeWarning,
223
+ stacklevel=3,
224
+ )
225
+ return 1, table_lock, bulk_options
226
+ if kind == "columnstore":
227
+ if bulk_options["batch_size"] < _COLUMNSTORE_MIN_BATCH:
228
+ bulk_options = {**bulk_options, "batch_size": _COLUMNSTORE_MIN_BATCH}
229
+ return workers, False, bulk_options
230
+ return workers, True, bulk_options
231
+
232
+
233
+ def _slice_size(count, workers):
234
+ """Slice size that gives each worker several slices to pull."""
235
+ return max(1, min(_MAX_SLICE_ROWS, -(-count // (workers * _SLICES_PER_WORKER))))
236
+
237
+
238
+ def _slice_rows(source, workers):
239
+ num_rows = getattr(source, "num_rows", None)
240
+ if isinstance(num_rows, int) and num_rows > 0:
241
+ return _slice_size(num_rows, workers)
242
+ return _MAX_SLICE_ROWS
243
+
244
+
245
+ def _load_single(connection_str, table, source, table_lock, bulk_options, connect_kwargs):
246
+ with connect(connection_str, autocommit=True, **connect_kwargs) as conn:
247
+ with closing(conn.cursor()) as cursor:
248
+ return cursor.bulkcopy_arrow(table, source, table_lock=table_lock, **bulk_options)
249
+
250
+
251
+ def _load_parallel(connection_str, workers, pieces, load, connect_kwargs):
252
+ """Feed ``pieces`` to ``workers`` connections. ``load(cursor, first, pieces)``
253
+ runs one bulk copy over the pieces a worker pulls and returns its result."""
254
+ stop = threading.Event()
255
+ failures: list = []
256
+ pending = queue.Queue(maxsize=workers * 4)
257
+
258
+ def worker():
259
+ try:
260
+ return _load_worker(connection_str, pending, stop, load, connect_kwargs)
261
+ except BaseException as exc:
262
+ failures.append(exc)
263
+ stop.set()
264
+ raise
265
+
266
+ def feed():
267
+ try:
268
+ for piece in pieces:
269
+ if not _put(pending, piece, stop):
270
+ return
271
+ except BaseException as exc:
272
+ failures.append(exc)
273
+ stop.set()
274
+ finally:
275
+ for _ in range(workers):
276
+ _put(pending, _END, stop)
277
+
278
+ with ThreadPoolExecutor(max_workers=workers + 1, thread_name_prefix="mssql-bulk") as pool:
279
+ futures = [pool.submit(worker) for _ in range(workers)]
280
+ wait([pool.submit(feed), *futures])
281
+ if failures:
282
+ raise failures[0]
283
+ results = [future.result() for future in futures]
284
+ return [r for r in results if r]
285
+
286
+
287
+ def _load_worker(connection_str, pending, stop, load, connect_kwargs):
288
+ with connect(connection_str, autocommit=True, **connect_kwargs) as conn:
289
+ with closing(conn.cursor()) as cursor:
290
+ first = _next(pending, stop)
291
+ if first is _END:
292
+ return None
293
+
294
+ def pieces():
295
+ piece = first
296
+ while piece is not _END:
297
+ yield piece
298
+ piece = _next(pending, stop)
299
+
300
+ return load(cursor, first, pieces())
301
+
302
+
303
+ def _load_arrow_pieces(cursor, table, first, pieces, table_lock, bulk_options):
304
+ import pyarrow as pa
305
+
306
+ # A RecordBatchReader is an Arrow C stream, which the native bulk
307
+ # copy consumes directly; a bare generator would need mssql_py_core.
308
+ reader = pa.RecordBatchReader.from_batches(first.schema, pieces)
309
+ return cursor.bulkcopy_arrow(table, reader, table_lock=table_lock, **bulk_options)
310
+
311
+
312
+ def _load_row_pieces(cursor, table, first, pieces, table_lock, bulk_options):
313
+ rows = itertools.chain.from_iterable(pieces)
314
+ return cursor.bulkcopy(table, rows, table_lock=table_lock, **bulk_options)
315
+
316
+
317
+ def _row_slices(rows, workers):
318
+ """Yield lists of rows: plain slices of a list or tuple, else islice chunks."""
319
+ if isinstance(rows, (list, tuple)):
320
+ count = len(rows)
321
+ size = _slice_size(count, workers)
322
+ for offset in range(0, count, size):
323
+ yield rows[offset : offset + size]
324
+ return
325
+ iterator = iter(rows)
326
+ while True:
327
+ chunk = list(itertools.islice(iterator, _MAX_SLICE_ROWS))
328
+ if not chunk:
329
+ return
330
+ yield chunk
331
+
332
+
333
+ def _slices(source, slice_rows):
334
+ """Yield non-empty RecordBatch slices of at most ``slice_rows`` rows."""
335
+ for batch in _record_batches(source):
336
+ for offset in range(0, batch.num_rows, slice_rows):
337
+ yield batch.slice(offset, slice_rows)
338
+
339
+
340
+ def _record_batches(source):
341
+ import pyarrow as pa
342
+
343
+ if isinstance(source, pa.Table):
344
+ return source.to_batches()
345
+ if isinstance(source, pa.RecordBatch):
346
+ return [source]
347
+ if isinstance(source, pa.RecordBatchReader) or hasattr(source, "__arrow_c_stream__"):
348
+ return pa.RecordBatchReader.from_stream(source)
349
+ if hasattr(source, "__arrow_c_array__"):
350
+ return [pa.record_batch(source)]
351
+ return source
352
+
353
+
354
+ def _put(pending, item, stop):
355
+ """Put ``item`` unless the load was stopped. Returns False if stopped."""
356
+ while not stop.is_set():
357
+ try:
358
+ pending.put(item, timeout=_POLL_SECONDS)
359
+ return True
360
+ except queue.Full:
361
+ continue
362
+ return False
363
+
364
+
365
+ def _next(pending, stop):
366
+ """Next slice, or ``_END`` when the feed is done or the load was stopped."""
367
+ while not stop.is_set():
368
+ try:
369
+ return pending.get(timeout=_POLL_SECONDS)
370
+ except queue.Empty:
371
+ continue
372
+ return _END
373
+
374
+
375
+ def _summarize(results, workers, started):
376
+ rows = sum(r.get("rows_copied", 0) for r in results)
377
+ batches = sum(r.get("batch_count", 0) for r in results)
378
+ elapsed = time.perf_counter() - started
379
+ return {
380
+ "rows_copied": rows,
381
+ "batch_count": batches,
382
+ "elapsed_time": elapsed,
383
+ "rows_per_second": rows / elapsed if elapsed > 0 else 0.0,
384
+ "workers": workers,
385
+ }