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/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()