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
shape_sqlserver/sql.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""SQL Server helpers shared by this plugin and by ``shape-fabric``.
|
|
2
|
+
|
|
3
|
+
Pure Python, standard library only. Nothing here opens a connection; :mod:`shape_sqlserver.auth`
|
|
4
|
+
does that. The helpers cover what every SQL Server consumer needs: safe identifier quoting,
|
|
5
|
+
connection strings (and their redaction for messages), the catalog queries, and the mapping
|
|
6
|
+
from SQL types to Shape column types and Arrow types.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
import re
|
|
13
|
+
import struct
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
DEFAULT_DRIVER = "ODBC Driver 18 for SQL Server"
|
|
18
|
+
DEFAULT_SCHEMA = "dbo"
|
|
19
|
+
DEFAULT_SAMPLE_ROWS = 1000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SqlServerError(RuntimeError):
|
|
23
|
+
"""A SQL Server problem the user can act on (bad input, missing driver, failed login)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# --- driver values -----------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
SQL_SS_TIMESTAMPOFFSET = -155 # ODBC type code of datetimeoffset
|
|
29
|
+
_OFFSET_STRUCT = struct.Struct("<6hI2h") # year month day hour minute second ns tz_hour tz_minute
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def datetimeoffset_from_bytes(raw: bytes | bytearray) -> dt.datetime:
|
|
33
|
+
"""The UTC instant of a ``datetimeoffset`` value as the ODBC driver delivers it (a packed
|
|
34
|
+
struct; ``pyodbc`` refuses the column unless an output converter is registered)."""
|
|
35
|
+
year, month, day, hour, minute, second, ns, tz_h, tz_m = _OFFSET_STRUCT.unpack(bytes(raw))
|
|
36
|
+
local = dt.datetime(year, month, day, hour, minute, second, ns // 1000)
|
|
37
|
+
return (local - dt.timedelta(hours=tz_h, minutes=tz_m)).replace(tzinfo=dt.UTC)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def register_converters(connection: Any) -> None:
|
|
41
|
+
"""Teach a pyodbc connection to read ``datetimeoffset`` columns. A connection without
|
|
42
|
+
``add_output_converter`` (another driver, a test double) is left alone."""
|
|
43
|
+
add = getattr(connection, "add_output_converter", None)
|
|
44
|
+
if callable(add):
|
|
45
|
+
add(SQL_SS_TIMESTAMPOFFSET, datetimeoffset_from_bytes)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --- identifiers -------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def quote_ident(name: str) -> str:
|
|
52
|
+
"""``name`` as a bracket-quoted T-SQL identifier (``]`` doubled), so it cannot break out."""
|
|
53
|
+
if not isinstance(name, str) or not name:
|
|
54
|
+
raise SqlServerError("a SQL identifier must be a non-empty string")
|
|
55
|
+
if "\x00" in name:
|
|
56
|
+
raise SqlServerError("a SQL identifier cannot contain a NUL character")
|
|
57
|
+
return "[" + name.replace("]", "]]") + "]"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def qualified_name(schema: str, table: str) -> str:
|
|
61
|
+
"""``[schema].[table]``."""
|
|
62
|
+
return f"{quote_ident(schema)}.{quote_ident(table)}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def top_query(schema: str, table: str, rows: int, columns: list[str] | None = None) -> str:
|
|
66
|
+
"""``SELECT TOP n <columns|*> FROM [schema].[table]`` (``rows`` must be a positive int)."""
|
|
67
|
+
if isinstance(rows, bool) or not isinstance(rows, int) or rows < 1:
|
|
68
|
+
raise SqlServerError(f"row limit must be a positive integer, got {rows!r}")
|
|
69
|
+
cols = "*" if columns is None else ", ".join(quote_ident(c) for c in columns)
|
|
70
|
+
# rows is a checked int; every identifier went through quote_ident
|
|
71
|
+
return f"SELECT TOP {rows} {cols} FROM {qualified_name(schema, table)}" # nosec B608
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Types CHECKSUM cannot hash (SQL Server rejects them), so they never join a row hash.
|
|
75
|
+
NOT_HASHABLE_TYPES = frozenset(
|
|
76
|
+
{"text", "ntext", "image", "xml", "geography", "geometry", "hierarchyid", "sql_variant"}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ``CHECKSUM`` of an integer is the integer itself, so ordering by it alone would sample the
|
|
81
|
+
# lowest keys. The hash is therefore scrambled: ``(CHECKSUM(key) * MULTIPLIER) % MODULUS``, with
|
|
82
|
+
# the multiplier close to MODULUS times the golden ratio, so consecutive keys land far apart
|
|
83
|
+
# and any run of keys is spread evenly over the order. The product stays inside ``bigint``
|
|
84
|
+
# (|CHECKSUM| <= 2**31).
|
|
85
|
+
SPREAD_MULTIPLIER = 1327217885
|
|
86
|
+
SPREAD_MODULUS = 2147483647
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def spread_query(
|
|
90
|
+
schema: str,
|
|
91
|
+
table: str,
|
|
92
|
+
rows: int,
|
|
93
|
+
hash_columns: list[str],
|
|
94
|
+
tie_break: list[str] | None = None,
|
|
95
|
+
) -> str:
|
|
96
|
+
"""``SELECT TOP n * ... ORDER BY (CAST(CHECKSUM(<hash_columns>) AS bigint) * m) % p``: ``rows``
|
|
97
|
+
rows spread over the whole table, the same rows every time for the same data. The scrambled
|
|
98
|
+
hash makes the order unrelated to storage order or to the key's own order; ``tie_break``
|
|
99
|
+
(the key columns) settles hash collisions. The server reads the table once and keeps only
|
|
100
|
+
``rows`` rows."""
|
|
101
|
+
if isinstance(rows, bool) or not isinstance(rows, int) or rows < 1:
|
|
102
|
+
raise SqlServerError(f"row limit must be a positive integer, got {rows!r}")
|
|
103
|
+
if not hash_columns:
|
|
104
|
+
raise SqlServerError("a spread sample needs at least one column to hash")
|
|
105
|
+
checksum = "CHECKSUM(" + ", ".join(quote_ident(c) for c in hash_columns) + ")"
|
|
106
|
+
order = f"(CAST({checksum} AS bigint) * {SPREAD_MULTIPLIER}) % {SPREAD_MODULUS}"
|
|
107
|
+
for c in tie_break or ():
|
|
108
|
+
order += ", " + quote_ident(c)
|
|
109
|
+
# rows is a checked int; every identifier went through quote_ident
|
|
110
|
+
return f"SELECT TOP {rows} * FROM {qualified_name(schema, table)} ORDER BY {order}" # nosec B608
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --- connection strings ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
_SECRET_KEYS = (
|
|
116
|
+
"pwd",
|
|
117
|
+
"password",
|
|
118
|
+
"accesstoken",
|
|
119
|
+
"access token",
|
|
120
|
+
"client_secret",
|
|
121
|
+
"clientsecret",
|
|
122
|
+
"sharedaccesskey",
|
|
123
|
+
"accountkey",
|
|
124
|
+
"sas",
|
|
125
|
+
)
|
|
126
|
+
# A value is a brace-quoted ODBC value, a quoted string (which may hold ``;``), or runs to ``;``.
|
|
127
|
+
_PAIR = re.compile(
|
|
128
|
+
r"(?P<key>[^=;{}]+)=(?P<value>\s*\{(?:[^}]|\}\})*\}|\s*\"[^\"]*\"|\s*'[^']*'|[^;]*)"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _escape_value(value: str) -> str:
|
|
133
|
+
if value == "" or any(ch in value for ch in ";{}= ") or value != value.strip():
|
|
134
|
+
return "{" + value.replace("}", "}}") + "}"
|
|
135
|
+
return value
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def build_connection_string(
|
|
139
|
+
server: str,
|
|
140
|
+
database: str | None = None,
|
|
141
|
+
*,
|
|
142
|
+
user: str | None = None,
|
|
143
|
+
password: str | None = None,
|
|
144
|
+
driver: str = DEFAULT_DRIVER,
|
|
145
|
+
encrypt: bool = True,
|
|
146
|
+
trust_server_certificate: bool = False,
|
|
147
|
+
timeout: int = 30,
|
|
148
|
+
extra: Mapping[str, str] | None = None,
|
|
149
|
+
) -> str:
|
|
150
|
+
"""An ODBC connection string.
|
|
151
|
+
|
|
152
|
+
``user`` and ``password`` are for SQL logins; for Entra authentication leave them out and
|
|
153
|
+
pass the token through :func:`shape_sqlserver.auth.connect`.
|
|
154
|
+
"""
|
|
155
|
+
if not server:
|
|
156
|
+
raise SqlServerError("a server name is required")
|
|
157
|
+
parts = {
|
|
158
|
+
"Driver": "{" + driver.replace("}", "}}") + "}",
|
|
159
|
+
"Server": _escape_value(server),
|
|
160
|
+
}
|
|
161
|
+
if database:
|
|
162
|
+
parts["Database"] = _escape_value(database)
|
|
163
|
+
if user is not None:
|
|
164
|
+
parts["UID"] = _escape_value(user)
|
|
165
|
+
if password is not None:
|
|
166
|
+
parts["PWD"] = _escape_value(password)
|
|
167
|
+
parts["Encrypt"] = "yes" if encrypt else "no"
|
|
168
|
+
parts["TrustServerCertificate"] = "yes" if trust_server_certificate else "no"
|
|
169
|
+
parts["Connection Timeout"] = str(int(timeout))
|
|
170
|
+
for key, value in (extra or {}).items():
|
|
171
|
+
if not key or any(ch in key for ch in ";={}") or key != key.strip():
|
|
172
|
+
raise SqlServerError(f"invalid connection-string key {key!r}")
|
|
173
|
+
parts[key] = _escape_value(value)
|
|
174
|
+
return ";".join(f"{k}={v}" for k, v in parts.items())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def redact_connection_string(text: str) -> str:
|
|
178
|
+
"""``text`` with the value of every password or token key replaced by ``***``.
|
|
179
|
+
|
|
180
|
+
Use it on anything that may be shown to a user or logged.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def hide(match: re.Match[str]) -> str:
|
|
184
|
+
if match.group("key").strip().lower() in _SECRET_KEYS:
|
|
185
|
+
return f"{match.group('key')}=***"
|
|
186
|
+
return match.group(0)
|
|
187
|
+
|
|
188
|
+
return _PAIR.sub(hide, text)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --- catalog queries ---------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
TABLES_QUERY = """
|
|
194
|
+
SELECT s.name AS schema_name, t.name AS table_name, t.object_id
|
|
195
|
+
FROM sys.tables t
|
|
196
|
+
JOIN sys.schemas s ON t.schema_id = s.schema_id
|
|
197
|
+
WHERE s.name = ?
|
|
198
|
+
ORDER BY t.name
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
# ``base_type_name`` is the system type under a user-defined alias type (``CREATE TYPE Amount
|
|
202
|
+
# FROM decimal(10, 2)`` gives ``decimal``), and the type itself otherwise (CLR types included).
|
|
203
|
+
COLUMNS_QUERY = """
|
|
204
|
+
SELECT c.name AS column_name, tp.name AS type_name,
|
|
205
|
+
COALESCE(bt.name, tp.name) AS base_type_name,
|
|
206
|
+
c.max_length, c.precision, c.scale, c.is_nullable, c.is_identity, c.column_id
|
|
207
|
+
FROM sys.columns c
|
|
208
|
+
JOIN sys.types tp ON c.user_type_id = tp.user_type_id
|
|
209
|
+
LEFT JOIN sys.types bt
|
|
210
|
+
ON tp.is_user_defined = 1 AND tp.is_assembly_type = 0
|
|
211
|
+
AND bt.user_type_id = tp.system_type_id
|
|
212
|
+
WHERE c.object_id = ?
|
|
213
|
+
ORDER BY c.column_id
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
PRIMARY_KEY_QUERY = """
|
|
217
|
+
SELECT col.name AS column_name
|
|
218
|
+
FROM sys.key_constraints kc
|
|
219
|
+
JOIN sys.index_columns ic
|
|
220
|
+
ON kc.parent_object_id = ic.object_id AND kc.unique_index_id = ic.index_id
|
|
221
|
+
JOIN sys.columns col ON ic.object_id = col.object_id AND ic.column_id = col.column_id
|
|
222
|
+
WHERE kc.type = 'PK' AND kc.parent_object_id = ?
|
|
223
|
+
ORDER BY ic.key_ordinal
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
FOREIGN_KEY_QUERY = """
|
|
227
|
+
SELECT fk.name AS fk_name,
|
|
228
|
+
OBJECT_NAME(fk.parent_object_id) AS child_table,
|
|
229
|
+
cp.name AS child_column,
|
|
230
|
+
OBJECT_NAME(fk.referenced_object_id) AS parent_table,
|
|
231
|
+
cr.name AS parent_column,
|
|
232
|
+
fkc.constraint_column_id AS ordinal
|
|
233
|
+
FROM sys.foreign_keys fk
|
|
234
|
+
JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id
|
|
235
|
+
JOIN sys.columns cp
|
|
236
|
+
ON fkc.parent_object_id = cp.object_id AND fkc.parent_column_id = cp.column_id
|
|
237
|
+
JOIN sys.columns cr
|
|
238
|
+
ON fkc.referenced_object_id = cr.object_id AND fkc.referenced_column_id = cr.column_id
|
|
239
|
+
JOIN sys.tables t ON fk.parent_object_id = t.object_id
|
|
240
|
+
JOIN sys.schemas s ON t.schema_id = s.schema_id
|
|
241
|
+
WHERE s.name = ?
|
|
242
|
+
ORDER BY fk.name, fkc.constraint_column_id
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
ROW_COUNT_QUERY = """
|
|
246
|
+
SELECT s.name AS schema_name, t.name AS table_name, SUM(p.rows) AS row_count
|
|
247
|
+
FROM sys.tables t
|
|
248
|
+
JOIN sys.schemas s ON t.schema_id = s.schema_id
|
|
249
|
+
JOIN sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1)
|
|
250
|
+
WHERE s.name = ?
|
|
251
|
+
GROUP BY s.name, t.name
|
|
252
|
+
ORDER BY t.name
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def fetch_dicts(cursor: Any) -> list[dict[str, Any]]:
|
|
257
|
+
"""Every remaining row of ``cursor`` as a dict keyed by lower-cased column name.
|
|
258
|
+
|
|
259
|
+
Works with any DB-API cursor (it reads ``cursor.description``), not only pyodbc rows.
|
|
260
|
+
"""
|
|
261
|
+
names = [str(d[0]).lower() for d in cursor.description or ()]
|
|
262
|
+
return [dict(zip(names, row, strict=False)) for row in cursor.fetchall()]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# --- types -------------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
_INTEGER = frozenset({"int", "bigint", "smallint", "tinyint"})
|
|
268
|
+
_FLOAT = frozenset({"float", "real", "decimal", "numeric", "money", "smallmoney"})
|
|
269
|
+
_DATETIME = frozenset({"datetime", "datetime2", "smalldatetime", "datetimeoffset"})
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def sql_type_to_dtype(type_name: str) -> str:
|
|
273
|
+
"""The Shape column type (``integer``, ``float``, ``date``, ``datetime``, ``boolean`` or
|
|
274
|
+
``string``) of a SQL Server type name."""
|
|
275
|
+
t = type_name.lower()
|
|
276
|
+
if t in _INTEGER:
|
|
277
|
+
return "integer"
|
|
278
|
+
if t in _FLOAT:
|
|
279
|
+
return "float"
|
|
280
|
+
if t == "date":
|
|
281
|
+
return "date"
|
|
282
|
+
if t in _DATETIME:
|
|
283
|
+
return "datetime"
|
|
284
|
+
if t == "bit":
|
|
285
|
+
return "boolean"
|
|
286
|
+
return "string"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def sql_type_to_arrow(type_name: str, precision: int = 0, scale: int = 0) -> Any:
|
|
290
|
+
"""The Arrow type a SQL Server type is read as (``pyarrow`` is imported on first use)."""
|
|
291
|
+
import pyarrow as pa # type: ignore[import-untyped]
|
|
292
|
+
|
|
293
|
+
t = type_name.lower()
|
|
294
|
+
fixed: dict[str, Any] = {
|
|
295
|
+
"bigint": pa.int64(),
|
|
296
|
+
"int": pa.int32(),
|
|
297
|
+
"smallint": pa.int16(),
|
|
298
|
+
"tinyint": pa.uint8(),
|
|
299
|
+
"bit": pa.bool_(),
|
|
300
|
+
"real": pa.float32(),
|
|
301
|
+
"float": pa.float64(),
|
|
302
|
+
"money": pa.decimal128(19, 4),
|
|
303
|
+
"smallmoney": pa.decimal128(10, 4),
|
|
304
|
+
"date": pa.date32(),
|
|
305
|
+
"datetime": pa.timestamp("us"),
|
|
306
|
+
"datetime2": pa.timestamp("us"),
|
|
307
|
+
"smalldatetime": pa.timestamp("us"),
|
|
308
|
+
"datetimeoffset": pa.timestamp("us", tz="UTC"),
|
|
309
|
+
"time": pa.time64("us"),
|
|
310
|
+
"binary": pa.binary(),
|
|
311
|
+
"varbinary": pa.binary(),
|
|
312
|
+
"image": pa.binary(),
|
|
313
|
+
"rowversion": pa.binary(),
|
|
314
|
+
"timestamp": pa.binary(),
|
|
315
|
+
}
|
|
316
|
+
if t in fixed:
|
|
317
|
+
return fixed[t]
|
|
318
|
+
if t in ("decimal", "numeric"):
|
|
319
|
+
return pa.decimal128(max(int(precision), 1), int(scale))
|
|
320
|
+
return pa.string()
|