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.
@@ -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()