sqllocks-shape-sqlserver 0.9.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,454 @@
1
+ """Database profiling: the catalog supplies exact keys, types and row counts; a sample of rows
2
+ supplies distributions.
3
+
4
+ The result is an ordinary Shape profile (a multi-table dataset profile), so everything that
5
+ reads profiles reads it. Statistics describe the **sample** (``sample_rows`` rows per table,
6
+ default 1000, spread over the whole table: see :func:`fetch_sample`): ``null_rate``,
7
+ ``cardinality_ratio`` and ``is_unique`` use the rows actually sampled, and the profile records
8
+ that number (``sampled_rows`` on every table, ``sampling`` on the dataset) so a reader can tell
9
+ them from whole-table figures. Where no row was sampled they are ``None`` (unknown). The table's
10
+ ``row_count`` is the catalog's.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from collections import Counter
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+
21
+ from .auth import Credentials, connect
22
+ from .catalog import (
23
+ Catalog,
24
+ ColumnInfo,
25
+ TableInfo,
26
+ guess_primary_key,
27
+ key_stem,
28
+ read_catalog,
29
+ table_stem,
30
+ )
31
+ from .sql import (
32
+ DEFAULT_SAMPLE_ROWS,
33
+ DEFAULT_SCHEMA,
34
+ NOT_HASHABLE_TYPES,
35
+ TABLES_QUERY,
36
+ SqlServerError,
37
+ fetch_dicts,
38
+ register_converters,
39
+ spread_query,
40
+ sql_type_to_dtype,
41
+ top_query,
42
+ )
43
+
44
+ log = logging.getLogger("shape_sqlserver")
45
+
46
+ # A column is an enumeration when it has at most this many distinct values in the sample, or
47
+ # when distinct values are under 5% of the rows sampled, and in either case its values repeat:
48
+ # distinct values are at most this fraction of the sample's non-null values (P1-18). A unique
49
+ # column is never an enumeration.
50
+ ENUM_MAX_CARDINALITY = 50
51
+ ENUM_MAX_RATIO = 0.05
52
+ ENUM_MAX_DISTINCT_PER_VALUE = 0.5
53
+ UNIQUE_RATIO = 0.99
54
+ # ``is_unique`` needs at least this many non-null sampled values: one value is trivially distinct.
55
+ UNIQUE_MIN_VALUES = 2
56
+
57
+ # How a table was sampled (``tables[...].sample_method`` in the profile).
58
+ SAMPLE_ALL = "all rows"
59
+ SAMPLE_SPREAD = "checksum spread"
60
+ SAMPLE_FALLBACK = "first rows (fallback)"
61
+ SAMPLE_NONE = "none"
62
+
63
+ # Where a foreign key came from (``evidence`` of a relationship, ``fk_evidence`` of a column).
64
+ DECLARED, DATA, NAME = "declared", "data", "name"
65
+
66
+
67
+ def fetch_sample(
68
+ cursor: Any,
69
+ schema: str,
70
+ table: str,
71
+ rows: int,
72
+ *,
73
+ hash_columns: list[str] | None = None,
74
+ tie_break: list[str] | None = None,
75
+ ) -> dict[str, list[Any]]:
76
+ """``rows`` rows of a table as ``{column: values}`` (``None`` for NULL).
77
+
78
+ With ``hash_columns`` the rows are spread over the whole table and the same every time for
79
+ the same data: ``SELECT TOP n * ... ORDER BY CHECKSUM(hash_columns)`` (one scan of the
80
+ table; the server keeps only ``n`` rows). Without it, the first ``rows`` rows the server
81
+ returns."""
82
+ if hash_columns:
83
+ cursor.execute(spread_query(schema, table, rows, hash_columns, tie_break))
84
+ else:
85
+ cursor.execute(top_query(schema, table, rows))
86
+ names = [str(d[0]) for d in cursor.description]
87
+ data = cursor.fetchall()
88
+ return {n: [row[i] for row in data] for i, n in enumerate(names)}
89
+
90
+
91
+ def _read_sample(
92
+ cursor: Any, catalog: Catalog, t: TableInfo, sample_rows: int
93
+ ) -> tuple[dict[str, list[Any]], str]:
94
+ if t.row_count <= sample_rows: # the whole table: no choice to make
95
+ return fetch_sample(cursor, catalog.schema, t.name, sample_rows), SAMPLE_ALL
96
+ # A declared key is unique, so it spreads the sample; without one (a heap, a Fabric
97
+ # warehouse) every column the server can hash is hashed, so duplicate keys do not cluster.
98
+ hashed = t.primary_key or [
99
+ c.name for c in t.columns if c.type_name.lower() not in NOT_HASHABLE_TYPES
100
+ ]
101
+ if hashed:
102
+ try:
103
+ sample = fetch_sample(
104
+ cursor,
105
+ catalog.schema,
106
+ t.name,
107
+ sample_rows,
108
+ hash_columns=hashed,
109
+ tie_break=t.primary_key,
110
+ )
111
+ return sample, SAMPLE_SPREAD
112
+ except Exception as exc: # e.g. a server that cannot ORDER BY CHECKSUM
113
+ log.warning(
114
+ "Could not spread the sample of %s (%s); reading its first rows", t.name, exc
115
+ )
116
+ return fetch_sample(cursor, catalog.schema, t.name, sample_rows), SAMPLE_FALLBACK
117
+
118
+
119
+ def _sample_all(
120
+ cursor: Any, catalog: Catalog, sample_rows: int
121
+ ) -> tuple[dict[str, dict[str, list[Any]] | None], dict[str, str]]:
122
+ out: dict[str, dict[str, list[Any]] | None] = {}
123
+ methods: dict[str, str] = {}
124
+ for t in catalog.tables:
125
+ out[t.name] = None
126
+ methods[t.name] = SAMPLE_NONE
127
+ if sample_rows > 0:
128
+ try:
129
+ out[t.name], methods[t.name] = _read_sample(cursor, catalog, t, sample_rows)
130
+ except Exception as exc: # one unreadable table must not stop the walk
131
+ log.warning("Could not sample %s: %s", t.name, exc)
132
+ return out, methods
133
+
134
+
135
+ def _sample_std(values: np.ndarray[Any, Any]) -> float:
136
+ """Sample standard deviation (n - 1), NaN for fewer than two values."""
137
+ n = len(values)
138
+ if n < 2:
139
+ return float("nan")
140
+ avg = values.sum(dtype=np.float64) / n
141
+ return float(np.sqrt(((avg - values) ** 2).sum(dtype=np.float64) / (n - 1)))
142
+
143
+
144
+ def _profile_column(
145
+ info: ColumnInfo,
146
+ values: list[Any] | None,
147
+ sample_size: int,
148
+ pk_cols: list[str],
149
+ fks: dict[str, str],
150
+ ) -> Any:
151
+ from shape.profile.reference.model import ColumnProfile
152
+
153
+ dtype = sql_type_to_dtype(info.type_name)
154
+ cardinality = 0
155
+ null_count = 0
156
+ min_val = max_val = mean_val = std_val = None
157
+ present: list[Any] = []
158
+ if values is not None:
159
+ present = [v for v in values if v is not None]
160
+ cardinality = len(set(present))
161
+ null_count = len(values) - len(present)
162
+ if dtype in ("integer", "float") and present:
163
+ nums = np.asarray([float(v) for v in present], dtype=np.float64)
164
+ min_val, max_val = float(nums.min()), float(nums.max())
165
+ mean_val = float(nums.sum(dtype=np.float64) / len(nums))
166
+ std_val = _sample_std(nums)
167
+ denominator = max(sample_size, 1) # the rows actually sampled, not the table's row count
168
+ ratio = cardinality / denominator
169
+ # No sampled row: the rates are unknown, not zero. Unique among the non-null sampled values,
170
+ # and only claimed from at least UNIQUE_MIN_VALUES of them.
171
+ known = values is not None and sample_size > 0
172
+ non_null = len(present)
173
+ is_unique: bool | None = None
174
+ if known and non_null >= UNIQUE_MIN_VALUES:
175
+ is_unique = cardinality / non_null > UNIQUE_RATIO
176
+ is_enum = (
177
+ cardinality > 0
178
+ and (cardinality <= ENUM_MAX_CARDINALITY or ratio < ENUM_MAX_RATIO)
179
+ and cardinality <= ENUM_MAX_DISTINCT_PER_VALUE * non_null
180
+ and not is_unique
181
+ )
182
+ enum_values: dict[str, float] | None = None
183
+ if is_enum and dtype in ("string", "boolean") and values is not None:
184
+ counts = Counter(present)
185
+ total = sum(counts.values())
186
+ ordered = sorted(counts.items(), key=lambda kv: -kv[1]) # stable: ties keep first seen
187
+ enum_values = {
188
+ (str(k).lower() if dtype == "boolean" else str(k)): v / total for k, v in ordered
189
+ }
190
+ return ColumnProfile(
191
+ name=info.name,
192
+ dtype=dtype,
193
+ null_count=null_count,
194
+ null_rate=null_count / denominator if known else None,
195
+ cardinality=cardinality,
196
+ cardinality_ratio=ratio if known else None,
197
+ is_unique=is_unique,
198
+ is_enum=is_enum,
199
+ enum_values=enum_values,
200
+ min_value=min_val,
201
+ max_value=max_val,
202
+ mean=mean_val,
203
+ std=std_val,
204
+ distribution=None,
205
+ distribution_params=None,
206
+ pattern=None,
207
+ is_primary_key=info.name in pk_cols,
208
+ is_foreign_key=info.name in fks,
209
+ fk_ref_table=fks.get(info.name),
210
+ )
211
+
212
+
213
+ def _arrow_table(sample: dict[str, list[Any]]) -> Any:
214
+ import pyarrow as pa # type: ignore[import-untyped]
215
+
216
+ arrays = []
217
+ for values in sample.values():
218
+ try:
219
+ arrays.append(pa.array(values))
220
+ except (pa.ArrowInvalid, pa.ArrowTypeError, OverflowError):
221
+ arrays.append(pa.array([None if v is None else str(v) for v in values]))
222
+ return pa.table(dict(zip(sample.keys(), arrays, strict=True)))
223
+
224
+
225
+ def _links_from_data(
226
+ samples: dict[str, dict[str, list[Any]] | None],
227
+ ) -> dict[str, dict[str, Any]]:
228
+ """Foreign keys that profiling the sampled tables together finds between them."""
229
+ from shape.api import profile as shape_profile
230
+
231
+ tables = {name: _arrow_table(s) for name, s in samples.items() if s is not None}
232
+ if not tables:
233
+ return {}
234
+ found = shape_profile(tables).to_dict()
235
+ return {
236
+ rel["name"]: {
237
+ "child_table": rel["child"],
238
+ "parent_table": rel["parent"],
239
+ "child_columns": list(rel["child_columns"]),
240
+ "parent_columns": list(rel["parent_columns"]),
241
+ }
242
+ for rel in found["relationships"]
243
+ }
244
+
245
+
246
+ def _norm(name: str) -> str:
247
+ return name.lower().replace("_", "")
248
+
249
+
250
+ def _infer_keys_from_names(
251
+ catalog: Catalog, links: dict[str, dict[str, Any]], skip: set[tuple[str, str]]
252
+ ) -> None:
253
+ """Link ``orders.customer_id`` to a table ``customer`` (or ``dimcustomer``) by name.
254
+
255
+ Only a column that ends in a whole ``id`` or ``key`` word counts (``customer_id``,
256
+ ``CustomerId``, ``customerKey``; never ``paid`` or ``valid``). Columns in ``skip`` (already
257
+ linked by a declared key or by the data) are left alone, and so is a table's own
258
+ single-column declared key. The parent's columns are filled in later (``None`` here)."""
259
+ by_norm = {_norm(t.name): t.name for t in catalog.tables}
260
+ for t in catalog.tables:
261
+ own_key = t.primary_key if len(t.primary_key) == 1 else []
262
+ for c in t.columns:
263
+ col = c.name
264
+ if (t.name, col) in skip or col in own_key:
265
+ continue
266
+ candidate = key_stem(col)
267
+ # the table's own key: named after its stem (``dimcustomer.customer_id``) or after
268
+ # the whole name (``factory.factory_id``, whose stem would be ``ory``)
269
+ if candidate is None or _norm(candidate) in (_norm(table_stem(t.name)), _norm(t.name)):
270
+ continue
271
+ parent = by_norm.get(_norm(candidate)) or by_norm.get("dim" + _norm(candidate))
272
+ if parent is not None:
273
+ links[f"fk_{t.name}_{col}"] = {
274
+ "child_table": t.name,
275
+ "parent_table": parent,
276
+ "child_columns": [col],
277
+ "parent_columns": None,
278
+ "evidence": NAME,
279
+ }
280
+
281
+
282
+ def _profile_tables(
283
+ cursor: Any, catalog: Catalog, sample_rows: int
284
+ ) -> tuple[Any, dict[str, int], dict[str, str], dict[tuple[str, str], str]]:
285
+ from shape.profile.reference.model import DatasetProfile, TableProfile
286
+
287
+ samples, methods = _sample_all(cursor, catalog, sample_rows)
288
+
289
+ # Foreign keys. Declared keys are authoritative for the columns they cover. Beside them
290
+ # (and when the schema declares none) the sampled data decides first, then names fill the
291
+ # columns the data left unlinked. Every link records where it came from.
292
+ links: dict[str, dict[str, Any]] = {
293
+ name: {
294
+ "child_table": fk.child_table,
295
+ "parent_table": fk.parent_table,
296
+ "child_columns": fk.child_columns,
297
+ "parent_columns": fk.parent_columns,
298
+ "evidence": DECLARED,
299
+ }
300
+ for name, fk in catalog.foreign_keys.items()
301
+ }
302
+ covered = {(lk["child_table"], c) for lk in links.values() for c in lk["child_columns"]}
303
+ for name, lk in _links_from_data(samples).items():
304
+ if any((lk["child_table"], c) in covered for c in lk["child_columns"]):
305
+ continue
306
+ links[name] = {**lk, "evidence": DATA}
307
+ covered.update((lk["child_table"], c) for c in lk["child_columns"])
308
+ _infer_keys_from_names(catalog, links, covered)
309
+
310
+ fk_columns: dict[str, dict[str, str]] = {}
311
+ evidence: dict[tuple[str, str], str] = {}
312
+ for lk in links.values():
313
+ for col in lk["child_columns"]:
314
+ fk_columns.setdefault(lk["child_table"], {})[col] = lk["parent_table"]
315
+ evidence[(lk["child_table"], col)] = lk["evidence"]
316
+
317
+ profiles: dict[str, Any] = {}
318
+ sampled: dict[str, int] = {}
319
+ for t in catalog.tables:
320
+ sample = samples[t.name]
321
+ size = len(next(iter(sample.values()), [])) if sample is not None else 0
322
+ sampled[t.name] = size
323
+ fks = fk_columns.get(t.name, {})
324
+ pk = t.primary_key or guess_primary_key(t.name, t.columns, sample, fks)
325
+ columns = {
326
+ c.name: _profile_column(
327
+ c,
328
+ sample.get(c.name) if sample is not None else None,
329
+ size,
330
+ pk,
331
+ fks,
332
+ )
333
+ for c in t.columns
334
+ }
335
+ profiles[t.name] = TableProfile(
336
+ name=t.name,
337
+ row_count=t.row_count,
338
+ columns=columns,
339
+ primary_key=pk,
340
+ detected_fks=dict(fks),
341
+ )
342
+ log.info("Profiled %s: %d columns, %d rows", t.name, len(columns), t.row_count)
343
+
344
+ names = {t.name for t in catalog.tables}
345
+ relationships = []
346
+ for name, lk in links.items():
347
+ if lk["child_table"] not in names:
348
+ continue
349
+ parent_columns = lk["parent_columns"]
350
+ if parent_columns is None: # name-inferred: the parent's key, else the column's name
351
+ parent_columns = profiles[lk["parent_table"]].primary_key[:1] or lk["child_columns"]
352
+ relationships.append(
353
+ {
354
+ "name": name,
355
+ "parent": lk["parent_table"],
356
+ "child": lk["child_table"],
357
+ "parent_columns": parent_columns,
358
+ "child_columns": lk["child_columns"],
359
+ "type": "one_to_many",
360
+ "evidence": lk["evidence"],
361
+ }
362
+ )
363
+ return DatasetProfile(tables=profiles, relationships=relationships), sampled, methods, evidence
364
+
365
+
366
+ def _check_found(cursor: Any, catalog: Catalog, tables: list[str] | None) -> None:
367
+ """An empty profile is never a success: a schema with no tables, or a requested table the
368
+ schema does not have, is an error that lists what the schema does have."""
369
+ found = {t.name for t in catalog.tables}
370
+ missing = [t for t in tables or () if t not in found]
371
+ if found and not missing:
372
+ return
373
+ every = sorted(r["table_name"] for r in _schema_tables(cursor, catalog.schema))
374
+ if not every:
375
+ raise SqlServerError(
376
+ f"schema {catalog.schema!r} has no tables (or the login cannot see them); "
377
+ "give the schema with --schema"
378
+ )
379
+ names = ", ".join(repr(t) for t in missing)
380
+ raise SqlServerError(
381
+ f"schema {catalog.schema!r} has no table {names}; its tables are: {', '.join(every)}"
382
+ )
383
+
384
+
385
+ def _schema_tables(cursor: Any, schema: str) -> list[dict[str, Any]]:
386
+ cursor.execute(TABLES_QUERY, (schema,))
387
+ return fetch_dicts(cursor)
388
+
389
+
390
+ def profile_database(
391
+ connection_string: str | None = None,
392
+ *,
393
+ credentials: Credentials | None = None,
394
+ connection: Any = None,
395
+ schema: str = DEFAULT_SCHEMA,
396
+ sample_rows: int = DEFAULT_SAMPLE_ROWS,
397
+ tables: list[str] | None = None,
398
+ name: str | None = None,
399
+ ) -> Any:
400
+ """Profile one schema of a SQL Server, Azure SQL or Fabric SQL database.
401
+
402
+ Pass ``connection`` (an open DB-API connection, which is reused and left open) or a
403
+ ``connection_string`` with ``credentials`` (the default is the Azure CLI's account; use
404
+ ``Credentials("sql")`` when the connection string holds a login). Returns a
405
+ ``shape.profile.reference.Profile``.
406
+ """
407
+ if isinstance(sample_rows, bool) or not isinstance(sample_rows, int) or sample_rows < 0:
408
+ raise SqlServerError(f"sample_rows must be a non-negative integer, got {sample_rows!r}")
409
+ if connection is None:
410
+ if not connection_string:
411
+ raise SqlServerError("give a connection string or an open connection")
412
+ conn = connect(connection_string, credentials)
413
+ else:
414
+ conn = connection
415
+ register_converters(conn)
416
+ cursor = conn.cursor()
417
+ try:
418
+ catalog = read_catalog(cursor, schema, tables)
419
+ _check_found(cursor, catalog, tables)
420
+ dataset, sampled, methods, evidence = _profile_tables(cursor, catalog, sample_rows)
421
+ finally:
422
+ cursor.close()
423
+ if connection is None:
424
+ conn.close()
425
+ from shape.profile.reference import Profile
426
+ from shape.profile.reference.profile import dataset_to_dict
427
+
428
+ data = dataset_to_dict(dataset)
429
+ for table_name, rows in sampled.items():
430
+ table = data["tables"][table_name]
431
+ table["sampled_rows"] = rows
432
+ table["sample_method"] = methods[table_name]
433
+ for col_name, column in table["columns"].items():
434
+ column["fk_evidence"] = evidence.get((table_name, col_name))
435
+ data["sampling"] = {
436
+ "method": (
437
+ "spread over the table: SELECT TOP n ... ORDER BY a scrambled CHECKSUM(primary key, "
438
+ "or every hashable column when there is no declared key); a table no larger than n "
439
+ "is read whole"
440
+ ),
441
+ "requested_rows": sample_rows,
442
+ "note": (
443
+ "null_count, cardinality, null_rate, cardinality_ratio, is_unique, is_enum, "
444
+ "enum_values, min_value, max_value, mean and std describe the sampled rows "
445
+ "(tables[...].sampled_rows), not the whole table; row_count is the catalog's. "
446
+ "null_rate, cardinality_ratio and is_unique are null where no row was sampled; "
447
+ "is_unique means unique among the non-null sampled values (null below 2 of them). "
448
+ "tables[...].sample_method says how each table was read."
449
+ ),
450
+ }
451
+ return Profile(data, name=name or schema)
452
+
453
+
454
+ __all__ = ["fetch_sample", "profile_database"]
@@ -0,0 +1,195 @@
1
+ """``mssql://`` source: one SQL Server table as a stream of Arrow record batches.
2
+
3
+ URI: ``mssql://SERVER[:PORT]/DATABASE?schema=dbo&table=orders``. The URI never carries a
4
+ secret. Options (all optional):
5
+
6
+ ``connection``
7
+ an open DB-API connection; reused and left open. With it the URI may be just
8
+ ``mssql:///?table=orders``.
9
+ ``connection_string``
10
+ a full ODBC connection string, instead of building one from the URI.
11
+ ``credentials`` / ``auth``
12
+ a :class:`~shape_sqlserver.auth.Credentials`, or one of its method names (default ``cli``).
13
+ ``user`` and ``password``
14
+ a SQL login, used with ``auth="sql"`` (or when given, by default).
15
+ ``batch_size``
16
+ rows per record batch (default 65536).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import datetime as dt
22
+ from collections.abc import Callable, Iterator
23
+ from typing import Any
24
+ from urllib.parse import parse_qs, unquote, urlparse
25
+
26
+ import pyarrow as pa # type: ignore[import-untyped]
27
+
28
+ from .auth import Credentials, connect
29
+ from .catalog import TableInfo, read_catalog
30
+ from .sql import (
31
+ DEFAULT_SCHEMA,
32
+ SqlServerError,
33
+ build_connection_string,
34
+ datetimeoffset_from_bytes,
35
+ qualified_name,
36
+ quote_ident,
37
+ register_converters,
38
+ sql_type_to_arrow,
39
+ )
40
+
41
+ SCHEME = "mssql"
42
+ DEFAULT_BATCH_SIZE = 65536
43
+
44
+
45
+ def parse_uri(uri: str) -> tuple[str, int | None, str | None, str, str]:
46
+ """``(server, port, database, schema, table)`` of an ``mssql://`` URI."""
47
+ parsed = urlparse(uri)
48
+ if parsed.scheme != SCHEME:
49
+ raise SqlServerError(f"{uri!r} is not an {SCHEME}:// URI")
50
+ query = parse_qs(parsed.query)
51
+ tables = query.get("table")
52
+ if not tables or not tables[0]:
53
+ raise SqlServerError(f"{uri!r} names no table; add ?table=NAME (and schema=NAME)")
54
+ schema = query.get("schema", [DEFAULT_SCHEMA])[0] or DEFAULT_SCHEMA
55
+ database = unquote(parsed.path.lstrip("/")) or None
56
+ return parsed.hostname or "", parsed.port, database, schema, tables[0]
57
+
58
+
59
+ def normalize_value(value: Any, type_name: str) -> Any:
60
+ """A driver value as Arrow expects it (``datetimeoffset`` arrives as raw bytes)."""
61
+ if value is None:
62
+ return None
63
+ if type_name == "datetimeoffset" and isinstance(value, bytes | bytearray):
64
+ return datetimeoffset_from_bytes(value)
65
+ if isinstance(value, dt.datetime) and value.tzinfo is not None:
66
+ return value.astimezone(dt.UTC)
67
+ if type_name == "uniqueidentifier":
68
+ return str(value)
69
+ if type_name in ("binary", "varbinary", "image", "rowversion", "timestamp"):
70
+ return bytes(value)
71
+ return value
72
+
73
+
74
+ class SqlServerSource:
75
+ """Reads a SQL Server table (``mssql://`` URI) as record batches."""
76
+
77
+ name = "mssql"
78
+ schemes = (SCHEME,)
79
+
80
+ def __init__(self, connection_factory: Callable[[], Any] | None = None) -> None:
81
+ # Opens the connection when neither ``connection`` nor ``connection_string`` is given
82
+ # (tests, and hosts that manage their own connections).
83
+ self._factory = connection_factory
84
+
85
+ def can_open(self, uri: str) -> bool:
86
+ parsed = urlparse(uri)
87
+ return parsed.scheme == SCHEME and bool(parse_qs(parsed.query).get("table"))
88
+
89
+ def _credentials(self, options: dict[str, Any]) -> Credentials:
90
+ given = options.get("credentials")
91
+ if isinstance(given, Credentials):
92
+ return given
93
+ auth = options.get("auth") or ("sql" if options.get("user") else "cli")
94
+ return Credentials(
95
+ method=str(auth),
96
+ tenant_id=options.get("tenant_id"),
97
+ client_id=options.get("client_id"),
98
+ client_secret=options.get("client_secret"),
99
+ )
100
+
101
+ def _open(self, uri: str, options: dict[str, Any]) -> tuple[Any, bool]:
102
+ """``(connection, owned)``."""
103
+ if options.get("connection") is not None:
104
+ register_converters(options["connection"])
105
+ return options["connection"], False
106
+ text = options.get("connection_string")
107
+ if not text and self._factory is not None:
108
+ return self._factory(), True
109
+ if not text:
110
+ server, port, database, _, _ = parse_uri(uri)
111
+ if not server:
112
+ raise SqlServerError(
113
+ "the URI names no server; use mssql://SERVER/DATABASE?table=NAME or pass "
114
+ "connection_string= or connection="
115
+ )
116
+ text = build_connection_string(
117
+ f"{server},{port}" if port else server,
118
+ database,
119
+ user=options.get("user"),
120
+ password=options.get("password"),
121
+ trust_server_certificate=bool(options.get("trust_server_certificate", False)),
122
+ )
123
+ return connect(text, self._credentials(options)), True
124
+
125
+ def schema(self, uri: str, **options: Any) -> pa.Schema:
126
+ _, _, _, schema_name, table = parse_uri(uri)
127
+ conn, owned = self._open(uri, options)
128
+ try:
129
+ cursor = conn.cursor()
130
+ try:
131
+ return self._schema(cursor, schema_name, table)
132
+ finally:
133
+ cursor.close()
134
+ finally:
135
+ if owned:
136
+ conn.close()
137
+
138
+ def _schema(self, cursor: Any, schema_name: str, table: str) -> pa.Schema:
139
+ return _arrow_schema(_table_info(cursor, schema_name, table))
140
+
141
+ def read(self, uri: str, **options: Any) -> Iterator[pa.RecordBatch]:
142
+ _, _, _, schema_name, table = parse_uri(uri)
143
+ batch_size = int(options.get("batch_size", DEFAULT_BATCH_SIZE))
144
+ if batch_size < 1:
145
+ raise SqlServerError("batch_size must be a positive integer")
146
+ conn, owned = self._open(uri, options)
147
+ try:
148
+ cursor = conn.cursor()
149
+ try:
150
+ info = _table_info(cursor, schema_name, table)
151
+ schema = _arrow_schema(info)
152
+ declared = info.primary_key # the catalog's declared key, in key order
153
+ sql = "SELECT " + ", ".join(quote_ident(c.name) for c in info.columns)
154
+ sql += " FROM " + qualified_name(schema_name, table)
155
+ if declared:
156
+ sql += " ORDER BY " + ", ".join(quote_ident(c) for c in declared)
157
+ cursor.execute(sql)
158
+ types = [c.type_name for c in info.columns]
159
+ while True:
160
+ rows = cursor.fetchmany(batch_size)
161
+ if not rows:
162
+ break
163
+ columns = [
164
+ pa.array(
165
+ [normalize_value(row[i], types[i]) for row in rows],
166
+ type=schema.field(i).type,
167
+ )
168
+ for i in range(len(types))
169
+ ]
170
+ yield pa.RecordBatch.from_arrays(columns, schema=schema)
171
+ finally:
172
+ cursor.close()
173
+ finally:
174
+ if owned:
175
+ conn.close()
176
+
177
+
178
+ def _table_info(cursor: Any, schema_name: str, table: str) -> TableInfo:
179
+ found = read_catalog(cursor, schema_name, [table]).tables
180
+ if not found:
181
+ raise SqlServerError(f"table {schema_name}.{table} not found")
182
+ return found[0]
183
+
184
+
185
+ def _arrow_schema(info: TableInfo) -> pa.Schema:
186
+ return pa.schema(
187
+ [
188
+ pa.field(
189
+ c.name,
190
+ sql_type_to_arrow(c.type_name, c.precision, c.scale),
191
+ nullable=c.is_nullable,
192
+ )
193
+ for c in info.columns
194
+ ]
195
+ )