table-validator 0.1.0__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.
- table_validator/__init__.py +46 -0
- table_validator/auth/__init__.py +1 -0
- table_validator/auth/azure_auth.py +52 -0
- table_validator/auth/databricks_auth.py +31 -0
- table_validator/cli/__init__.py +1 -0
- table_validator/cli/main.py +722 -0
- table_validator/cli/partition_prompt.py +78 -0
- table_validator/cli/summary_table.py +146 -0
- table_validator/cli/wizard.py +429 -0
- table_validator/config/__init__.py +1 -0
- table_validator/config/manager.py +84 -0
- table_validator/config/schema.py +179 -0
- table_validator/connectors/__init__.py +1 -0
- table_validator/connectors/azure_connector.py +809 -0
- table_validator/connectors/databricks_connector.py +1230 -0
- table_validator/engine/__init__.py +1 -0
- table_validator/engine/comparison_engine.py +645 -0
- table_validator/models.py +952 -0
- table_validator/reports/__init__.py +1 -0
- table_validator/reports/excel_report.py +953 -0
- table_validator/validators/__init__.py +1 -0
- table_validator/validators/blob_discovery.py +467 -0
- table_validator/validators/catalog_validator.py +1863 -0
- table_validator/validators/row_validator.py +1727 -0
- table_validator-0.1.0.dist-info/METADATA +190 -0
- table_validator-0.1.0.dist-info/RECORD +30 -0
- table_validator-0.1.0.dist-info/WHEEL +5 -0
- table_validator-0.1.0.dist-info/entry_points.txt +2 -0
- table_validator-0.1.0.dist-info/licenses/LICENSE +21 -0
- table_validator-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Azure Connectors: Blob Storage and Azure SQL Database.
|
|
3
|
+
|
|
4
|
+
AzureConnector reads data files (CSV/Excel/Parquet) from Azure Storage Blob
|
|
5
|
+
Containers into Pandas DataFrames. Format is auto-detected from the blob
|
|
6
|
+
path's extension, since a blob's file format has nothing to do with the
|
|
7
|
+
target Databricks table's own storage format (always queried via SQL
|
|
8
|
+
regardless of what format Databricks stores it in internally).
|
|
9
|
+
|
|
10
|
+
AzureSqlConnector establishes connectivity to an Azure SQL Database (via
|
|
11
|
+
pyodbc / ODBC Driver 17 for SQL Server) and retrieves data / schema
|
|
12
|
+
information.
|
|
13
|
+
|
|
14
|
+
Neither class contains comparison logic - each answers factual questions
|
|
15
|
+
only ("what tables exist", "what are the null counts for these columns")
|
|
16
|
+
and never decides PASS/FAIL. That decision lives in the validators.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import fnmatch
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
from io import BytesIO, StringIO
|
|
25
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
26
|
+
|
|
27
|
+
import pandas as pd
|
|
28
|
+
import pyodbc
|
|
29
|
+
from azure.storage.blob import BlobServiceClient
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AzureConnector:
|
|
35
|
+
"""
|
|
36
|
+
Azure Storage connector for reading CSV/Excel/Parquet files from
|
|
37
|
+
Blob Storage.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
account_name: str,
|
|
43
|
+
account_key: str,
|
|
44
|
+
container_name: str,
|
|
45
|
+
) -> None:
|
|
46
|
+
|
|
47
|
+
self.account_name = account_name
|
|
48
|
+
self.account_key = account_key
|
|
49
|
+
self.container_name = container_name
|
|
50
|
+
|
|
51
|
+
self.blob_service_client: Optional[BlobServiceClient] = None
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
# Connection Management
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
def connect(self) -> None:
|
|
57
|
+
|
|
58
|
+
if self.blob_service_client is not None:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
account_url = (
|
|
63
|
+
f"https://{self.account_name}.blob.core.windows.net"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
self.blob_service_client = BlobServiceClient(
|
|
67
|
+
account_url=account_url,
|
|
68
|
+
credential=self.account_key,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
logger.info(
|
|
72
|
+
"Successfully connected to Azure Storage Account: %s",
|
|
73
|
+
self.account_name,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
logger.exception(
|
|
78
|
+
"Failed to connect to Azure Storage"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
raise ConnectionError(
|
|
82
|
+
f"Unable to connect to Azure Storage: {exc}"
|
|
83
|
+
) from exc
|
|
84
|
+
|
|
85
|
+
def disconnect(self) -> None:
|
|
86
|
+
|
|
87
|
+
self.blob_service_client = None
|
|
88
|
+
|
|
89
|
+
logger.info("Azure Storage connection released")
|
|
90
|
+
|
|
91
|
+
def test_connection(self) -> bool:
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
self.connect()
|
|
95
|
+
|
|
96
|
+
container_client = (
|
|
97
|
+
self.blob_service_client.get_container_client(
|
|
98
|
+
self.container_name
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
container_client.get_container_properties()
|
|
103
|
+
|
|
104
|
+
logger.info(
|
|
105
|
+
"Azure Storage connection test successful"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
|
|
112
|
+
logger.error(
|
|
113
|
+
"Azure Storage connection test failed: %s",
|
|
114
|
+
exc,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Data Access
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
def read_csv(
|
|
123
|
+
self,
|
|
124
|
+
blob_path: str,
|
|
125
|
+
) -> pd.DataFrame:
|
|
126
|
+
"""
|
|
127
|
+
Read a data file from Azure Storage and return a DataFrame.
|
|
128
|
+
|
|
129
|
+
Despite the name (kept for backward compatibility - existing
|
|
130
|
+
callers all say "read_csv"), the format is auto-detected from
|
|
131
|
+
blob_path's extension: .csv/.txt -> CSV, .xlsx/.xls -> Excel,
|
|
132
|
+
.parquet -> Parquet. The source file's format is independent of
|
|
133
|
+
the target Databricks table's own storage format, which is
|
|
134
|
+
always queried via SQL regardless.
|
|
135
|
+
|
|
136
|
+
Example blob_path:
|
|
137
|
+
n8ndirectory/day.csv
|
|
138
|
+
n8ndirectory/day.xlsx
|
|
139
|
+
n8ndirectory/day.parquet
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
self.connect()
|
|
143
|
+
|
|
144
|
+
blob_client = self.blob_service_client.get_blob_client(
|
|
145
|
+
container=self.container_name,
|
|
146
|
+
blob=blob_path,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
raw_bytes = blob_client.download_blob().readall()
|
|
150
|
+
|
|
151
|
+
lower_path = blob_path.lower()
|
|
152
|
+
|
|
153
|
+
if lower_path.endswith((".xlsx", ".xls")):
|
|
154
|
+
df = pd.read_excel(BytesIO(raw_bytes))
|
|
155
|
+
elif lower_path.endswith(".parquet"):
|
|
156
|
+
df = pd.read_parquet(BytesIO(raw_bytes))
|
|
157
|
+
elif lower_path.endswith((".csv", ".txt")):
|
|
158
|
+
df = pd.read_csv(StringIO(raw_bytes.decode("utf-8")))
|
|
159
|
+
else:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"Unsupported file type for blob '{blob_path}'. "
|
|
162
|
+
"Supported extensions: .csv, .txt, .xlsx, .xls, .parquet"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
logger.info(
|
|
166
|
+
"File loaded successfully | file=%s | shape=%s",
|
|
167
|
+
blob_path,
|
|
168
|
+
df.shape,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return df
|
|
172
|
+
|
|
173
|
+
def get_schema(
|
|
174
|
+
self,
|
|
175
|
+
blob_path: str,
|
|
176
|
+
) -> pd.DataFrame:
|
|
177
|
+
"""
|
|
178
|
+
Return schema information for a source file (any supported format).
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
column_name
|
|
182
|
+
data_type
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
df = self.read_csv(blob_path)
|
|
186
|
+
|
|
187
|
+
schema_df = pd.DataFrame(
|
|
188
|
+
{
|
|
189
|
+
"column_name": df.columns,
|
|
190
|
+
"data_type": [
|
|
191
|
+
str(dtype)
|
|
192
|
+
for dtype in df.dtypes
|
|
193
|
+
],
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return schema_df
|
|
198
|
+
|
|
199
|
+
# Supported source-data extensions, same set read_csv() dispatches on.
|
|
200
|
+
_SUPPORTED_EXTENSIONS = (".csv", ".txt", ".xlsx", ".xls", ".parquet")
|
|
201
|
+
|
|
202
|
+
def list_blobs(
|
|
203
|
+
self,
|
|
204
|
+
folder_prefix: Optional[str] = None,
|
|
205
|
+
file_pattern: Optional[str] = None,
|
|
206
|
+
) -> List[str]:
|
|
207
|
+
"""
|
|
208
|
+
List blob paths in this connector's container, optionally scoped
|
|
209
|
+
by a path prefix and/or a glob-style file_pattern (e.g. '*.csv').
|
|
210
|
+
|
|
211
|
+
Only blobs with a supported data extension are returned (matching
|
|
212
|
+
read_csv()'s dispatch table) - anything else in the container
|
|
213
|
+
(README files, folder markers, unrelated data) is silently
|
|
214
|
+
excluded rather than surfaced as a comparison candidate.
|
|
215
|
+
|
|
216
|
+
folder_prefix is passed straight through as the SDK's own prefix
|
|
217
|
+
filter (server-side, not a client-side scan); file_pattern is
|
|
218
|
+
applied client-side via fnmatch against the blob's base name.
|
|
219
|
+
"""
|
|
220
|
+
self.connect()
|
|
221
|
+
|
|
222
|
+
container_client = self.blob_service_client.get_container_client(
|
|
223
|
+
self.container_name
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
blobs = container_client.list_blobs(name_starts_with=folder_prefix or None)
|
|
227
|
+
|
|
228
|
+
matches: List[str] = []
|
|
229
|
+
for blob in blobs:
|
|
230
|
+
name = blob.name
|
|
231
|
+
if not name.lower().endswith(self._SUPPORTED_EXTENSIONS):
|
|
232
|
+
continue
|
|
233
|
+
base_name = name.rsplit("/", 1)[-1]
|
|
234
|
+
if file_pattern and not fnmatch.fnmatch(base_name, file_pattern):
|
|
235
|
+
continue
|
|
236
|
+
matches.append(name)
|
|
237
|
+
|
|
238
|
+
logger.info(
|
|
239
|
+
"Listed %d matching blob(s) | container=%s | folder_prefix=%s | file_pattern=%s",
|
|
240
|
+
len(matches), self.container_name, folder_prefix, file_pattern,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return sorted(matches)
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# Context Manager Support
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
def __enter__(self) -> "AzureConnector":
|
|
249
|
+
|
|
250
|
+
self.connect()
|
|
251
|
+
|
|
252
|
+
return self
|
|
253
|
+
|
|
254
|
+
def __exit__(
|
|
255
|
+
self,
|
|
256
|
+
exc_type,
|
|
257
|
+
exc_val,
|
|
258
|
+
exc_tb,
|
|
259
|
+
) -> None:
|
|
260
|
+
|
|
261
|
+
self.disconnect()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# Data types for which MIN/MAX is meaningful. Matched as a prefix against
|
|
265
|
+
# SQL Server's INFORMATION_SCHEMA.COLUMNS.DATA_TYPE values.
|
|
266
|
+
_MIN_MAX_ELIGIBLE_TYPE_PREFIXES = (
|
|
267
|
+
"tinyint",
|
|
268
|
+
"smallint",
|
|
269
|
+
"int",
|
|
270
|
+
"bigint",
|
|
271
|
+
"float",
|
|
272
|
+
"real",
|
|
273
|
+
"decimal",
|
|
274
|
+
"numeric",
|
|
275
|
+
"money",
|
|
276
|
+
"smallmoney",
|
|
277
|
+
"date",
|
|
278
|
+
"datetime",
|
|
279
|
+
"datetime2",
|
|
280
|
+
"smalldatetime",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class AzureSqlConnector:
|
|
285
|
+
"""
|
|
286
|
+
Lightweight reusable connector for Azure SQL Database.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
def __init__(
|
|
290
|
+
self,
|
|
291
|
+
server: Optional[str] = None,
|
|
292
|
+
database: Optional[str] = None,
|
|
293
|
+
username: Optional[str] = None,
|
|
294
|
+
password: Optional[str] = None,
|
|
295
|
+
) -> None:
|
|
296
|
+
"""
|
|
297
|
+
All four arguments must be resolved by the caller before
|
|
298
|
+
construction - server/database from config.azure.sql_server/
|
|
299
|
+
sql_database, username/password via
|
|
300
|
+
table_validator.auth.azure_auth.get_azure_credential(). This
|
|
301
|
+
connector does not read credentials from the environment itself.
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
self._server = server
|
|
305
|
+
self._database = database
|
|
306
|
+
self._username = username
|
|
307
|
+
self._password = password
|
|
308
|
+
|
|
309
|
+
if not self._server or not self._database:
|
|
310
|
+
raise ValueError(
|
|
311
|
+
"Azure SQL server and database are required. "
|
|
312
|
+
"Provide them via constructor arguments."
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
if not self._username or not self._password:
|
|
316
|
+
raise ValueError(
|
|
317
|
+
"Azure SQL username and password are required. "
|
|
318
|
+
"Provide them via constructor arguments."
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
self._connection: Optional[pyodbc.Connection] = None
|
|
322
|
+
|
|
323
|
+
logger.debug(
|
|
324
|
+
"AzureSqlConnector initialized for server=%s database=%s",
|
|
325
|
+
self._server, self._database,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
# Connection Lifecycle
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
def connect(self) -> None:
|
|
332
|
+
|
|
333
|
+
if self._connection is not None:
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
conn_str = (
|
|
337
|
+
"DRIVER={ODBC Driver 17 for SQL Server};"
|
|
338
|
+
f"SERVER=tcp:{self._server},1433;"
|
|
339
|
+
f"DATABASE={self._database};"
|
|
340
|
+
f"UID={self._username};"
|
|
341
|
+
f"PWD={self._password};"
|
|
342
|
+
"Encrypt=yes;TrustServerCertificate=no;Connection Timeout=30;"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
self._connection = pyodbc.connect(conn_str)
|
|
347
|
+
|
|
348
|
+
logger.info(
|
|
349
|
+
"Successfully connected to Azure SQL Database '%s' on '%s'",
|
|
350
|
+
self._database, self._server,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
except pyodbc.Error as exc:
|
|
354
|
+
self._connection = None
|
|
355
|
+
sanitized_exc = self._redact_password(str(exc))
|
|
356
|
+
logger.error(
|
|
357
|
+
"Failed to connect to Azure SQL Database: %s", sanitized_exc
|
|
358
|
+
)
|
|
359
|
+
raise ConnectionError(
|
|
360
|
+
f"Unable to connect to Azure SQL Database: {sanitized_exc}"
|
|
361
|
+
) from None
|
|
362
|
+
except Exception as exc:
|
|
363
|
+
self._connection = None
|
|
364
|
+
logger.exception("Failed to connect to Azure SQL Database")
|
|
365
|
+
raise ConnectionError(
|
|
366
|
+
f"Unable to connect to Azure SQL Database: {exc}"
|
|
367
|
+
) from exc
|
|
368
|
+
|
|
369
|
+
def disconnect(self) -> None:
|
|
370
|
+
|
|
371
|
+
if self._connection is None:
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
self._connection.close()
|
|
376
|
+
logger.info("Disconnected from Azure SQL Database")
|
|
377
|
+
except Exception as exc:
|
|
378
|
+
logger.warning("Error while closing Azure SQL connection: %s", exc)
|
|
379
|
+
finally:
|
|
380
|
+
self._connection = None
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def _redact_password(text: str) -> str:
|
|
384
|
+
"""
|
|
385
|
+
Redact a PWD=... segment from ODBC connection-string text, in case
|
|
386
|
+
the driver echoes the connection string back inside an error
|
|
387
|
+
message (some ODBC drivers do this on auth failures). Applied to
|
|
388
|
+
any pyodbc.Error raised from connect() before it is logged or
|
|
389
|
+
re-raised, so the plaintext password never reaches logs or a
|
|
390
|
+
caller's stack trace.
|
|
391
|
+
"""
|
|
392
|
+
return re.sub(r"PWD=[^;]*", "PWD=***", text, flags=re.IGNORECASE)
|
|
393
|
+
|
|
394
|
+
def test_connection(self) -> bool:
|
|
395
|
+
try:
|
|
396
|
+
self.connect()
|
|
397
|
+
self._execute_to_dataframe("SELECT 1 AS ok")
|
|
398
|
+
logger.info("Azure SQL connection test succeeded")
|
|
399
|
+
return True
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
logger.error("Azure SQL connection test failed: %s", exc)
|
|
402
|
+
return False
|
|
403
|
+
|
|
404
|
+
# ------------------------------------------------------------------
|
|
405
|
+
# Internal Helpers
|
|
406
|
+
# ------------------------------------------------------------------
|
|
407
|
+
def _ensure_connected(self) -> pyodbc.Connection:
|
|
408
|
+
if self._connection is None:
|
|
409
|
+
self.connect()
|
|
410
|
+
if self._connection is None:
|
|
411
|
+
raise ConnectionError("Azure SQL connection is not available")
|
|
412
|
+
return self._connection
|
|
413
|
+
|
|
414
|
+
def _execute_to_dataframe(self, query: str) -> pd.DataFrame:
|
|
415
|
+
connection = self._ensure_connected()
|
|
416
|
+
try:
|
|
417
|
+
cursor = connection.cursor()
|
|
418
|
+
cursor.execute(query)
|
|
419
|
+
|
|
420
|
+
if cursor.description is None:
|
|
421
|
+
cursor.close()
|
|
422
|
+
return pd.DataFrame()
|
|
423
|
+
|
|
424
|
+
columns = [desc[0] for desc in cursor.description]
|
|
425
|
+
rows = cursor.fetchall()
|
|
426
|
+
cursor.close()
|
|
427
|
+
|
|
428
|
+
return pd.DataFrame((tuple(r) for r in rows), columns=columns)
|
|
429
|
+
|
|
430
|
+
except Exception as exc:
|
|
431
|
+
logger.exception("Failed to execute query against Azure SQL Database")
|
|
432
|
+
raise RuntimeError(f"Unable to execute query: {exc}") from exc
|
|
433
|
+
|
|
434
|
+
@staticmethod
|
|
435
|
+
def _quote_ident(identifier: str) -> str:
|
|
436
|
+
"""Bracket-quote a single identifier part, escaping embedded brackets."""
|
|
437
|
+
escaped = identifier.replace("]", "]]")
|
|
438
|
+
return f"[{escaped}]"
|
|
439
|
+
|
|
440
|
+
@classmethod
|
|
441
|
+
def _qualify(cls, schema: str, table: str) -> str:
|
|
442
|
+
"""Build a schema-qualified, bracket-quoted [schema].[table] identifier."""
|
|
443
|
+
return f"{cls._quote_ident(schema)}.{cls._quote_ident(table)}"
|
|
444
|
+
|
|
445
|
+
# ------------------------------------------------------------------
|
|
446
|
+
# Generic passthrough
|
|
447
|
+
# ------------------------------------------------------------------
|
|
448
|
+
def execute_query(self, query: str) -> pd.DataFrame:
|
|
449
|
+
"""Public entry point for executing an arbitrary read-only query."""
|
|
450
|
+
return self._execute_to_dataframe(query)
|
|
451
|
+
|
|
452
|
+
# ------------------------------------------------------------------
|
|
453
|
+
# Schema / table metadata
|
|
454
|
+
# ------------------------------------------------------------------
|
|
455
|
+
def get_schemas(self) -> List[str]:
|
|
456
|
+
query = """
|
|
457
|
+
SELECT SCHEMA_NAME
|
|
458
|
+
FROM INFORMATION_SCHEMA.SCHEMATA
|
|
459
|
+
WHERE SCHEMA_NAME NOT IN (
|
|
460
|
+
'sys', 'INFORMATION_SCHEMA', 'guest', 'db_owner',
|
|
461
|
+
'db_accessadmin', 'db_securityadmin', 'db_ddladmin',
|
|
462
|
+
'db_backupoperator', 'db_datareader', 'db_datawriter',
|
|
463
|
+
'db_denydatareader', 'db_denydatawriter'
|
|
464
|
+
)
|
|
465
|
+
"""
|
|
466
|
+
try:
|
|
467
|
+
df = self._execute_to_dataframe(query)
|
|
468
|
+
except Exception as exc:
|
|
469
|
+
logger.exception("Failed to list schemas")
|
|
470
|
+
raise RuntimeError(f"Unable to list schemas: {exc}") from exc
|
|
471
|
+
|
|
472
|
+
if df.empty:
|
|
473
|
+
return []
|
|
474
|
+
return sorted(str(v) for v in df["SCHEMA_NAME"])
|
|
475
|
+
|
|
476
|
+
def get_tables(self, schema: str) -> List[str]:
|
|
477
|
+
query = f"""
|
|
478
|
+
SELECT TABLE_NAME
|
|
479
|
+
FROM INFORMATION_SCHEMA.TABLES
|
|
480
|
+
WHERE TABLE_SCHEMA = '{schema}' AND TABLE_TYPE = 'BASE TABLE'
|
|
481
|
+
"""
|
|
482
|
+
try:
|
|
483
|
+
df = self._execute_to_dataframe(query)
|
|
484
|
+
except Exception as exc:
|
|
485
|
+
logger.exception("Failed to list tables for schema '%s'", schema)
|
|
486
|
+
raise RuntimeError(f"Unable to list tables for schema '{schema}': {exc}") from exc
|
|
487
|
+
|
|
488
|
+
if df.empty:
|
|
489
|
+
return []
|
|
490
|
+
return sorted(str(v) for v in df["TABLE_NAME"])
|
|
491
|
+
|
|
492
|
+
def get_table_schema(self, schema: str, table: str) -> pd.DataFrame:
|
|
493
|
+
"""
|
|
494
|
+
Returns columns: column_name, data_type, is_nullable (bool),
|
|
495
|
+
ordinal_position.
|
|
496
|
+
"""
|
|
497
|
+
query = f"""
|
|
498
|
+
SELECT COLUMN_NAME AS column_name,
|
|
499
|
+
DATA_TYPE AS data_type,
|
|
500
|
+
IS_NULLABLE AS is_nullable,
|
|
501
|
+
ORDINAL_POSITION AS ordinal_position
|
|
502
|
+
FROM INFORMATION_SCHEMA.COLUMNS
|
|
503
|
+
WHERE TABLE_SCHEMA = '{schema}' AND TABLE_NAME = '{table}'
|
|
504
|
+
ORDER BY ORDINAL_POSITION
|
|
505
|
+
"""
|
|
506
|
+
try:
|
|
507
|
+
df = self._execute_to_dataframe(query)
|
|
508
|
+
except Exception as exc:
|
|
509
|
+
logger.exception(
|
|
510
|
+
"Failed to retrieve column metadata for '%s.%s'", schema, table
|
|
511
|
+
)
|
|
512
|
+
raise RuntimeError(
|
|
513
|
+
f"Unable to retrieve column metadata for '{schema}.{table}': {exc}"
|
|
514
|
+
) from exc
|
|
515
|
+
|
|
516
|
+
if df.empty:
|
|
517
|
+
return pd.DataFrame(
|
|
518
|
+
columns=["column_name", "data_type", "is_nullable", "ordinal_position"]
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
df["is_nullable"] = df["is_nullable"].astype(str).str.upper().eq("YES")
|
|
522
|
+
return df.reset_index(drop=True)
|
|
523
|
+
|
|
524
|
+
def get_row_count(self, schema: str, table: str) -> int:
|
|
525
|
+
query = f"SELECT COUNT(*) AS row_count FROM {self._qualify(schema, table)}"
|
|
526
|
+
try:
|
|
527
|
+
df = self._execute_to_dataframe(query)
|
|
528
|
+
except Exception as exc:
|
|
529
|
+
logger.exception("Failed to get row count for '%s.%s'", schema, table)
|
|
530
|
+
raise RuntimeError(f"Unable to get row count for '{schema}.{table}': {exc}") from exc
|
|
531
|
+
|
|
532
|
+
if df.empty:
|
|
533
|
+
return 0
|
|
534
|
+
return int(df.iloc[0]["row_count"])
|
|
535
|
+
|
|
536
|
+
def get_column_statistics(
|
|
537
|
+
self,
|
|
538
|
+
schema: str,
|
|
539
|
+
table: str,
|
|
540
|
+
columns: Sequence[str],
|
|
541
|
+
min_max_columns: Optional[Sequence[str]] = None,
|
|
542
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
543
|
+
"""
|
|
544
|
+
Single aggregate query returning null count, distinct count, and
|
|
545
|
+
(for min_max_columns) MIN/MAX for every requested column.
|
|
546
|
+
|
|
547
|
+
Returns: {column_name: {"null_count": int, "distinct_count": int,
|
|
548
|
+
"min": Any | None, "max": Any | None}}
|
|
549
|
+
"""
|
|
550
|
+
if not columns:
|
|
551
|
+
return {}
|
|
552
|
+
|
|
553
|
+
min_max_set = {c.lower() for c in (min_max_columns or [])}
|
|
554
|
+
|
|
555
|
+
select_parts = []
|
|
556
|
+
for col in columns:
|
|
557
|
+
q = self._quote_ident(col)
|
|
558
|
+
alias_nulls = self._quote_ident(f"{col}__nulls")
|
|
559
|
+
alias_distinct = self._quote_ident(f"{col}__distinct")
|
|
560
|
+
select_parts.append(f"SUM(CASE WHEN {q} IS NULL THEN 1 ELSE 0 END) AS {alias_nulls}")
|
|
561
|
+
select_parts.append(f"COUNT(DISTINCT {q}) AS {alias_distinct}")
|
|
562
|
+
if col.lower() in min_max_set:
|
|
563
|
+
alias_min = self._quote_ident(f"{col}__min")
|
|
564
|
+
alias_max = self._quote_ident(f"{col}__max")
|
|
565
|
+
select_parts.append(f"MIN({q}) AS {alias_min}")
|
|
566
|
+
select_parts.append(f"MAX({q}) AS {alias_max}")
|
|
567
|
+
|
|
568
|
+
query = f"SELECT {', '.join(select_parts)} FROM {self._qualify(schema, table)}"
|
|
569
|
+
|
|
570
|
+
try:
|
|
571
|
+
df = self._execute_to_dataframe(query)
|
|
572
|
+
except Exception as exc:
|
|
573
|
+
logger.exception(
|
|
574
|
+
"Failed to compute column statistics for '%s.%s'", schema, table
|
|
575
|
+
)
|
|
576
|
+
raise RuntimeError(
|
|
577
|
+
f"Unable to compute column statistics for '{schema}.{table}': {exc}"
|
|
578
|
+
) from exc
|
|
579
|
+
|
|
580
|
+
result: Dict[str, Dict[str, Any]] = {}
|
|
581
|
+
if df.empty:
|
|
582
|
+
return {col: {"null_count": None, "distinct_count": None,
|
|
583
|
+
"min": None, "max": None} for col in columns}
|
|
584
|
+
|
|
585
|
+
row = df.iloc[0]
|
|
586
|
+
for col in columns:
|
|
587
|
+
entry: Dict[str, Any] = {
|
|
588
|
+
"null_count": int(row.get(f"{col}__nulls"))
|
|
589
|
+
if row.get(f"{col}__nulls") is not None else None,
|
|
590
|
+
"distinct_count": int(row.get(f"{col}__distinct"))
|
|
591
|
+
if row.get(f"{col}__distinct") is not None else None,
|
|
592
|
+
"min": None,
|
|
593
|
+
"max": None,
|
|
594
|
+
}
|
|
595
|
+
if col.lower() in min_max_set:
|
|
596
|
+
entry["min"] = row.get(f"{col}__min")
|
|
597
|
+
entry["max"] = row.get(f"{col}__max")
|
|
598
|
+
result[col] = entry
|
|
599
|
+
|
|
600
|
+
return result
|
|
601
|
+
|
|
602
|
+
@staticmethod
|
|
603
|
+
def is_min_max_eligible(data_type: str) -> bool:
|
|
604
|
+
dt = (data_type or "").strip().lower()
|
|
605
|
+
return any(dt.startswith(prefix) for prefix in _MIN_MAX_ELIGIBLE_TYPE_PREFIXES)
|
|
606
|
+
|
|
607
|
+
# ------------------------------------------------------------------
|
|
608
|
+
# Row-hash comparison (pushed down via HASHBYTES)
|
|
609
|
+
# ------------------------------------------------------------------
|
|
610
|
+
def get_row_hashes(
|
|
611
|
+
self,
|
|
612
|
+
schema: str,
|
|
613
|
+
table: str,
|
|
614
|
+
columns: Sequence[str],
|
|
615
|
+
primary_key_cols: Sequence[str],
|
|
616
|
+
column_types: Optional[Dict[str, str]] = None,
|
|
617
|
+
) -> pd.DataFrame:
|
|
618
|
+
"""
|
|
619
|
+
Single push-down query returning one deterministic row hash per
|
|
620
|
+
primary key value(s), via T-SQL HASHBYTES('SHA2_256', ...).
|
|
621
|
+
|
|
622
|
+
`column_types` maps each column name (case-insensitive) to its
|
|
623
|
+
declared SQL Server data type (e.g. from get_table_schema), used
|
|
624
|
+
to format the hash-input string identically to Databricks' own
|
|
625
|
+
CAST(col AS STRING) convention for the equivalent value:
|
|
626
|
+
- float/real -> CAST to FLOAT then formatted so whole numbers
|
|
627
|
+
keep a trailing ".0" (matches Databricks' `double` string
|
|
628
|
+
form; verified empirically, see azure_sql_validator.py).
|
|
629
|
+
- decimal/numeric/money/smallmoney -> CONVERT(VARCHAR, x, 2)
|
|
630
|
+
(fixed, non-scientific decimal notation).
|
|
631
|
+
- the synthetic type "decimal_as_integer" -> caller override for
|
|
632
|
+
when the TARGET column is an integer type even though this
|
|
633
|
+
side is decimal/numeric/money: formats without decimal places
|
|
634
|
+
so equal whole values hash identically instead of every row
|
|
635
|
+
differing purely from the type mismatch (see
|
|
636
|
+
AzureSqlValidator._effective_source_types).
|
|
637
|
+
- date -> CONVERT(VARCHAR, x, 23) ('YYYY-MM-DD').
|
|
638
|
+
- datetime/datetime2/smalldatetime -> CONVERT(VARCHAR, x, 126)
|
|
639
|
+
(ISO 8601).
|
|
640
|
+
- everything else (including when the type is unknown) ->
|
|
641
|
+
CAST(col AS NVARCHAR(MAX)).
|
|
642
|
+
|
|
643
|
+
Returns a DataFrame with one row per primary key: the key
|
|
644
|
+
column(s) plus `row_hash` (lowercase hex string, to match
|
|
645
|
+
Databricks' sha2() output format).
|
|
646
|
+
"""
|
|
647
|
+
if not primary_key_cols:
|
|
648
|
+
raise ValueError("primary_key_cols must be non-empty")
|
|
649
|
+
|
|
650
|
+
key_list = ", ".join(self._quote_ident(k) for k in primary_key_cols)
|
|
651
|
+
null_sentinel = "\x01NULL\x01"
|
|
652
|
+
types_lower = {k.lower(): v for k, v in (column_types or {}).items()}
|
|
653
|
+
|
|
654
|
+
hashed_exprs = [
|
|
655
|
+
f"ISNULL({self._hash_string_expr(c, types_lower.get(c.lower(), ''))}, '{null_sentinel}')"
|
|
656
|
+
for c in columns
|
|
657
|
+
]
|
|
658
|
+
|
|
659
|
+
if hashed_exprs:
|
|
660
|
+
concat_expr = " + '||' + ".join(hashed_exprs)
|
|
661
|
+
row_hash_expr = (
|
|
662
|
+
f"LOWER(CONVERT(VARCHAR(64), "
|
|
663
|
+
f"HASHBYTES('SHA2_256', {concat_expr}), 2))"
|
|
664
|
+
)
|
|
665
|
+
else:
|
|
666
|
+
row_hash_expr = (
|
|
667
|
+
f"LOWER(CONVERT(VARCHAR(64), "
|
|
668
|
+
f"HASHBYTES('SHA2_256', '{null_sentinel}'), 2))"
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
query = f"""
|
|
672
|
+
SELECT {key_list}, {row_hash_expr} AS row_hash
|
|
673
|
+
FROM {self._qualify(schema, table)}
|
|
674
|
+
"""
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
df = self._execute_to_dataframe(query)
|
|
678
|
+
except Exception as exc:
|
|
679
|
+
logger.exception("Failed to compute row hashes for '%s.%s'", schema, table)
|
|
680
|
+
raise RuntimeError(
|
|
681
|
+
f"Unable to compute row hashes for '{schema}.{table}': {exc}"
|
|
682
|
+
) from exc
|
|
683
|
+
|
|
684
|
+
if df.empty:
|
|
685
|
+
return pd.DataFrame(columns=list(primary_key_cols) + ["row_hash"])
|
|
686
|
+
|
|
687
|
+
return df
|
|
688
|
+
|
|
689
|
+
def get_row_hashes_by_row_number(
|
|
690
|
+
self,
|
|
691
|
+
schema: str,
|
|
692
|
+
table: str,
|
|
693
|
+
columns: Sequence[str],
|
|
694
|
+
column_types: Optional[Dict[str, str]] = None,
|
|
695
|
+
) -> pd.DataFrame:
|
|
696
|
+
"""
|
|
697
|
+
Fallback for tables with no configured primary key: assigns a
|
|
698
|
+
synthetic row number via ROW_NUMBER() OVER (ORDER BY <every
|
|
699
|
+
requested column>) and hashes each row, mirroring
|
|
700
|
+
DatabricksConnector.get_row_hashes_by_row_number so both sides can
|
|
701
|
+
be compared the same way when no real shared key exists. See that
|
|
702
|
+
method's docstring for the caveat about what row-number matching
|
|
703
|
+
can and cannot detect.
|
|
704
|
+
|
|
705
|
+
Returns a DataFrame with columns: row_number, row_hash.
|
|
706
|
+
"""
|
|
707
|
+
if not columns:
|
|
708
|
+
raise ValueError("columns must be non-empty for row-number based hashing")
|
|
709
|
+
|
|
710
|
+
null_sentinel = "\x01NULL\x01"
|
|
711
|
+
types_lower = {k.lower(): v for k, v in (column_types or {}).items()}
|
|
712
|
+
|
|
713
|
+
hashed_exprs = [
|
|
714
|
+
f"ISNULL({self._hash_string_expr(c, types_lower.get(c.lower(), ''))}, '{null_sentinel}')"
|
|
715
|
+
for c in columns
|
|
716
|
+
]
|
|
717
|
+
order_by = ", ".join(self._quote_ident(c) for c in columns)
|
|
718
|
+
concat_expr = " + '||' + ".join(hashed_exprs)
|
|
719
|
+
row_hash_expr = (
|
|
720
|
+
f"LOWER(CONVERT(VARCHAR(64), HASHBYTES('SHA2_256', {concat_expr}), 2))"
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
query = f"""
|
|
724
|
+
SELECT
|
|
725
|
+
ROW_NUMBER() OVER (ORDER BY {order_by}) AS row_number,
|
|
726
|
+
{row_hash_expr} AS row_hash
|
|
727
|
+
FROM {self._qualify(schema, table)}
|
|
728
|
+
"""
|
|
729
|
+
|
|
730
|
+
try:
|
|
731
|
+
df = self._execute_to_dataframe(query)
|
|
732
|
+
except Exception as exc:
|
|
733
|
+
logger.exception(
|
|
734
|
+
"Failed to compute row-number-based hashes for '%s.%s'", schema, table
|
|
735
|
+
)
|
|
736
|
+
raise RuntimeError(
|
|
737
|
+
f"Unable to compute row-number-based hashes for '{schema}.{table}': {exc}"
|
|
738
|
+
) from exc
|
|
739
|
+
|
|
740
|
+
if df.empty:
|
|
741
|
+
return pd.DataFrame(columns=["row_number", "row_hash"])
|
|
742
|
+
|
|
743
|
+
return df
|
|
744
|
+
|
|
745
|
+
def _hash_string_expr(self, column: str, data_type: str) -> str:
|
|
746
|
+
"""
|
|
747
|
+
Build the SQL expression that converts one column to its hash-input
|
|
748
|
+
string form, keyed off the column's declared SQL Server type so it
|
|
749
|
+
matches Databricks' CAST(col AS STRING) output for the equivalent
|
|
750
|
+
value (see get_row_hashes docstring for the per-type rules).
|
|
751
|
+
"""
|
|
752
|
+
q = self._quote_ident(column)
|
|
753
|
+
dt = (data_type or "").strip().lower()
|
|
754
|
+
|
|
755
|
+
if dt in ("float", "real"):
|
|
756
|
+
# SQL Server's default CAST(float AS NVARCHAR) can use
|
|
757
|
+
# scientific notation and doesn't guarantee a trailing ".0"
|
|
758
|
+
# for whole numbers the way Databricks' double->string does.
|
|
759
|
+
# STR(x, 30, 10) then trimming trailing zeros (keeping at
|
|
760
|
+
# least one decimal digit) reproduces that convention.
|
|
761
|
+
return (
|
|
762
|
+
f"CASE WHEN {q} IS NULL THEN NULL ELSE "
|
|
763
|
+
f"CASE WHEN {q} = ROUND({q}, 0) THEN "
|
|
764
|
+
f"CONVERT(VARCHAR(30), CAST({q} AS BIGINT)) + '.0' "
|
|
765
|
+
f"ELSE LTRIM(RTRIM(STR({q}, 30, 10))) END END"
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
if dt == "decimal_as_integer":
|
|
769
|
+
# Caller-requested override: the target column is an integer
|
|
770
|
+
# type even though this side is decimal/numeric/money - format
|
|
771
|
+
# without decimal places (dropping a fractional remainder, if
|
|
772
|
+
# any) so numerically-equal whole values hash identically
|
|
773
|
+
# instead of every row appearing changed purely due to the
|
|
774
|
+
# type mismatch. A genuinely fractional value here means a
|
|
775
|
+
# real precision loss versus the integer target, which still
|
|
776
|
+
# surfaces correctly since the fraction is truncated on both
|
|
777
|
+
# sides' comparison via values_differ() at the detail stage.
|
|
778
|
+
return f"CONVERT(VARCHAR(50), CAST({q} AS BIGINT))"
|
|
779
|
+
|
|
780
|
+
if dt in ("decimal", "numeric", "money", "smallmoney"):
|
|
781
|
+
return f"CONVERT(VARCHAR(50), {q}, 2)"
|
|
782
|
+
|
|
783
|
+
if dt == "date":
|
|
784
|
+
return f"CONVERT(VARCHAR(10), {q}, 23)"
|
|
785
|
+
|
|
786
|
+
if dt in ("datetime", "datetime2", "smalldatetime"):
|
|
787
|
+
return f"CONVERT(VARCHAR(33), {q}, 126)"
|
|
788
|
+
|
|
789
|
+
# VARCHAR, not NVARCHAR: HASHBYTES hashes raw bytes, and
|
|
790
|
+
# NVARCHAR is UTF-16 (2 bytes/char) while Databricks' CAST(col AS
|
|
791
|
+
# STRING) is UTF-8 (1 byte/char for ASCII) - identical text would
|
|
792
|
+
# otherwise hash completely differently between the two sides
|
|
793
|
+
# (verified empirically: same input string, NVARCHAR cast gave a
|
|
794
|
+
# column twice the byte length of the matching VARCHAR/UTF-8 form,
|
|
795
|
+
# and a different hash). ASCII-only: VARCHAR uses a single-byte
|
|
796
|
+
# codepage, so genuine non-ASCII characters (accents, non-Latin
|
|
797
|
+
# scripts) will NOT hash-match Databricks' UTF-8 form under this
|
|
798
|
+
# cast - re-verify before trusting row-hash results on Unicode text.
|
|
799
|
+
return f"CAST({q} AS VARCHAR(MAX))"
|
|
800
|
+
|
|
801
|
+
# ------------------------------------------------------------------
|
|
802
|
+
# Context Manager Support
|
|
803
|
+
# ------------------------------------------------------------------
|
|
804
|
+
def __enter__(self) -> "AzureSqlConnector":
|
|
805
|
+
self.connect()
|
|
806
|
+
return self
|
|
807
|
+
|
|
808
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
809
|
+
self.disconnect()
|