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.
- shape_sqlserver/__init__.py +28 -0
- shape_sqlserver/auth.py +130 -0
- shape_sqlserver/catalog.py +181 -0
- shape_sqlserver/command.py +118 -0
- shape_sqlserver/profiler.py +454 -0
- shape_sqlserver/source.py +195 -0
- shape_sqlserver/sql.py +320 -0
- shape_sqlserver/testing.py +786 -0
- sqllocks_shape_sqlserver-0.9.1.dist-info/METADATA +51 -0
- sqllocks_shape_sqlserver-0.9.1.dist-info/RECORD +14 -0
- sqllocks_shape_sqlserver-0.9.1.dist-info/WHEEL +5 -0
- sqllocks_shape_sqlserver-0.9.1.dist-info/entry_points.txt +5 -0
- sqllocks_shape_sqlserver-0.9.1.dist-info/licenses/LICENSE +21 -0
- sqllocks_shape_sqlserver-0.9.1.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|